├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── conf.py ├── discussion │ ├── gcra.rst │ └── index.rst ├── how_to_guides │ ├── configuration.rst │ ├── exempt.rst │ ├── global_limits.rst │ ├── index.rst │ ├── key.rst │ ├── skip.rst │ └── store.rst ├── index.rst ├── make.bat ├── reference │ ├── api.rst │ └── index.rst └── tutorials │ ├── index.rst │ ├── installation.rst │ └── quickstart.rst ├── pyproject.toml ├── setup.cfg ├── src └── quart_rate_limiter │ ├── __init__.py │ ├── py.typed │ ├── redis_store.py │ ├── store.py │ └── valkey_store.py ├── tests ├── conftest.py └── test_basic.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | tox: 11 | name: ${{ matrix.name }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - {name: '3.13', python: '3.13', tox: py313} 18 | - {name: '3.12', python: '3.12', tox: py312} 19 | - {name: '3.11', python: '3.11', tox: py311} 20 | - {name: '3.10', python: '3.10', tox: py310} 21 | - {name: '3.9', python: '3.9', tox: py39} 22 | - {name: 'docs', python: '3.13', tox: docs} 23 | - {name: 'format', python: '3.13', tox: format} 24 | - {name: 'mypy', python: '3.13', tox: mypy} 25 | - {name: 'pep8', python: '3.13', tox: pep8} 26 | - {name: 'package', python: '3.13', tox: package} 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python }} 34 | 35 | - run: pip install tox 36 | 37 | - run: tox -e ${{ matrix.tox }} 38 | 39 | redis-tox: 40 | runs-on: ubuntu-latest 41 | 42 | container: python:3.13 43 | 44 | services: 45 | redis: 46 | image: redis 47 | options: >- 48 | --health-cmd "redis-cli ping" 49 | --health-interval 10s 50 | --health-timeout 5s 51 | --health-retries 5 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | 56 | - run: pip install tox 57 | 58 | - run: tox -e redis 59 | 60 | valkey-tox: 61 | runs-on: ubuntu-latest 62 | 63 | container: python:3.13 64 | 65 | services: 66 | valkey: 67 | image: valkey/valkey 68 | options: >- 69 | --health-cmd "valkey-cli ping" 70 | --health-interval 10s 71 | --health-timeout 5s 72 | --health-retries 5 73 | 74 | steps: 75 | - uses: actions/checkout@v4 76 | 77 | - run: pip install tox 78 | 79 | - run: tox -e valkey 80 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - uses: actions/setup-python@v5 13 | with: 14 | python-version: 3.13 15 | 16 | - run: | 17 | pip install pdm 18 | pdm build 19 | - uses: actions/upload-artifact@v4 20 | with: 21 | path: ./dist 22 | 23 | pypi-publish: 24 | needs: ['build'] 25 | environment: 'publish' 26 | 27 | name: upload release to PyPI 28 | runs-on: ubuntu-latest 29 | permissions: 30 | # IMPORTANT: this permission is mandatory for trusted publishing 31 | id-token: write 32 | steps: 33 | - uses: actions/download-artifact@v4 34 | 35 | - name: Publish package distributions to PyPI 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | packages-dir: artifact/ 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | venv/ 3 | __pycache__/ 4 | Quart_Rate_Limiter.egg-info/ 5 | .cache/ 6 | .tox/ 7 | TODO 8 | .mypy_cache/ 9 | docs/_build/ 10 | docs/reference/source/ 11 | .coverage 12 | .pytest_cache/ 13 | dist/ 14 | pdm.lock 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3.13" 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | 15 | sphinx: 16 | configuration: docs/conf.py 17 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 0.12.0 2025-05-31 2 | ----------------- 3 | 4 | * Support Valkey as an alternative store. 5 | * Switch from utcnow to now(UTC) to remove deprecation warnings. 6 | 7 | 0.11.0 2024-12-24 8 | ----------------- 9 | 10 | * Bugfix don't continually extend limits with blueprint/defaults. 11 | * Support Python 3.13, drop Python 3.8. 12 | 13 | 0.10.0 2024-05-19 14 | ----------------- 15 | 16 | * Add the ability to skip rate limits via a skip_function. 17 | 18 | 0.9.0 2023-10-07 19 | ---------------- 20 | 21 | * Officially support Python 3.12 drop Python 3.7. 22 | * Support Quart 0.19 onwards. 23 | 24 | 0.8.0 2023-01-21 25 | ---------------- 26 | 27 | * Use redis rather than aioredis as the two have merged. 28 | * Bugfix ensure the Content-Type header is present. 29 | * Improve the typing for better type checking. 30 | * Officially support Python 3.10, and Python 3.11. 31 | 32 | 0.7.0 2022-04-04 33 | ---------------- 34 | 35 | * Support an enabled flag, ``QUART_RATE_LIMITER_ENABLED`` to disable 36 | all rate limiting (meant for testing). 37 | 38 | 0.6.0 2021-09-04 39 | ---------------- 40 | 41 | * Support aioredis >= 2.0. 42 | * Switch from remote_addr to access_route[0] for the remote key as the 43 | latter is correct when proxies are involved. 44 | 45 | 0.5.0 2021-05-11 46 | ---------------- 47 | 48 | * Support Quart 0.15 as the minimum version. 49 | 50 | 0.4.1 2021-04-10 51 | ---------------- 52 | 53 | * Bugfix cast Redis result to float. 54 | 55 | 0.4.0 2020-03-29 56 | ---------------- 57 | 58 | * Allow routes to be marked as rate exempt. 59 | * Bugfix redis storage type. 60 | 61 | 0.3.0 2020-03-07 62 | ---------------- 63 | 64 | * Add optional default limits to be applied to all routes. 65 | * Allow for an entire blueprint to be limited. 66 | * Allow a list of limits when adding rate limits (rather than using 67 | multiple decorators). 68 | 69 | 0.2.0 2020-02-09 70 | ---------------- 71 | 72 | * Support Python 3.8. 73 | * RateLimitExceeded now inherits from TooManyRequests in Quart. 74 | 75 | 0.1.0 2019-09-15 76 | ---------------- 77 | 78 | * Released initial alpha version. 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright P G Jones 2019. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Quart-Rate-Limiter 2 | ================== 3 | 4 | |Build Status| |docs| |pypi| |python| |license| 5 | 6 | Quart-Rate-Limiter is an extension for `Quart 7 | `_ to allow for rate limits to be 8 | defined and enforced on a per route basis. The 429 error response 9 | includes a `RFC7231 10 | `_ compliant 11 | ``Retry-After`` header and the successful responses contain headers 12 | compliant with the `RateLimit Header Fields for HTTP 13 | `_ RFC 14 | draft. 15 | 16 | Quickstart 17 | ---------- 18 | 19 | To add a rate limit first initialise the RateLimiting extension with 20 | the application, and then rate limit the route, 21 | 22 | .. code-block:: python 23 | 24 | app = Quart(__name__) 25 | rate_limiter = RateLimiter(app) 26 | 27 | @app.get('/') 28 | @rate_limit(1, timedelta(seconds=10)) 29 | async def handler(): 30 | ... 31 | 32 | Simple examples 33 | ~~~~~~~~~~~~~~~ 34 | 35 | To limit a route to 1 request per second and a maximum of 20 per minute, 36 | 37 | .. code-block:: python 38 | 39 | @app.route('/') 40 | @rate_limit(1, timedelta(seconds=1)) 41 | @rate_limit(20, timedelta(minutes=1)) 42 | async def handler(): 43 | ... 44 | 45 | Alternatively the ``limits`` argument can be used for multiple limits, 46 | 47 | .. code-block:: python 48 | 49 | @app.route('/') 50 | @rate_limit( 51 | limits=[ 52 | RateLimit(1, timedelta(seconds=1)), 53 | RateLimit(20, timedelta(minutes=1)), 54 | ], 55 | ) 56 | async def handler(): 57 | ... 58 | 59 | To identify remote users based on their authentication ID, rather than 60 | their IP, 61 | 62 | .. code-block:: python 63 | 64 | async def key_function(): 65 | return current_user.id 66 | 67 | RateLimiter(app, key_function=key_function) 68 | 69 | The ``key_function`` is a coroutine function to allow session lookups 70 | if appropriate. 71 | 72 | Contributing 73 | ------------ 74 | 75 | Quart-Rate-Limiter is developed on `GitHub 76 | `_. You are very welcome to 77 | open `issues `_ or 78 | propose `merge requests 79 | `_. 80 | 81 | Testing 82 | ~~~~~~~ 83 | 84 | The best way to test Quart-Rate-Limiter is with Tox, 85 | 86 | .. code-block:: console 87 | 88 | $ pip install tox 89 | $ tox 90 | 91 | this will check the code style and run the tests. 92 | 93 | Help 94 | ---- 95 | 96 | The Quart-Rate-Limiter `documentation 97 | `_ is the best 98 | places to start, after that try searching `stack overflow 99 | `_ or ask for help 100 | `on gitter `_. If you still 101 | can't find an answer please `open an issue 102 | `_. 103 | 104 | 105 | .. |Build Status| image:: https://github.com/pgjones/quart-rate-limiter/actions/workflows/ci.yml/badge.svg 106 | :target: https://github.com/pgjones/quart-rate-limiter/commits/main 107 | 108 | .. |docs| image:: https://readthedocs.org/projects/quart-rate-limiter/badge/?version=latest&style=flat 109 | :target: https://quart-rate-limiter.readthedocs.io/en/latest/ 110 | 111 | .. |pypi| image:: https://img.shields.io/pypi/v/quart-rate-limiter.svg 112 | :target: https://pypi.python.org/pypi/Quart-Rate-Limiter/ 113 | 114 | .. |python| image:: https://img.shields.io/pypi/pyversions/quart-rate-limiter.svg 115 | :target: https://pypi.python.org/pypi/Quart-Rate-Limiter/ 116 | 117 | .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg 118 | :target: https://github.com/pgjones/quart-rate-limiter/blob/main/LICENSE 119 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | import os 21 | import sys 22 | from importlib.metadata import version as imp_version 23 | sys.path.insert(0, os.path.abspath('../')) 24 | 25 | 26 | project = 'Quart-Rate-Limiter' 27 | copyright = '2019, Philip Jones' 28 | author = 'Philip Jones' 29 | version = imp_version("quart-rate-limiter") 30 | release = version 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] 38 | 39 | source_suffix = '.rst' 40 | 41 | # List of patterns, relative to source directory, that match files and 42 | # directories to ignore when looking for source files. 43 | # This pattern also affects html_static_path and html_extra_path. 44 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 45 | 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | 49 | # The theme to use for HTML and HTML Help pages. See the documentation for 50 | # a list of builtin themes. 51 | # 52 | html_theme = "pydata_sphinx_theme" 53 | 54 | html_theme_options = { 55 | "external_links": [ 56 | {"name": "Source code", "url": "https://github.com/pgjones/quart-rate-limiter"}, 57 | {"name": "Issues", "url": "https://github.com/pgjones/quart-rate-limiter/issues"}, 58 | ], 59 | "icon_links": [ 60 | { 61 | "name": "Github", 62 | "url": "https://github.com/pgjones/quart-rate-limiter", 63 | "icon": "fab fa-github", 64 | }, 65 | ], 66 | } 67 | -------------------------------------------------------------------------------- /docs/discussion/gcra.rst: -------------------------------------------------------------------------------- 1 | Generic Cell Rate Algorithm 2 | =========================== 3 | 4 | I think GCRA is rarely used because of its perceived complexity and 5 | relative obscurity. However, it is a perfect algorithm for rate 6 | limiting and Quart-Rate-Limiter uses it. 7 | -------------------------------------------------------------------------------- /docs/discussion/index.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Discussions 3 | =========== 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | gcra.rst 9 | -------------------------------------------------------------------------------- /docs/how_to_guides/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuring Quart-Rate-Limiter 2 | ============================== 3 | 4 | The following configuration options are used by 5 | Quart-Rate-Limiter. They should be set as part of the standard `Quart 6 | configuration 7 | `_. 8 | 9 | =========================== ============ ================ 10 | Configuration key type default 11 | --------------------------- ------------ ---------------- 12 | QUART_RATE_LIMITER_ENABLED bool True 13 | =========================== ============ ================ 14 | 15 | ``QUART_RATE_LIMITER_ENABLED`` is most useful for testing as it 16 | prevents rate limits, which may depend on test timing, from mistakenly 17 | failing tests. 18 | -------------------------------------------------------------------------------- /docs/how_to_guides/exempt.rst: -------------------------------------------------------------------------------- 1 | Exempting routes 2 | ================ 3 | 4 | You may decide some of your routes should be exempt from rate 5 | limits. To do so use the :func:`~quart_rate_limiter.rate_exempt` 6 | decorator, 7 | 8 | .. code-block:: python 9 | 10 | from quart_rate_limiter import rate_exempt 11 | 12 | @app.get("/exempt") 13 | @rate_exempt 14 | async def handler(): 15 | ... 16 | -------------------------------------------------------------------------------- /docs/how_to_guides/global_limits.rst: -------------------------------------------------------------------------------- 1 | App wide limits 2 | =============== 3 | 4 | It is often useful to define limits that should apply to every route 5 | in your app by default. This removes the need to decorate every route 6 | individually. To do so use the ``default_limits`` argument, 7 | 8 | .. code-block:: python 9 | 10 | RateLimiter( 11 | app, 12 | default_limits=[ 13 | RateLimit(1, timedelta(seconds=1)), 14 | RateLimit(20, timedelta(minutes=1)), 15 | ], 16 | ) 17 | 18 | Blueprint wide limits 19 | --------------------- 20 | 21 | Alternatively you may want to set limits for all routes in a blueprint 22 | using :func:`~quart_rate_limiter.limit_blueprint`, 23 | 24 | .. code-block:: python 25 | 26 | from quart_rate_limiter import limit_blueprint 27 | 28 | blueprint = Blueprint("name", __name__) 29 | limit_blueprint(blueprint, 10, timedelta(seconds=10)) 30 | 31 | .. warning:: 32 | 33 | These limits apply to the blueprint only and not any nested 34 | blueprints. 35 | -------------------------------------------------------------------------------- /docs/how_to_guides/index.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | How to guides 3 | ============= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | configuration.rst 9 | exempt.rst 10 | global_limits.rst 11 | key.rst 12 | skip.rst 13 | store.rst 14 | -------------------------------------------------------------------------------- /docs/how_to_guides/key.rst: -------------------------------------------------------------------------------- 1 | Changing the key 2 | ================ 3 | 4 | A rate limit such as ``RateLimit(1, timedelta(seconds=1))`` means that 5 | each key can only make one 1 request per seond. By default this key is 6 | based on the IP address of the requester as per the 7 | :func:`~quart_rate_limiter.remote_addr_key`. This can be changed to 8 | key on whatever you'd like by providing a ``key_function`` to the 9 | :class:`~quart_rate_limiter.RateLimiter` or more specifically to each 10 | :class:`~quart_rate_limiter.RateLimit`. 11 | 12 | 13 | A common example is to key on the authenticated user's ID, which is 14 | shown below using `Quart Auth`_, 15 | 16 | .. code-block:: python 17 | 18 | from quart_auth import current_user 19 | from quart_rate_limiter import RateLimiter, remote_addr_key 20 | 21 | async def auth_key_function() -> str: 22 | if await current_user.is_authenticated: 23 | return current_user.auth_id 24 | else: 25 | return await remote_addr_key 26 | 27 | RateLimiter(app, key_function=auth_key_function) 28 | -------------------------------------------------------------------------------- /docs/how_to_guides/skip.rst: -------------------------------------------------------------------------------- 1 | Skipping limits 2 | =============== 3 | 4 | It is often useful to have higher limits for authenticated users which 5 | requires the default lower limit to be skipped. This is possible by 6 | supplying a ``skip_function``, as shown below using `Quart 7 | Auth`_, 8 | 9 | .. code-block:: python 10 | 11 | from quart_auth import current_user 12 | 13 | async def skip_authenticated() -> bool: 14 | return await current_user.is_authenticated 15 | 16 | RateLimiter( 17 | app, 18 | default_limits=[ 19 | RateLimit(1, timedelta(seconds=1), skip_function=skip_authenticated), 20 | RateLimit(20, timedelta(seconds=1)), 21 | ], 22 | ) 23 | 24 | Skipping static routes 25 | ---------------------- 26 | 27 | Another common use case is to skip limits for the static serving 28 | routes via the following, 29 | 30 | .. code-block:: python 31 | 32 | from quart import request 33 | 34 | from quart_auth import RateLimiter 35 | 36 | async def _skip_static() -> bool: 37 | return request.endpoint.endswith("static") 38 | 39 | RateLimiter(app, skip_function=_skip_static) 40 | -------------------------------------------------------------------------------- /docs/how_to_guides/store.rst: -------------------------------------------------------------------------------- 1 | Changing the store 2 | ================== 3 | 4 | By default Quart-Rate-Limiter stores the active limits in memory. This 5 | means that each instance of the app tracks it's own limits and hence 6 | multiple instances together will allow a higher overall limit. To 7 | avoid this you can centralise the store so that all instances use the 8 | same store and hence limits. 9 | 10 | Redis store 11 | ----------- 12 | 13 | Quart-Rate-Limiter has a builtin interface to use a redis store which 14 | can be used after installing Quart-Rate-Limiter with the ``redis`` 15 | extension, ``pip install quart-rate-limiter[redis]``, as so, 16 | 17 | .. code-block:: python 18 | 19 | from quart_rate_limiter.redis_store import RedisStore 20 | 21 | redis_store = RedisStore("address") 22 | RateLimiter(app, store=redis_store) 23 | 24 | 25 | Valkey store 26 | ------------ 27 | 28 | Quart-Rate-Limiter has a builtin interface to use a valkey store which 29 | can be used after installing Quart-Rate-Limiter with the ``valkey`` 30 | extension, ``pip install quart-rate-limiter[valkey]``, as so, 31 | 32 | .. code-block:: python 33 | 34 | from quart_rate_limiter.valkey_store import ValkeyStore 35 | 36 | valkey_store = ValkeyStore("address") 37 | RateLimiter(app, store=valkey_store) 38 | 39 | Custom store 40 | ------------ 41 | 42 | Alternatively you can use a custom storage location by implementing 43 | the :class:`~quart_rate_limiter.store.RateLimiterStoreABC` abstract 44 | base class and then providing an instance of it to the ``RateLimiter`` 45 | on construction as above. The 46 | :class:`~quart_rate_limiter.redis_store.RedisStore` likely provides a 47 | good example. 48 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. title:: Quart-Rate-Limiter documentation 4 | 5 | Quart-Rate-Limiter 6 | ================== 7 | 8 | Quart-Rate-Limiter is an extension for `Quart 9 | `_ to allow for rate limits to be 10 | defined and enforced on a per route basis. The 429 error response 11 | includes a `RFC7231 12 | `_ compliant 13 | ``Retry-After`` header and the successful responses contain headers 14 | compliant with the `RateLimit Header Fields for HTTP 15 | `_ RFC 16 | draft. 17 | 18 | If you are, 19 | 20 | * new to Quart-Rate-Limiter then try the :ref:`quickstart`, 21 | * new to Quart then try the `Quart documentation 22 | `_, 23 | 24 | Quart-Rate-Limiter is developed on `GitHub 25 | `_. If you come across 26 | an issue, or have a feature request please open an `issue 27 | `_. If you want 28 | to contribute a fix or the feature-implementation please do (typo 29 | fixes welcome), by proposing a `merge request 30 | `_. If 31 | you want to ask for help try `on gitter 32 | `_. 33 | 34 | Tutorials 35 | --------- 36 | 37 | .. toctree:: 38 | :maxdepth: 2 39 | 40 | tutorials/index.rst 41 | 42 | How to guides 43 | ------------- 44 | 45 | .. toctree:: 46 | :maxdepth: 2 47 | 48 | how_to_guides/index.rst 49 | 50 | Discussion 51 | ---------- 52 | 53 | .. toctree:: 54 | :maxdepth: 2 55 | 56 | discussion/index.rst 57 | 58 | References 59 | ---------- 60 | 61 | .. toctree:: 62 | :maxdepth: 2 63 | 64 | reference/index.rst 65 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/reference/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | source/modules.rst 9 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Reference 3 | ========= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | api.rst 9 | -------------------------------------------------------------------------------- /docs/tutorials/index.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Tutorials 3 | ========= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | installation.rst 9 | quickstart.rst 10 | -------------------------------------------------------------------------------- /docs/tutorials/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | Quart-Rate-Limiter is only compatible with Python 3.9 or higher and 7 | can be installed using pip or your favorite python package manager. 8 | 9 | .. code-block:: sh 10 | 11 | pip install quart-rate-limiter 12 | 13 | Installing quart-rate-limiter will install Quart if it is not present 14 | in your environment. 15 | -------------------------------------------------------------------------------- /docs/tutorials/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | Quart-Tasks is used by associating it with an app and then decorating 7 | routes you'd like to rate limit, 8 | 9 | .. code-block:: python 10 | 11 | from quart import Quart 12 | from quart_rate_limiter import RateLimiter, rate_limit 13 | 14 | app = Quart(__name__) 15 | rate_limiter = RateLimiter(app) 16 | 17 | @app.route('/') 18 | @rate_limit(1, timedelta(seconds=10)) 19 | async def handler(): 20 | ... 21 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "quart-rate-limiter" 3 | version = "0.12.0" 4 | description = "A Quart extension to provide rate limiting support" 5 | authors = [ 6 | {name = "pgjones", email = "philip.graham.jones@googlemail.com"}, 7 | ] 8 | classifiers = [ 9 | "Development Status :: 3 - Alpha", 10 | "Environment :: Web Environment", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | include = ["src/quart_rate_limiter/py.typed"] 25 | license = {text = "MIT"} 26 | readme = "README.rst" 27 | repository = "https://github.com/pgjones/quart-rate-limiter/" 28 | dependencies = [ 29 | "quart >= 0.19", 30 | ] 31 | requires-python = ">=3.9" 32 | 33 | [project.optional-dependencies] 34 | docs = ["pydata_sphinx_theme"] 35 | redis = ["redis >= 4.4.0"] 36 | valkey = ["valkey"] 37 | 38 | [tool.black] 39 | line-length = 100 40 | target-version = ["py39"] 41 | 42 | [tool.isort] 43 | combine_as_imports = true 44 | force_grid_wrap = 0 45 | include_trailing_comma = true 46 | known_first_party = "quart_rate_limiter, tests" 47 | line_length = 100 48 | multi_line_output = 3 49 | no_lines_before = "LOCALFOLDER" 50 | order_by_type = false 51 | reverse_relative = true 52 | 53 | [tool.mypy] 54 | allow_redefinition = true 55 | disallow_any_generics = false 56 | disallow_subclassing_any = true 57 | disallow_untyped_calls = false 58 | disallow_untyped_defs = true 59 | implicit_reexport = true 60 | no_implicit_optional = true 61 | show_error_codes = true 62 | strict = true 63 | strict_equality = true 64 | strict_optional = false 65 | warn_redundant_casts = true 66 | warn_return_any = false 67 | warn_unused_configs = true 68 | warn_unused_ignores = true 69 | 70 | [[tool.mypy.overrides]] 71 | module =["redis.*"] 72 | ignore_missing_imports = true 73 | 74 | [tool.pytest.ini_options] 75 | addopts = "--no-cov-on-fail --showlocals --strict-markers" 76 | asyncio_default_fixture_loop_scope = "function" 77 | asyncio_mode = "auto" 78 | testpaths = ["tests"] 79 | 80 | [build-system] 81 | requires = ["pdm-backend"] 82 | build-backend = "pdm.backend" 83 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E252, W503, W504 3 | max_line_length = 100 4 | -------------------------------------------------------------------------------- /src/quart_rate_limiter/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from dataclasses import dataclass 3 | from datetime import datetime, timedelta, timezone 4 | from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, TypeVar, Union 5 | 6 | from flask.sansio.blueprints import Blueprint 7 | from quart import current_app, Quart, request, Response 8 | from quart.typing import RouteCallable, WebsocketCallable 9 | from werkzeug.exceptions import TooManyRequests 10 | 11 | from .store import MemoryStore, RateLimiterStoreABC 12 | 13 | UTC = timezone.utc # Replace with direct import when 3.9 EoL 14 | QUART_RATE_LIMITER_LIMITS_ATTRIBUTE = "_quart_rate_limiter_limits" 15 | QUART_RATE_LIMITER_EXEMPT_ATTRIBUTE = "_quart_rate_limiter_exempt" 16 | 17 | KeyCallable = Callable[[], Awaitable[str]] 18 | SkipCallable = Callable[[], Awaitable[bool]] 19 | 20 | 21 | class RateLimitExceeded(TooManyRequests): 22 | """A 429 Rate limit exceeded error. 23 | 24 | Arguments: 25 | retry_after: Seconds left till the remaining resets to the limit. 26 | """ 27 | 28 | def __init__(self, retry_after: int) -> None: 29 | super().__init__() 30 | self.retry_after = retry_after 31 | 32 | def get_headers(self, *args: Any) -> List[Tuple[str, str]]: 33 | headers = super().get_headers(*args) 34 | headers.append(("Retry-After", str(self.retry_after))) 35 | return headers 36 | 37 | 38 | @dataclass 39 | class RateLimit: 40 | count: int 41 | period: timedelta 42 | key_function: Optional[KeyCallable] = None 43 | skip_function: Optional[SkipCallable] = None 44 | 45 | @property 46 | def inverse(self) -> float: 47 | return self.period.total_seconds() / self.count 48 | 49 | @property 50 | def key(self) -> str: 51 | return f"{self.count}-{self.period.total_seconds()}" 52 | 53 | 54 | T = TypeVar("T", bound=Union[RouteCallable, WebsocketCallable]) 55 | 56 | 57 | def rate_limit( 58 | limit: Optional[int] = None, 59 | period: Optional[timedelta] = None, 60 | key_function: Optional[KeyCallable] = None, 61 | skip_function: Optional[SkipCallable] = None, 62 | *, 63 | limits: Optional[List[RateLimit]] = None, 64 | ) -> Callable[[T], T]: 65 | """A decorator to add a rate limit marker to the route. 66 | 67 | This should be used to wrap a route handler (or view function) to 68 | apply a rate limit to requests to that route. Note that it is 69 | important that this decorator be wrapped by the route decorator 70 | and not vice, versa, as below. 71 | 72 | .. code-block:: python 73 | 74 | @app.route('/') 75 | @rate_limit(10, timedelta(seconds=10)) 76 | async def index(): 77 | ... 78 | 79 | Arguments: 80 | limit: The maximum number of requests to serve within a 81 | period. 82 | period: The duration over which the number of requests must 83 | not exceed the *limit*. 84 | key_function: A coroutine function that returns a unique key 85 | to identify the user agent. 86 | limits: Optional list of limits to use. Use instead of limit 87 | & period, 88 | 89 | .. code-block:: python 90 | 91 | async def example_key_function() -> str: 92 | return request.remote_addr 93 | 94 | """ 95 | if limit is not None or period is not None: 96 | if limits is not None: 97 | raise ValueError("Please use either limit & period or limits") 98 | limits = [RateLimit(limit, period, key_function, skip_function)] 99 | if limits is None: 100 | raise ValueError("No Rate Limit(s) set") 101 | 102 | def decorator(func: T) -> T: 103 | rate_limits = getattr(func, QUART_RATE_LIMITER_LIMITS_ATTRIBUTE, []) 104 | rate_limits.extend(limits) 105 | setattr(func, QUART_RATE_LIMITER_LIMITS_ATTRIBUTE, rate_limits) 106 | return func 107 | 108 | return decorator 109 | 110 | 111 | def rate_exempt(func: T) -> T: 112 | """A decorator to mark the route as exempt from rate limits. 113 | 114 | This should be used to wrap a route handler (or view function) to 115 | ensure no rate limits are applied to the route. Note that it is 116 | important that this decorator be wrapped by the route decorator 117 | and not vice, versa, as below. 118 | 119 | .. code-block:: python 120 | 121 | @app.route('/') 122 | @rate_exempt 123 | async def index(): 124 | ... 125 | """ 126 | setattr(func, QUART_RATE_LIMITER_EXEMPT_ATTRIBUTE, True) 127 | return func 128 | 129 | 130 | U = TypeVar("U", bound=Blueprint) 131 | 132 | 133 | def limit_blueprint( 134 | blueprint: U, 135 | limit: Optional[int] = None, 136 | period: Optional[timedelta] = None, 137 | key_function: Optional[KeyCallable] = None, 138 | skip_function: Optional[SkipCallable] = None, 139 | *, 140 | limits: Optional[List[RateLimit]] = None, 141 | ) -> U: 142 | """A function to add a rate limit marker to the blueprint. 143 | 144 | This should be used to apply a rate limit to all routes registered 145 | on the blueprint. 146 | 147 | .. code-block:: python 148 | 149 | blueprint = Blueprint("name", __name__) 150 | limit_blueprint(blueprint, 10, timedelta(seconds=10)) 151 | 152 | Arguments: 153 | blueprint: The blueprint to limit. 154 | limit: The maximum number of requests to serve within a 155 | period. 156 | period: The duration over which the number of requests must 157 | not exceed the *limit*. 158 | key_function: A coroutine function that returns a unique key 159 | to identify the user agent. 160 | limits: Optional list of limits to use. Use instead of limit 161 | & period, 162 | 163 | .. code-block:: python 164 | 165 | async def example_key_function() -> str: 166 | return request.remote_addr 167 | 168 | """ 169 | if limit is not None or period is not None: 170 | if limits is not None: 171 | raise ValueError("Please use either limit & period or limits") 172 | limits = [RateLimit(limit, period, key_function, skip_function)] 173 | if limits is None: 174 | raise ValueError("No Rate Limit(s) set") 175 | 176 | rate_limits = getattr(blueprint, QUART_RATE_LIMITER_LIMITS_ATTRIBUTE, []) 177 | rate_limits.extend(limits) 178 | setattr(blueprint, QUART_RATE_LIMITER_LIMITS_ATTRIBUTE, rate_limits) 179 | return blueprint 180 | 181 | 182 | async def remote_addr_key() -> str: 183 | return request.access_route[0] 184 | 185 | 186 | class RateLimiter: 187 | """A Rate limiter instance. 188 | 189 | This can be used to initialise Rate Limiting for a given app, 190 | either directly, 191 | 192 | .. code-block:: python 193 | 194 | app = Quart(__name__) 195 | rate_limiter = RateLimiter(app) 196 | 197 | or via the factory pattern, 198 | 199 | .. code-block:: python 200 | 201 | rate_limiter = RateLimiter() 202 | 203 | def create_app(): 204 | app = Quart(__name__) 205 | rate_limiter.init_app(app) 206 | return app 207 | 208 | The limiter itself can be customised using the following 209 | arguments, 210 | 211 | Arguments: 212 | key_function: A coroutine function that returns a unique key 213 | to identify the user agent. 214 | store: The store that contains the theoretical arrival times by 215 | key. 216 | default_limits: A sequence of instances of RateLimit, these become 217 | the default rate limits for all routes in addition to those set 218 | manually using the rate_limit decorator. They will also use the 219 | RateLimiter's key_function if none is supplied. 220 | enabled: Set to False to disable rate limiting entirely. 221 | """ 222 | 223 | def __init__( 224 | self, 225 | app: Optional[Quart] = None, 226 | key_function: KeyCallable = remote_addr_key, 227 | store: Optional[RateLimiterStoreABC] = None, 228 | default_limits: List[RateLimit] = None, 229 | enabled: bool = True, 230 | skip_function: SkipCallable = None, 231 | ) -> None: 232 | self.key_function = key_function 233 | self.skip_function = skip_function 234 | self.store: RateLimiterStoreABC 235 | if store is None: 236 | self.store = MemoryStore() 237 | else: 238 | self.store = store 239 | 240 | self._default_rate_limits = default_limits or [] 241 | self._blueprint_rate_limits: Dict[str, List[RateLimit]] = defaultdict(list) 242 | 243 | if app is not None: 244 | self.init_app(app, enabled=enabled) 245 | 246 | def _get_limits_for_view_function( 247 | self, view_func: Callable, blueprint: Optional[Blueprint] 248 | ) -> List[RateLimit]: 249 | if getattr(view_func, QUART_RATE_LIMITER_EXEMPT_ATTRIBUTE, False): 250 | return [] 251 | else: 252 | view_limits = getattr(view_func, QUART_RATE_LIMITER_LIMITS_ATTRIBUTE, []) 253 | blueprint_limits = getattr(blueprint, QUART_RATE_LIMITER_LIMITS_ATTRIBUTE, []) 254 | return view_limits + blueprint_limits + self._default_rate_limits 255 | 256 | def init_app(self, app: Quart, enabled: bool = True) -> None: 257 | app.before_request(self._before_request) 258 | app.after_request(self._after_request) 259 | app.before_serving(self._before_serving) 260 | app.after_serving(self._after_serving) 261 | app.config.setdefault("QUART_RATE_LIMITER_ENABLED", enabled) 262 | 263 | async def _before_serving(self) -> None: 264 | await self.store.before_serving() 265 | 266 | async def _after_serving(self) -> None: 267 | await self.store.after_serving() 268 | 269 | async def _before_request(self) -> None: 270 | if not current_app.config["QUART_RATE_LIMITER_ENABLED"]: 271 | return 272 | 273 | endpoint = request.endpoint 274 | view_func = current_app.view_functions.get(endpoint) 275 | blueprint = current_app.blueprints.get(request.blueprint) 276 | if view_func is not None: 277 | rate_limits = [ 278 | limit 279 | for limit in self._get_limits_for_view_function(view_func, blueprint) 280 | if not await self._should_skip(limit) 281 | ] 282 | await self._raise_on_rejection(endpoint, rate_limits) 283 | await self._update_limits(endpoint, rate_limits) 284 | 285 | async def _raise_on_rejection(self, endpoint: str, rate_limits: List[RateLimit]) -> None: 286 | now = datetime.now(UTC) 287 | for rate_limit in rate_limits: 288 | key = await self._create_key(endpoint, rate_limit) 289 | # This is the GCRA rate limiting system and tat stands for 290 | # the theoretical arrival time. 291 | tat = max(await self.store.get(key, now), now) 292 | separation = (tat - now).total_seconds() 293 | max_interval = rate_limit.period.total_seconds() - rate_limit.inverse 294 | if separation > max_interval: 295 | retry_after = ((tat - timedelta(seconds=max_interval)) - now).total_seconds() 296 | raise RateLimitExceeded(int(retry_after)) 297 | 298 | async def _update_limits(self, endpoint: str, rate_limits: List[RateLimit]) -> None: 299 | # Update the tats for all the rate limits. This must only 300 | # occur if no limit rejects the request. 301 | now = datetime.now(UTC) 302 | for rate_limit in rate_limits: 303 | key = await self._create_key(endpoint, rate_limit) 304 | tat = max(await self.store.get(key, now), now) 305 | new_tat = max(tat, now) + timedelta(seconds=rate_limit.inverse) 306 | await self.store.set(key, new_tat) 307 | 308 | async def _after_request(self, response: Response) -> Response: 309 | if not current_app.config["QUART_RATE_LIMITER_ENABLED"]: 310 | return response 311 | 312 | endpoint = request.endpoint 313 | view_func = current_app.view_functions.get(endpoint) 314 | blueprint = current_app.blueprints.get(request.blueprint) 315 | rate_limits = self._get_limits_for_view_function(view_func, blueprint) 316 | try: 317 | min_limit = min(rate_limits, key=lambda rate_limit: rate_limit.period.total_seconds()) 318 | except ValueError: 319 | pass # No rate limits 320 | else: 321 | key = await self._create_key(endpoint, min_limit) 322 | now = datetime.now(UTC) 323 | tat = max(await self.store.get(key, now), now) 324 | separation = (tat - now).total_seconds() 325 | remaining = int((min_limit.period.total_seconds() - separation) / min_limit.inverse) 326 | response.headers["RateLimit-Limit"] = str(min_limit.count) 327 | response.headers["RateLimit-Remaining"] = str(remaining) 328 | response.headers["RateLimit-Reset"] = str(int(separation)) 329 | 330 | return response 331 | 332 | async def _create_key(self, endpoint: str, rate_limit: RateLimit) -> str: 333 | key_function = rate_limit.key_function or self.key_function 334 | key = await key_function() 335 | app_name = current_app.import_name 336 | return f"{app_name}-{endpoint}-{rate_limit.key}-{key}" 337 | 338 | async def _should_skip(self, rate_limit: RateLimit) -> bool: 339 | skip_function = rate_limit.skip_function or self.skip_function 340 | if skip_function is None: 341 | return False 342 | else: 343 | return await skip_function() 344 | -------------------------------------------------------------------------------- /src/quart_rate_limiter/py.typed: -------------------------------------------------------------------------------- 1 | Marker 2 | -------------------------------------------------------------------------------- /src/quart_rate_limiter/redis_store.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Optional 3 | 4 | from redis import asyncio as aioredis 5 | 6 | from .store import RateLimiterStoreABC 7 | 8 | 9 | class RedisStore(RateLimiterStoreABC): 10 | """An redis backed store of rate limits. 11 | 12 | Arguments: 13 | address: The address of the redis instance. 14 | kwargs: Any keyword arguments to pass to the redis client on 15 | creation, see the redis documentation. 16 | """ 17 | 18 | def __init__(self, address: str, **kwargs: Any) -> None: 19 | self._redis: Optional[aioredis.Redis] = None 20 | self._redis_arguments = (address, kwargs) 21 | 22 | async def before_serving(self) -> None: 23 | self._redis = await aioredis.from_url(self._redis_arguments[0], **self._redis_arguments[1]) 24 | 25 | async def get(self, key: str, default: datetime) -> datetime: 26 | result = await self._redis.get(key) 27 | if result is None: 28 | return default 29 | else: 30 | return datetime.fromtimestamp(float(result)) 31 | 32 | async def set(self, key: str, tat: datetime) -> None: 33 | await self._redis.set(key, tat.timestamp()) 34 | 35 | async def after_serving(self) -> None: 36 | await self._redis.close() 37 | self._redis = None 38 | -------------------------------------------------------------------------------- /src/quart_rate_limiter/store.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from datetime import datetime 3 | from typing import Dict 4 | 5 | 6 | class RateLimiterStoreABC(metaclass=ABCMeta): 7 | @abstractmethod 8 | async def before_serving(self) -> None: 9 | """A coroutine within which any setup can be done.""" 10 | pass 11 | 12 | @abstractmethod 13 | async def get(self, key: str, default: datetime) -> datetime: 14 | """Get the TAT for the given *key* if present or the *default* if not. 15 | 16 | Arguments: 17 | key: The key to indentify the TAT. 18 | default: If no TAT for the *key* is available, return this 19 | default. 20 | 21 | Returns: 22 | A Theoretical Arrival Time, TAT. 23 | """ 24 | pass 25 | 26 | @abstractmethod 27 | async def set(self, key: str, tat: datetime) -> None: 28 | """Set the TAT for the given *key*. 29 | 30 | Arguments: 31 | key: The key to indentify the TAT. 32 | tat: The TAT value to set. 33 | """ 34 | pass 35 | 36 | @abstractmethod 37 | async def after_serving(self) -> None: 38 | """A coroutine within which any cleanup can be done.""" 39 | pass 40 | 41 | 42 | class MemoryStore(RateLimiterStoreABC): 43 | """An in memory store of rate limits.""" 44 | 45 | def __init__(self) -> None: 46 | self._tats: Dict[str, datetime] = {} 47 | 48 | async def get(self, key: str, default: datetime) -> datetime: 49 | return self._tats.get(key, default) 50 | 51 | async def set(self, key: str, tat: datetime) -> None: 52 | self._tats[key] = tat 53 | 54 | async def before_serving(self) -> None: 55 | pass 56 | 57 | async def after_serving(self) -> None: 58 | pass 59 | -------------------------------------------------------------------------------- /src/quart_rate_limiter/valkey_store.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Optional 3 | 4 | import valkey.asyncio as valkey 5 | 6 | from .store import RateLimiterStoreABC 7 | 8 | 9 | class ValkeyStore(RateLimiterStoreABC): 10 | """An Valkey backed store of rate limits. 11 | 12 | Arguments: 13 | address: The address of the valkey instance. 14 | kwargs: Any keyword arguments to pass to the valkey client on 15 | creation, see the valkey-py documentation. 16 | """ 17 | 18 | def __init__(self, address: str, **kwargs: Any) -> None: 19 | self._valkey: Optional[valkey.Valkey] = None 20 | self._valkey_arguments = (address, kwargs) 21 | 22 | async def before_serving(self) -> None: 23 | self._valkey = valkey.from_url(self._valkey_arguments[0], **self._valkey_arguments[1]) 24 | 25 | async def get(self, key: str, default: datetime) -> datetime: 26 | result = await self._valkey.get(key) 27 | if result is None: 28 | return default 29 | else: 30 | return datetime.fromtimestamp(float(result)) 31 | 32 | async def set(self, key: str, tat: datetime) -> None: 33 | await self._valkey.set(key, tat.timestamp()) 34 | 35 | async def after_serving(self) -> None: 36 | await self._valkey.aclose() 37 | self._valkey = None 38 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | from typing import AsyncGenerator 3 | 4 | import pytest 5 | from _pytest.config import Config 6 | from _pytest.config.argparsing import Parser 7 | from _pytest.monkeypatch import MonkeyPatch 8 | from quart import Quart, request, ResponseReturnValue 9 | 10 | import quart_rate_limiter 11 | from quart_rate_limiter import rate_limit, RateLimiter 12 | from quart_rate_limiter.redis_store import RedisStore 13 | from quart_rate_limiter.store import MemoryStore, RateLimiterStoreABC 14 | from quart_rate_limiter.valkey_store import ValkeyStore 15 | 16 | 17 | def pytest_addoption(parser: Parser) -> None: 18 | parser.addoption("--redis-host", action="store", default=None) 19 | parser.addoption("--valkey-host", action="store", default=None) 20 | 21 | 22 | @pytest.fixture(name="fixed_datetime") 23 | def _fixed_datetime(monkeypatch: MonkeyPatch) -> datetime: 24 | class MockDatetime(datetime): 25 | @classmethod 26 | def now(cls, tz) -> datetime: # type: ignore 27 | return datetime(2019, 3, 4) 28 | 29 | monkeypatch.setattr(quart_rate_limiter, "datetime", MockDatetime) 30 | return MockDatetime.now(timezone.utc) 31 | 32 | 33 | async def _skip_function() -> bool: 34 | return request.headers.get("X-Skip") == "True" 35 | 36 | 37 | @pytest.fixture(name="app", scope="function") 38 | async def _app(pytestconfig: Config) -> AsyncGenerator[Quart, None]: 39 | app = Quart(__name__) 40 | 41 | @app.route("/rate_limit/") 42 | @rate_limit(1, timedelta(seconds=2)) 43 | @rate_limit(10, timedelta(seconds=20)) 44 | async def index() -> ResponseReturnValue: 45 | return "" 46 | 47 | store: RateLimiterStoreABC 48 | redis_host = pytestconfig.getoption("redis_host") 49 | valkey_host = pytestconfig.getoption("valkey_host") 50 | if redis_host is not None: 51 | store = RedisStore(f"redis://{redis_host}") 52 | elif valkey_host is not None: 53 | store = ValkeyStore(f"redis://{valkey_host}") 54 | else: 55 | store = MemoryStore() 56 | 57 | RateLimiter(app, store=store, skip_function=_skip_function) 58 | async with app.test_app(): 59 | yield app 60 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | from quart import Blueprint, Quart, ResponseReturnValue 5 | 6 | from quart_rate_limiter import limit_blueprint, rate_exempt, RateLimit, RateLimiter 7 | 8 | 9 | async def test_rate_limit(app: Quart, fixed_datetime: datetime) -> None: 10 | test_client = app.test_client() 11 | response = await test_client.get("/rate_limit/") 12 | assert response.status_code == 200 13 | assert response.headers["RateLimit-Limit"] == "1" 14 | assert response.headers["RateLimit-Remaining"] == "0" 15 | assert response.headers["RateLimit-Reset"] == "2" 16 | 17 | response = await test_client.get("/rate_limit/") 18 | assert response.status_code == 429 19 | assert response.headers["Retry-After"] == "2" 20 | assert response.headers["Content-Type"] == "text/html; charset=utf-8" 21 | 22 | 23 | async def test_rate_limit_unique_keys(app: Quart, fixed_datetime: datetime) -> None: 24 | test_client = app.test_client() 25 | response = await test_client.get("/rate_limit/", scope_base={"client": ("127.0.0.1",)}) 26 | assert response.status_code == 200 27 | response = await test_client.get("/rate_limit/", scope_base={"client": ("127.0.0.2",)}) 28 | assert response.status_code == 200 29 | 30 | 31 | @pytest.fixture(name="app_default_limit") 32 | def _app_default_limit() -> Quart: 33 | app = Quart(__name__) 34 | 35 | @app.route("/") 36 | async def index() -> ResponseReturnValue: 37 | return "" 38 | 39 | @app.route("/exempt") 40 | @rate_exempt 41 | async def exempt() -> ResponseReturnValue: 42 | return "" 43 | 44 | rate_limit = RateLimit(1, timedelta(seconds=2)) 45 | RateLimiter(app, default_limits=[rate_limit]) 46 | return app 47 | 48 | 49 | async def test_default_rate_limits(app_default_limit: Quart, fixed_datetime: datetime) -> None: 50 | test_client = app_default_limit.test_client() 51 | response = await test_client.get("/") 52 | assert response.status_code == 200 53 | assert response.headers["RateLimit-Limit"] == "1" 54 | assert response.headers["RateLimit-Remaining"] == "0" 55 | assert response.headers["RateLimit-Reset"] == "2" 56 | 57 | response = await test_client.get("/") 58 | assert response.status_code == 429 59 | assert response.headers["Retry-After"] == "2" 60 | 61 | 62 | async def test_rate_exempt(app_default_limit: Quart) -> None: 63 | test_client = app_default_limit.test_client() 64 | response = await test_client.get("/exempt") 65 | assert "RateLimit-Limit" not in response.headers 66 | assert "RateLimit-Remaining" not in response.headers 67 | assert "RateLimit-Reset" not in response.headers 68 | 69 | response = await test_client.get("/exempt") 70 | assert response.status_code == 200 71 | 72 | 73 | async def test_rate_limit_skip_function(app: Quart, fixed_datetime: datetime) -> None: 74 | test_client = app.test_client() 75 | response = await test_client.get("/rate_limit/", headers={"X-Skip": "True"}) 76 | assert response.status_code == 200 77 | response = await test_client.get("/rate_limit/", headers={"X-Skip": "True"}) 78 | assert response.status_code == 200 79 | 80 | 81 | @pytest.fixture(name="app_blueprint_limit") 82 | def _app_blueprint_limit() -> Quart: 83 | app = Quart(__name__) 84 | 85 | blueprint = Blueprint("blue", __name__) 86 | 87 | @blueprint.route("/") 88 | async def index() -> ResponseReturnValue: 89 | return "" 90 | 91 | app.register_blueprint(blueprint) 92 | 93 | RateLimiter(app) 94 | limit_blueprint(blueprint, 1, timedelta(seconds=2)) 95 | return app 96 | 97 | 98 | async def test_blueprint_rate_limits(app_blueprint_limit: Quart, fixed_datetime: datetime) -> None: 99 | test_client = app_blueprint_limit.test_client() 100 | response = await test_client.get("/") 101 | assert response.status_code == 200 102 | assert response.headers["RateLimit-Limit"] == "1" 103 | assert response.headers["RateLimit-Remaining"] == "0" 104 | assert response.headers["RateLimit-Reset"] == "2" 105 | 106 | response = await test_client.get("/") 107 | assert response.status_code == 429 108 | assert response.headers["Retry-After"] == "2" 109 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = docs,format,mypy,py39,py310,py311,py312,py313,pep8,package 3 | isolated_build = True 4 | 5 | [testenv] 6 | deps = 7 | redis 8 | pytest 9 | pytest-asyncio 10 | pytest-cov 11 | pytest-sugar 12 | valkey 13 | commands = pytest --cov=quart_rate_limiter {posargs} 14 | 15 | [testenv:redis] 16 | basepython = python3.13 17 | deps = 18 | redis 19 | pytest 20 | pytest-asyncio 21 | pytest-cov 22 | pytest-sugar 23 | valkey 24 | commands = pytest --cov=quart_rate_limiter --redis-host="redis" {posargs} 25 | 26 | [testenv:valkey] 27 | basepython = python3.13 28 | deps = 29 | redis 30 | pytest 31 | pytest-asyncio 32 | pytest-cov 33 | pytest-sugar 34 | valkey 35 | commands = pytest --cov=quart_rate_limiter --valkey-host="valkey" {posargs} 36 | 37 | [testenv:docs] 38 | basepython = python3.13 39 | deps = 40 | pydata-sphinx-theme 41 | sphinx 42 | commands = 43 | sphinx-apidoc -e -f -o docs/reference/source/ src/quart_rate_limiter/ 44 | sphinx-build -b html -d {envtmpdir}/doctrees docs/ docs/_build/html/ 45 | 46 | [testenv:format] 47 | basepython = python3.13 48 | deps = 49 | black 50 | isort 51 | commands = 52 | black --check --diff src/quart_rate_limiter/ tests/ 53 | isort --check --diff src/quart_rate_limiter/ tests 54 | 55 | [testenv:pep8] 56 | basepython = python3.13 57 | deps = 58 | flake8 59 | pep8-naming 60 | flake8-print 61 | commands = flake8 src/quart_rate_limiter/ tests/ 62 | 63 | [testenv:mypy] 64 | basepython = python3.13 65 | deps = 66 | redis 67 | mypy 68 | pytest 69 | valkey 70 | commands = 71 | mypy src/quart_rate_limiter/ tests/ 72 | 73 | [testenv:package] 74 | basepython = python3.13 75 | deps = 76 | pdm 77 | twine 78 | commands = 79 | pdm build 80 | twine check dist/* 81 | --------------------------------------------------------------------------------