├── .coveragerc ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature.md ├── dependabot.yml ├── stale.yml └── workflows │ └── main.yml ├── .gitignore ├── .gitmodules ├── .readthedocs.yml ├── CLASSIFIERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTIONS.rst ├── HISTORY.rst ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.rst ├── doc ├── Makefile └── source │ ├── _static │ ├── colors.css │ ├── custom.css │ ├── limiter.css │ ├── logo-og.png │ ├── logo.png │ ├── logo.svg │ ├── tap-icon.ico │ ├── tap-icon.png │ ├── tap-icon.svg │ └── tap-logo.png │ ├── api.rst │ ├── changelog.rst │ ├── cli.rst │ ├── conf.py │ ├── configuration.rst │ ├── development.rst │ ├── index.rst │ ├── misc.rst │ ├── recipes.rst │ ├── strategies.rst │ └── theme_config.py ├── docker-compose.yml ├── examples ├── kitchensink.py └── sample.py ├── flask_limiter ├── __init__.py ├── _compat.py ├── _version.py ├── commands.py ├── constants.py ├── contrib │ ├── __init__.py │ └── util.py ├── errors.py ├── extension.py ├── limits.py ├── manager.py ├── py.typed ├── typing.py ├── util.py └── version.py ├── push-release.sh ├── pyproject.toml ├── pytest.ini ├── requirements ├── ci.txt ├── dev.txt ├── docs.txt ├── main.txt └── test.txt ├── scripts └── github_release_notes.sh ├── setup.cfg ├── setup.py ├── tag.sh ├── tests ├── __init__.py ├── conftest.py ├── static │ └── image.png ├── test_blueprints.py ├── test_commands.py ├── test_configuration.py ├── test_context_manager.py ├── test_decorators.py ├── test_error_handling.py ├── test_flask_ext.py ├── test_regressions.py ├── test_storage.py └── test_views.py └── versioneer.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | /**/flask_limiter/_compat.py 4 | /**/flask_limiter/_version* 5 | /**/tests/*.py 6 | /**/flask_limiter/contrib/*.py 7 | versioneer.py 8 | setup.py 9 | [report] 10 | exclude_lines = 11 | pragma: no cover 12 | noqa 13 | raise NotImplementedError 14 | if typing.TYPE_CHECKING 15 | if TYPE_CHECKING 16 | @overload 17 | @abstractmethod 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | flask_ratelimits/_version.py export-subst 2 | flask_limiter/_version.py export-subst 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: alisaifee 2 | open_collective: flask-limiter 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Submit a bug report 4 | labels: 'bug' 5 | 6 | --- 7 | 8 | 9 | 10 | ## Expected Behaviour 11 | 12 | 13 | 14 | ## Current Behaviour 15 | 16 | 17 | 18 | ## Steps to Reproduce 19 | 20 | 24 | 1. 25 | 1. 26 | 1. 27 | 1. 28 | 29 | ## Your Environment 30 | 31 | 32 | 33 | - Flask-limiter version: 34 | - Flask version: 35 | - Operating system: 36 | - Python version: 37 | 38 | 41 | 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature or Enhancement 3 | about: Propose a new feature or enhancement 4 | labels: 'enhancement' 5 | 6 | --- 7 | 8 | 9 | 10 | ## Expected Behaviour 11 | 12 | 13 | 14 | 17 | 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 90 5 | daysUntilClose: 7 6 | onlyLabels: [] 7 | exemptLabels: 8 | - pinned 9 | - security 10 | - enhancement 11 | - bug 12 | 13 | # Set to true to ignore issues in a project (defaults to false) 14 | exemptProjects: false 15 | 16 | # Set to true to ignore issues in a milestone (defaults to false) 17 | exemptMilestones: false 18 | 19 | # Set to true to ignore issues with an assignee (defaults to false) 20 | exemptAssignees: false 21 | 22 | # Label to use when marking as stale 23 | staleLabel: wontfix 24 | 25 | # Comment to post when marking as stale. Set to `false` to disable 26 | markComment: > 27 | This issue has been automatically marked as stale because it has not had 28 | recent activity. It will be closed if no further activity occurs. 29 | 30 | # Comment to post when removing the stale label. 31 | # unmarkComment: > 32 | # Your comment here. 33 | 34 | # Comment to post when closing a stale Issue or Pull Request. 35 | # closeComment: > 36 | # Your comment here. 37 | 38 | # Limit the number of actions per hour, from 1-30. Default is 30 39 | limitPerRun: 30 40 | 41 | # Limit to only `issues` or `pulls` 42 | only: issues 43 | 44 | # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': 45 | # pulls: 46 | # daysUntilStale: 30 47 | # markComment: > 48 | # This pull request has been automatically marked as stale because it has not had 49 | # recent activity. It will be closed if no further activity occurs. Thank you 50 | # for your contributions. 51 | 52 | # issues: 53 | # exemptLabels: 54 | # - confirmed 55 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.10", "3.11", "3.12", "3.13"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Cache dependencies 14 | uses: actions/cache@v3 15 | with: 16 | path: ~/.cache/pip 17 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements/**') }} 18 | restore-keys: | 19 | ${{ runner.os }}-pip- 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip setuptools wheel 27 | pip install -r requirements/ci.txt 28 | - name: Lint with ruff 29 | run: | 30 | ruff check --select I flask_limiter tests examples 31 | ruff format --check flask_limiter tests examples 32 | ruff check flask_limiter tests examples 33 | - name: Type checking 34 | run: | 35 | mypy flask_limiter 36 | test: 37 | runs-on: ubuntu-latest 38 | name: Test (Python ${{ matrix.python-version }}, Flask ${{matrix.flask-version}}) 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | python-version: ["3.10", "3.11", "3.12", "3.13"] 43 | flask-version: ["flask>=2.3,<2.4", "flask>=3.0,<3.1", "flask>=3.1,<3.2"] 44 | steps: 45 | - uses: actions/checkout@v3 46 | - name: Cache dependencies 47 | uses: actions/cache@v3 48 | with: 49 | path: ~/.cache/pip 50 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements/**') }} 51 | restore-keys: | 52 | ${{ runner.os }}-pip- 53 | - name: Set up Python ${{ matrix.python-version }} 54 | uses: actions/setup-python@v3 55 | with: 56 | python-version: ${{ matrix.python-version }} 57 | - name: Install dependencies 58 | run: | 59 | python -m pip install --upgrade pip setuptools wheel 60 | pip install -r requirements/ci.txt 61 | - name: Install Flask ${{ matrix.flask-version }} 62 | run: | 63 | pip uninstall -y flask werkzeug 64 | pip install "${{ matrix.flask-version }}" 65 | - name: Test 66 | run: | 67 | pytest --cov-report=xml 68 | - name: Upload coverage to Codecov 69 | uses: codecov/codecov-action@v5 70 | env: 71 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 72 | - name: Check Coverage 73 | run: | 74 | coverage report --fail-under=100 || (echo 'Insufficient coverage' && $(exit 1)) 75 | build_wheels: 76 | needs: [lint] 77 | name: Build wheel 78 | runs-on: ubuntu-latest 79 | steps: 80 | - uses: actions/checkout@v3 81 | with: 82 | fetch-depth: 0 83 | - name: Set up Python 84 | uses: actions/setup-python@v3 85 | with: 86 | python-version: '3.13' 87 | - name: Build wheels 88 | run: | 89 | python -m pip install -U build 90 | python -m build --wheel 91 | - uses: actions/upload-artifact@v4 92 | with: 93 | name: wheels 94 | path: ./dist/*.whl 95 | build_sdist: 96 | needs: [lint] 97 | name: Build source distribution 98 | runs-on: ubuntu-latest 99 | steps: 100 | - uses: actions/checkout@v3 101 | with: 102 | fetch-depth: 0 103 | - name: Set up Python 104 | uses: actions/setup-python@v3 105 | with: 106 | python-version: '3.13' 107 | - name: Build sdist 108 | run: | 109 | pipx run build --sdist 110 | - uses: actions/upload-artifact@v4 111 | with: 112 | name: src_dist 113 | path: dist/*.tar.gz 114 | upload_pypi: 115 | needs: [test, build_wheels, build_sdist] 116 | runs-on: ubuntu-latest 117 | if: github.ref == 'refs/heads/master' 118 | permissions: 119 | id-token: write 120 | steps: 121 | - uses: actions/download-artifact@v4.1.7 122 | with: 123 | name: wheels 124 | path: dist 125 | - uses: actions/download-artifact@v4.1.7 126 | with: 127 | name: src_dist 128 | path: dist 129 | - uses: pypa/gh-action-pypi-publish@release/v1 130 | with: 131 | repository-url: https://test.pypi.org/legacy/ 132 | skip-existing: true 133 | upload_pypi_release: 134 | needs: [test, build_wheels, build_sdist] 135 | runs-on: ubuntu-latest 136 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 137 | permissions: 138 | id-token: write 139 | steps: 140 | - uses: actions/download-artifact@v4.1.7 141 | with: 142 | name: wheels 143 | path: dist 144 | - uses: actions/download-artifact@v4.1.7 145 | with: 146 | name: src_dist 147 | path: dist 148 | - uses: pypa/gh-action-pypi-publish@release/v1 149 | 150 | github_release: 151 | needs: [upload_pypi_release] 152 | name: Create Release 153 | runs-on: ubuntu-latest 154 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 155 | steps: 156 | - name: Checkout code 157 | uses: actions/checkout@v3 158 | with: 159 | fetch-depth: 0 160 | - name: Download wheels 161 | uses: actions/download-artifact@v4.1.7 162 | with: 163 | name: wheels 164 | path: dist 165 | - name: Download src dist 166 | uses: actions/download-artifact@v4.1.7 167 | with: 168 | name: src_dist 169 | path: dist 170 | - name: Generate release notes 171 | run: | 172 | ./scripts/github_release_notes.sh > release_notes.md 173 | - name: Create Release 174 | uses: ncipollo/release-action@v1 175 | with: 176 | artifacts: "dist/*" 177 | bodyFile: release_notes.md 178 | token: ${{ secrets.GITHUB_TOKEN }} 179 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.log 3 | cover/* 4 | .coverage* 5 | .test_env 6 | .tool-versions 7 | .idea 8 | build/ 9 | dist/ 10 | doc/_build 11 | htmlcov 12 | *egg-info* 13 | .cache 14 | .eggs 15 | .python-version 16 | .*.swp 17 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/.gitmodules -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-20.04 4 | tools: 5 | python: "3.13" 6 | # You can also specify other tool versions: 7 | # nodejs: "16" 8 | # rust: "1.55" 9 | # golang: "1.17" 10 | 11 | # Build documentation in the docs/ directory with Sphinx 12 | sphinx: 13 | configuration: doc/source/conf.py 14 | 15 | python: 16 | install: 17 | - requirements: requirements/docs.txt 18 | - method: setuptools 19 | path: . 20 | -------------------------------------------------------------------------------- /CLASSIFIERS: -------------------------------------------------------------------------------- 1 | Development Status :: 5 - Production/Stable 2 | Environment :: Web Environment 3 | Framework :: Flask 4 | Intended Audience :: Developers 5 | License :: OSI Approved :: MIT License 6 | Operating System :: MacOS 7 | Operating System :: POSIX :: Linux 8 | Operating System :: OS Independent 9 | Topic :: Software Development :: Libraries :: Python Modules 10 | Programming Language :: Python :: 3.10 11 | Programming Language :: Python :: 3.11 12 | Programming Language :: Python :: 3.12 13 | Programming Language :: Python :: 3.13 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTIONS.rst: -------------------------------------------------------------------------------- 1 | Contributions 2 | ============= 3 | 4 | * `Timothee Groleau `_ 5 | * `Zehua Liu `_ 6 | * `Guilherme Polo `_ 7 | * `Mattias Granlund `_ 8 | * `Josh Friend `_ 9 | * `Sami Hiltunen `_ 10 | * `Henning Peters `_ 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Ali-Akber Saifee 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | include HISTORY.rst 4 | include CONTRIBUTIONS.rst 5 | include CLASSIFIERS 6 | include versioneer.py 7 | recursive-include requirements *.txt 8 | recursive-include doc/source * 9 | recursive-include doc *.py Make* 10 | include flask_limiter/_version.py 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | ruff check flask_limiter tests examples --select I 3 | ruff format --check flask_limiter tests examples 4 | ruff check flask_limiter tests examples 5 | mypy flask_limiter 6 | 7 | lint-fix: 8 | ruff check flask_limiter tests examples --select I --fix 9 | ruff format flask_limiter tests examples 10 | ruff check --fix flask_limiter tests examples 11 | mypy flask_limiter 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. |ci| image:: https://github.com/alisaifee/flask-limiter/actions/workflows/main.yml/badge.svg?branch=master 2 | :target: https://github.com/alisaifee/flask-limiter/actions?query=branch%3Amaster+workflow%3ACI 3 | .. |codecov| image:: https://codecov.io/gh/alisaifee/flask-limiter/branch/master/graph/badge.svg 4 | :target: https://codecov.io/gh/alisaifee/flask-limiter 5 | .. |pypi| image:: https://img.shields.io/pypi/v/Flask-Limiter.svg?style=flat-square 6 | :target: https://pypi.python.org/pypi/Flask-Limiter 7 | .. |license| image:: https://img.shields.io/pypi/l/Flask-Limiter.svg?style=flat-square 8 | :target: https://pypi.python.org/pypi/Flask-Limiter 9 | .. |docs| image:: https://readthedocs.org/projects/flask-limiter/badge/?version=latest 10 | :target: https://flask-limiter.readthedocs.org/en/latest 11 | 12 | ************* 13 | Flask-Limiter 14 | ************* 15 | 16 | 17 | |docs| |ci| |codecov| |pypi| |license| 18 | 19 | **Flask-Limiter** adds rate limiting to `Flask `_ applications. 20 | 21 | You can configure rate limits at different levels such as: 22 | 23 | - Application wide global limits per user 24 | - Default limits per route 25 | - By `Blueprints `_ 26 | - By `Class-based views `_ 27 | - By `individual routes `_ 28 | 29 | **Flask-Limiter** can be `configured `_ to fit your application in many ways, including: 30 | 31 | - Persistance to various commonly used `storage backends `_ 32 | (such as Redis, Memcached & MongoDB) 33 | via `limits `__ 34 | - Any rate limiting strategy supported by `limits `__ 35 | 36 | Follow the quickstart below to get started or `read the documentation `_ for more details. 37 | 38 | 39 | Quickstart 40 | =========== 41 | 42 | Install 43 | ------- 44 | .. code-block:: bash 45 | 46 | pip install Flask-Limiter 47 | 48 | Add the rate limiter to your flask app 49 | --------------------------------------- 50 | .. code-block:: python 51 | 52 | # app.py 53 | 54 | from flask import Flask 55 | from flask_limiter import Limiter 56 | from flask_limiter.util import get_remote_address 57 | 58 | app = Flask(__name__) 59 | limiter = Limiter( 60 | get_remote_address, 61 | app=app, 62 | default_limits=["2 per minute", "1 per second"], 63 | storage_uri="memory://", 64 | # Redis 65 | # storage_uri="redis://localhost:6379", 66 | # Redis cluster 67 | # storage_uri="redis+cluster://localhost:7000,localhost:7001,localhost:70002", 68 | # Memcached 69 | # storage_uri="memcached://localhost:11211", 70 | # Memcached Cluster 71 | # storage_uri="memcached://localhost:11211,localhost:11212,localhost:11213", 72 | # MongoDB 73 | # storage_uri="mongodb://localhost:27017", 74 | strategy="fixed-window", # or "moving-window", or "sliding-window-counter" 75 | ) 76 | 77 | @app.route("/slow") 78 | @limiter.limit("1 per day") 79 | def slow(): 80 | return "24" 81 | 82 | @app.route("/fast") 83 | def fast(): 84 | return "42" 85 | 86 | @app.route("/ping") 87 | @limiter.exempt 88 | def ping(): 89 | return 'PONG' 90 | 91 | Inspect the limits using the command line interface 92 | --------------------------------------------------- 93 | .. code-block:: bash 94 | 95 | $ FLASK_APP=app:app flask limiter limits 96 | 97 | app 98 | ├── fast: /fast 99 | │ ├── 2 per 1 minute 100 | │ └── 1 per 1 second 101 | ├── ping: /ping 102 | │ └── Exempt 103 | └── slow: /slow 104 | └── 1 per 1 day 105 | 106 | Run the app 107 | ----------- 108 | .. code-block:: bash 109 | 110 | $ FLASK_APP=app:app flask run 111 | 112 | 113 | Test it out 114 | ----------- 115 | The ``fast`` endpoint respects the default rate limit while the 116 | ``slow`` endpoint uses the decorated one. ``ping`` has no rate limit associated 117 | with it. 118 | 119 | .. code-block:: bash 120 | 121 | $ curl localhost:5000/fast 122 | 42 123 | $ curl localhost:5000/fast 124 | 42 125 | $ curl localhost:5000/fast 126 | 127 | 429 Too Many Requests 128 |

Too Many Requests

129 |

2 per 1 minute

130 | $ curl localhost:5000/slow 131 | 24 132 | $ curl localhost:5000/slow 133 | 134 | 429 Too Many Requests 135 |

Too Many Requests

136 |

1 per 1 day

137 | $ curl localhost:5000/ping 138 | PONG 139 | $ curl localhost:5000/ping 140 | PONG 141 | $ curl localhost:5000/ping 142 | PONG 143 | $ curl localhost:5000/ping 144 | PONG 145 | 146 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Flask-Ratelimit.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-Ratelimit.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Flask-Ratelimit" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-Ratelimit" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /doc/source/_static/colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg0: #fbf1c7; 3 | --bg1: #ebdbb2; 4 | --bg2: #d5c4a1; 5 | --bg3: #bdae93; 6 | --bg4: #a89984; 7 | --gry: #928374; 8 | --fg4: #7c6f64; 9 | --fg3: #665c54; 10 | --fg2: #504945; 11 | --fg1: #3c3836; 12 | --fg0: #282828; 13 | --red: #cc241d; 14 | --red2: #9d0006; 15 | --orange: #d65d0e; 16 | --orange2: #af3a03; 17 | --yellow: #d79921; 18 | --yellow2: #b57614; 19 | --green: #98971a; 20 | --green2: #79740e; 21 | --aqua: #689d6a; 22 | --aqua2: #427b58; 23 | --blue: #458588; 24 | --blue2: #076678; 25 | --purple: #b16286; 26 | --purple2: #8f3f71; 27 | } 28 | -------------------------------------------------------------------------------- /doc/source/_static/custom.css: -------------------------------------------------------------------------------- 1 | #flask-limiter h1 { 2 | display: none; 3 | } 4 | a.image-reference.logo:hover { 5 | filter: none; 6 | } 7 | a.logo { 8 | padding-bottom: 1em; 9 | } 10 | body[data-theme="dark"] img.logo, body[data-theme="dark"] img.sidebar-logo { 11 | filter: invert(0.7) sepia(0.4); 12 | } 13 | .badges { 14 | display: flex; 15 | padding: 10px; 16 | flex-direction: row; 17 | justify-content: center; 18 | } 19 | .header-badge { 20 | padding: 2px; 21 | } 22 | 23 | @media only screen and (max-width: 768px) { 24 | } 25 | -------------------------------------------------------------------------------- /doc/source/_static/limiter.css: -------------------------------------------------------------------------------- 1 | @import url("flasky.css"); 2 | div.warning, div.attention{ 3 | background-color: #ffedcc; 4 | } 5 | div.danger { 6 | background-color: #fdf3f2; 7 | } 8 | div.info, div.note { 9 | background-color: #e7f2fa; 10 | } 11 | div.tip, div.important { 12 | background-color: #dbfaf4; 13 | } 14 | div.alert { 15 | background-color: #ffedcc; 16 | } 17 | div.admonition{ 18 | border: none; 19 | } 20 | div.admonition p.admonition-title{ 21 | font-variant: small-caps; 22 | } 23 | p.admonition-title:after{ 24 | content: ""; 25 | } 26 | -------------------------------------------------------------------------------- /doc/source/_static/logo-og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/doc/source/_static/logo-og.png -------------------------------------------------------------------------------- /doc/source/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/doc/source/_static/logo.png -------------------------------------------------------------------------------- /doc/source/_static/tap-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/doc/source/_static/tap-icon.ico -------------------------------------------------------------------------------- /doc/source/_static/tap-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/doc/source/_static/tap-icon.png -------------------------------------------------------------------------------- /doc/source/_static/tap-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/doc/source/_static/tap-logo.png -------------------------------------------------------------------------------- /doc/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. currentmodule:: flask_limiter 5 | 6 | Extension 7 | --------- 8 | .. autoclass:: Limiter 9 | 10 | Limit objects 11 | -------------- 12 | 13 | The following dataclasses can be used to define rate limits with more 14 | granularity than what is available through the :class:`Limiter` constructor 15 | if needed (especially for **default**, **application wide** and **meta** limits). 16 | 17 | .. autoclass:: Limit 18 | .. autoclass:: ApplicationLimit 19 | .. autoclass:: MetaLimit 20 | 21 | For consistency the :class:`RouteLimit` dataclass is also available to define limits 22 | for decorating routes or blueprints. 23 | 24 | .. autoclass:: RouteLimit 25 | 26 | Utilities 27 | --------- 28 | .. autoclass:: ExemptionScope 29 | .. autoclass:: RequestLimit 30 | .. automodule:: flask_limiter.util 31 | 32 | Exceptions 33 | ---------- 34 | .. currentmodule:: flask_limiter 35 | 36 | .. autoexception:: RateLimitExceeded 37 | :no-inherited-members: 38 | -------------------------------------------------------------------------------- /doc/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../HISTORY.rst 2 | -------------------------------------------------------------------------------- /doc/source/cli.rst: -------------------------------------------------------------------------------- 1 | Command Line Interface 2 | ====================== 3 | 4 | .. versionadded:: 2.4.0 5 | 6 | Flask-Limiter adds a few subcommands to the Flask :doc:`flask:cli` for maintenance & diagnostic purposes. 7 | These can be accessed under the **limiter** sub-command as follows 8 | 9 | .. program-output:: FLASK_APP=../../examples/kitchensink.py:app flask limiter --help 10 | :shell: 11 | 12 | Example 13 | ------- 14 | 15 | The examples below use the following example application: 16 | 17 | .. literalinclude:: ../../examples/kitchensink.py 18 | :language: py 19 | 20 | Extension Config 21 | ^^^^^^^^^^^^^^^^ 22 | Use the subcommand **config** to display the active configuration 23 | 24 | .. code-block:: shell 25 | 26 | $ flask limiter config 27 | 28 | .. command-output:: FLASK_APP=../../examples/kitchensink.py:app flask limiter config 29 | :shell: 30 | 31 | List limits 32 | ^^^^^^^^^^^ 33 | .. code-block:: shell 34 | 35 | $ flask limiter limits 36 | 37 | Use the subcommand **limits** to display all configured limits 38 | 39 | .. command-output:: FLASK_APP=../../examples/kitchensink.py:app flask limiter limits 40 | :shell: 41 | 42 | ======================= 43 | Filter by endpoint name 44 | ======================= 45 | 46 | .. command-output:: FLASK_APP=../../examples/kitchensink.py:app flask limiter limits --endpoint=root 47 | :shell: 48 | 49 | ============== 50 | Filter by path 51 | ============== 52 | 53 | .. command-output:: FLASK_APP=../../examples/kitchensink.py:app flask limiter limits --path=/health/ 54 | :shell: 55 | 56 | ================== 57 | Check limit status 58 | ================== 59 | 60 | .. command-output:: FLASK_APP=../../examples/kitchensink.py:app flask limiter limits --key=127.0.0.1 61 | :shell: 62 | 63 | Clear limits 64 | ^^^^^^^^^^^^ 65 | .. code-block:: shell 66 | 67 | $ flask limiter clear 68 | 69 | The CLI exposes a subcommand **clear** that can be used to clear either all limits or limits for specific endpoints or routes by a 70 | ``key`` which represents the value returned by the :paramref:`~flask_limiter.Limiter.key_func` (i.e. a specific user) 71 | callable configured for your application. 72 | 73 | .. command-output:: FLASK_APP=../../examples/kitchensink.py:app flask limiter clear --help 74 | :shell: 75 | 76 | By default this is an interactive command which requires confirmation, however it can 77 | also be used in automations by using the ``-y`` flag to force confirmation. 78 | 79 | .. command-output:: FLASK_APP=../../examples/kitchensink.py:app flask limiter clear --key=127.0.0.1 -y 80 | :shell: 81 | 82 | 83 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | 3 | import os 4 | import re 5 | import sys 6 | 7 | sys.path.insert(0, os.path.abspath("../../")) 8 | sys.path.insert(0, os.path.abspath("./")) 9 | 10 | from theme_config import * 11 | 12 | import flask_limiter 13 | 14 | description = "Flask-Limiter adds rate limiting to flask applications." 15 | copyright = "2023, Ali-Akber Saifee" 16 | project = "Flask-Limiter" 17 | 18 | ahead = 0 19 | 20 | if ".post0.dev" in flask_limiter.__version__: 21 | version, ahead = flask_limiter.__version__.split(".post0.dev") 22 | else: 23 | version = flask_limiter.__version__ 24 | 25 | release = version 26 | 27 | html_title = f"{project} {{{release}}}" 28 | try: 29 | ahead = int(ahead) 30 | 31 | if ahead > 0: 32 | html_theme_options[ 33 | "announcement" 34 | ] = f""" 35 | This is a development version. The documentation for the latest stable version can be found here 36 | """ 37 | html_title = f"{project} {{dev}}" 38 | except: 39 | pass 40 | 41 | html_favicon = "_static/tap-icon.ico" 42 | html_static_path = ["./_static"] 43 | templates_path = ["./_templates"] 44 | html_css_files = [ 45 | "custom.css", 46 | "colors.css", 47 | "https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Be+Vietnam+Pro:wght@500&display=swap", 48 | ] 49 | 50 | html_theme_options.update({"light_logo": "tap-icon.png", "dark_logo": "tap-icon.png"}) 51 | 52 | extensions = [ 53 | "sphinx.ext.autodoc", 54 | "sphinx.ext.autosectionlabel", 55 | "sphinx.ext.autosummary", 56 | "sphinx.ext.extlinks", 57 | "sphinx.ext.intersphinx", 58 | "sphinxext.opengraph", 59 | "sphinx.ext.todo", 60 | "sphinx.ext.viewcode", 61 | "sphinxcontrib.programoutput", 62 | "sphinx_issues", 63 | "sphinx_inline_tabs", 64 | "sphinx_paramlinks", 65 | ] 66 | 67 | autodoc_default_options = { 68 | "members": True, 69 | "inherited-members": True, 70 | "inherit-docstrings": True, 71 | "member-order": "bysource", 72 | } 73 | add_module_names = False 74 | autoclass_content = "both" 75 | autodoc_typehints_format = "short" 76 | autodoc_preserve_defaults = True 77 | autosectionlabel_maxdepth = 3 78 | autosectionlabel_prefix_document = True 79 | issues_github_path = "alisaifee/flask-limiter" 80 | 81 | ogp_image = "_static/logo-og.png" 82 | 83 | extlinks = { 84 | "pypi": ("https://pypi.org/project/%s", "%s"), 85 | "githubsrc": ("https://github.com/alisaifee/flask-limiter/blob/master/%s", "%s"), 86 | } 87 | 88 | intersphinx_mapping = { 89 | "python": ("http://docs.python.org/", None), 90 | "limits": ("https://limits.readthedocs.io/en/stable/", None), 91 | "redis-py-cluster": ("https://redis-py-cluster.readthedocs.io/en/latest/", None), 92 | "redis-py": ("https://redis-py.readthedocs.io/en/latest/", None), 93 | "pymemcache": ("https://pymemcache.readthedocs.io/en/latest/", None), 94 | "pymongo": ("https://pymongo.readthedocs.io/en/stable/", None), 95 | "flask": ("https://flask.palletsprojects.com/en/latest/", None), 96 | "werkzeug": ("https://werkzeug.palletsprojects.com/en/latest/", None), 97 | "flaskrestful": ("http://flask-restful.readthedocs.org/en/latest/", None), 98 | } 99 | -------------------------------------------------------------------------------- /doc/source/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _RFC2616: https://tools.ietf.org/html/rfc2616#section-14.37 2 | .. _ratelimit-conf: 3 | 4 | Configuration 5 | ============= 6 | 7 | Using Flask Config 8 | ------------------ 9 | The following :doc:`Flask Configuration ` values are honored by 10 | :class:`~flask_limiter.Limiter`. If the corresponding configuration value is also present 11 | as an argument to the :class:`~flask_limiter.Limiter` constructor, the constructor argument will 12 | take priority. 13 | 14 | .. list-table:: 15 | 16 | * - .. data:: RATELIMIT_ENABLED 17 | 18 | Constructor argument: :paramref:`~flask_limiter.Limiter.enabled` 19 | 20 | - Overall kill switch for rate limits. Defaults to ``True`` 21 | * - .. data:: RATELIMIT_KEY_FUNC 22 | 23 | Constructor argument: :paramref:`~flask_limiter.Limiter.key_func` 24 | 25 | - A callable that returns the domain to rate limit (e.g. username, ip address etc) 26 | * - .. data:: RATELIMIT_KEY_PREFIX 27 | 28 | Constructor argument: :paramref:`~flask_limiter.Limiter.key_prefix` 29 | 30 | - Prefix that is prepended to each stored rate limit key and app context 31 | global name. This can be useful when using a shared storage for multiple 32 | applications or rate limit domains. For multi-instance use cases, explicitly 33 | pass ``key_prefix`` keyword argument to :class:`~flask_limiter.Limiter` constructor instead. 34 | * - .. data:: RATELIMIT_APPLICATION 35 | 36 | Constructor argument: :paramref:`~flask_limiter.Limiter.application_limits` 37 | 38 | - A comma (or some other delimiter) separated string that will be used to 39 | apply limits to the application as a whole (i.e. shared by all routes). 40 | * - .. data:: RATELIMIT_APPLICATION_PER_METHOD 41 | 42 | Constructor argument: :paramref:`~flask_limiter.Limiter.application_limits_per_method` 43 | 44 | - Whether application limits are applied per method, per route or as a combination 45 | of all method per route. 46 | * - .. data:: RATELIMIT_APPLICATION_EXEMPT_WHEN 47 | 48 | Constructor argument: :paramref:`~flask_limiter.Limiter.application_limits_exempt_when` 49 | 50 | - A function that should return a truthy value if the application rate limit(s) 51 | should be skipped for the current request. This callback is called from the 52 | :doc:`flask request context ` :meth:`~flask.Flask.before_request` hook. 53 | * - .. data:: RATELIMIT_APPLICATION_DEDUCT_WHEN 54 | 55 | Constructor argument: :paramref:`~flask_limiter.Limiter.application_limits_deduct_when` 56 | 57 | - A function that should return a truthy value if a deduction should be made 58 | from the application rate limit(s) for the current request. This callback is called 59 | from the :doc:`flask request context ` :meth:`~flask.Flask.after_request` hook. 60 | * - .. data:: RATELIMIT_APPLICATION_COST 61 | 62 | Constructor argument: :paramref:`~flask_limiter.Limiter.application_limits_cost` 63 | 64 | - The cost of a hit to the application wide shared limit as an integer or a function 65 | that takes no parameters and returns the cost as an integer (Default: 1) 66 | * - .. data:: RATELIMIT_DEFAULT 67 | 68 | Constructor argument: :paramref:`~flask_limiter.Limiter.default_limits` 69 | 70 | - A comma (or some other delimiter) separated string that will be used to 71 | apply a default limit on all routes that are otherwise not decorated with 72 | an explicit rate limit. If not provided, the default limits can be 73 | passed to the :class:`~flask_limiter.Limiter` constructor as well (the values passed to the 74 | constructor take precedence over those in the config). 75 | :ref:`ratelimit-string` for details. 76 | * - .. data:: RATELIMIT_DEFAULTS_PER_METHOD 77 | 78 | Constructor argument: :paramref:`~flask_limiter.Limiter.default_limits_per_method` 79 | 80 | - Whether default limits are applied per method, per route or as a combination 81 | of all method per route. 82 | * - .. data:: RATELIMIT_DEFAULTS_COST 83 | 84 | Constructor argument: :paramref:`~flask_limiter.Limiter.default_limits_cost` 85 | 86 | - The cost of a hit to the default limits as an integer or a function 87 | that takes no parameters and returns the cost as an integer (Default: 1) 88 | * - .. data:: RATELIMIT_DEFAULTS_EXEMPT_WHEN 89 | 90 | Constructor argument: :paramref:`~flask_limiter.Limiter.default_limits_exempt_when` 91 | 92 | - A function that should return a truthy value if the default rate limit(s) 93 | should be skipped for the current request. This callback is called from the 94 | :doc:`flask request context ` :meth:`~flask.Flask.before_request` hook. 95 | * - .. data:: RATELIMIT_DEFAULTS_DEDUCT_WHEN 96 | 97 | Constructor argument: :paramref:`~flask_limiter.Limiter.default_limits_deduct_when` 98 | 99 | - A function that should return a truthy value if a deduction should be made 100 | from the default rate limit(s) for the current request. This callback is called 101 | from the :doc:`flask request context ` :meth:`~flask.Flask.after_request` hook. 102 | * - .. data:: RATELIMIT_STORAGE_URI 103 | 104 | Constructor argument: :paramref:`~flask_limiter.Limiter.storage_uri` 105 | 106 | - A storage location conforming to the scheme in :ref:`limits:storage:storage scheme`. 107 | A basic in-memory storage can be used by specifying ``memory://`` but it 108 | should be used with caution in any production setup since: 109 | 110 | #. Each application process will have it's own storage 111 | #. The state of the rate limits will not persist beyond the process' life-time. 112 | 113 | Other supported backends include: 114 | 115 | - Memcached: ``memcached://host:port`` 116 | - MongoDB: ``mongodb://host:port`` 117 | - Redis: ``redis://host:port`` 118 | 119 | For specific examples and requirements of supported backends please 120 | refer to :ref:`limits:storage:storage scheme` and the :doc:`limits ` library. 121 | * - .. data:: RATELIMIT_STORAGE_OPTIONS 122 | 123 | Constructor argument: :paramref:`~flask_limiter.Limiter.storage_options` 124 | 125 | - A dictionary to set extra options to be passed to the storage implementation 126 | upon initialization. 127 | * - .. data:: RATELIMIT_REQUEST_IDENTIFIER 128 | 129 | Constructor argument: :paramref:`~flask_limiter.Limiter.request_identifier` 130 | 131 | - A callable that returns the unique identity of the current request. Defaults to :attr:`flask.Request.endpoint` 132 | * - .. data:: RATELIMIT_STRATEGY 133 | 134 | Constructor argument: :paramref:`~flask_limiter.Limiter.strategy` 135 | 136 | - The rate limiting strategy to use. :ref:`ratelimit-strategy` 137 | for details. 138 | * - .. data:: RATELIMIT_HEADERS_ENABLED 139 | 140 | Constructor argument: :paramref:`~flask_limiter.Limiter.headers_enabled` 141 | 142 | - Enables returning :ref:`ratelimit-headers`. Defaults to ``False`` 143 | * - .. data:: RATELIMIT_HEADER_LIMIT 144 | 145 | Constructor argument: :paramref:`~flask_limiter.Limiter.header_name_mapping` 146 | 147 | - Header for the current rate limit. Defaults to ``X-RateLimit-Limit`` 148 | * - .. data:: RATELIMIT_HEADER_RESET 149 | 150 | Constructor argument: :paramref:`~flask_limiter.Limiter.header_name_mapping` 151 | 152 | - Header for the reset time of the current rate limit. Defaults to ``X-RateLimit-Reset`` 153 | * - .. data:: RATELIMIT_HEADER_REMAINING 154 | 155 | Constructor argument: :paramref:`~flask_limiter.Limiter.header_name_mapping` 156 | 157 | - Header for the number of requests remaining in the current rate limit. Defaults to ``X-RateLimit-Remaining`` 158 | * - .. data:: RATELIMIT_HEADER_RETRY_AFTER 159 | 160 | Constructor argument: :paramref:`~flask_limiter.Limiter.header_name_mapping` 161 | 162 | - Header for when the client should retry the request. Defaults to ``Retry-After`` 163 | * - .. data:: RATELIMIT_HEADER_RETRY_AFTER_VALUE 164 | 165 | Constructor argument: :paramref:`~flask_limiter.Limiter.retry_after` 166 | 167 | - Allows configuration of how the value of the ``Retry-After`` header is rendered. 168 | One of ``http-date`` or ``delta-seconds``. (`RFC2616`_). 169 | * - .. data:: RATELIMIT_SWALLOW_ERRORS 170 | 171 | Constructor argument: :paramref:`~flask_limiter.Limiter.swallow_errors` 172 | 173 | - Whether to allow failures while attempting to perform a rate limit 174 | such as errors with downstream storage. Setting this value to ``True`` 175 | will effectively disable rate limiting for requests where an error has 176 | occurred. 177 | * - .. data:: RATELIMIT_IN_MEMORY_FALLBACK_ENABLED 178 | 179 | Constructor argument: :paramref:`~flask_limiter.Limiter.in_memory_fallback_enabled` 180 | 181 | - ``True``/``False``. If enabled an in memory rate limiter will be used 182 | as a fallback when the configured storage is down. Note that, when used in 183 | combination with ``RATELIMIT_IN_MEMORY_FALLBACK`` the original rate limits 184 | will not be inherited and the values provided in 185 | * - .. data:: RATELIMIT_IN_MEMORY_FALLBACK 186 | 187 | Constructor argument: :paramref:`~flask_limiter.Limiter.in_memory_fallback` 188 | 189 | - A comma (or some other delimiter) separated string 190 | that will be used when the configured storage is down. 191 | * - .. data:: RATELIMIT_FAIL_ON_FIRST_BREACH 192 | 193 | Constructor argument: :paramref:`~flask_limiter.Limiter.fail_on_first_breach` 194 | 195 | - Whether to stop processing remaining limits after the first breach. 196 | Default to ``True`` 197 | * - .. data:: RATELIMIT_ON_BREACH_CALLBACK 198 | 199 | Constructor argument: :paramref:`~flask_limiter.Limiter.on_breach` 200 | 201 | - A function that will be called when any limit in this 202 | extension is breached. 203 | * - .. data:: RATELIMIT_META 204 | 205 | Constructor argument: :paramref:`~flask_limiter.Limiter.meta_limits` 206 | 207 | - A comma (or some other delimiter) separated string that will be used to 208 | control the upper limit of a requesting client hitting any configured rate limit. 209 | Once a meta limit is exceeded all subsequent requests will raise a 210 | :class:`~flask_limiter.RateLimitExceeded` for the duration of the meta limit window. 211 | * - .. data:: RATELIMIT_ON_META_BREACH_CALLBACK 212 | 213 | Constructor argument: :paramref:`~flask_limiter.Limiter.on_meta_breach` 214 | 215 | - A function that will be called when a meta limit in this 216 | extension is breached. 217 | 218 | .. _ratelimit-string: 219 | 220 | Rate limit string notation 221 | -------------------------- 222 | 223 | Rate limits are specified as strings following the format:: 224 | 225 | [count] [per|/] [n (optional)] [second|minute|hour|day|month|year][s] 226 | 227 | You can combine multiple rate limits by separating them with a delimiter of your 228 | choice. 229 | 230 | Examples 231 | ^^^^^^^^ 232 | 233 | * ``10 per hour`` 234 | * ``10 per 2 hours`` 235 | * ``10/hour`` 236 | * ``5/2 seconds;10/hour;100/day;2000 per year`` 237 | * ``100/day, 500/7 days`` 238 | 239 | .. warning:: If rate limit strings that are provided to the :meth:`~flask_limiter.Limiter.limit` 240 | decorator are malformed and can't be parsed the decorated route will fall back 241 | to the default rate limit(s) and an ``ERROR`` log message will be emitted. Refer 242 | to :ref:`logging` for more details on capturing this information. Malformed 243 | default rate limit strings will however raise an exception as they are evaluated 244 | early enough to not cause disruption to a running application. 245 | 246 | 247 | .. _ratelimit-headers: 248 | 249 | Rate-limiting Headers 250 | --------------------- 251 | 252 | If the configuration is enabled, information about the rate limit with respect to the 253 | route being requested will be added to the response headers. Since multiple rate limits 254 | can be active for a given route - the rate limit with the lowest time granularity will be 255 | used in the scenario when the request does not breach any rate limits. 256 | 257 | .. tabularcolumns:: |p{8cm}|p{8.5cm}| 258 | 259 | ============================== ================================================ 260 | ``X-RateLimit-Limit`` The total number of requests allowed for the 261 | active window 262 | ``X-RateLimit-Remaining`` The number of requests remaining in the active 263 | window. 264 | ``X-RateLimit-Reset`` UTC seconds since epoch when the window will be 265 | reset. 266 | ``Retry-After`` Seconds to retry after or the http date when the 267 | Rate Limit will be reset. The way the value is presented 268 | depends on the configuration value set in :data:`RATELIMIT_HEADER_RETRY_AFTER_VALUE` 269 | and defaults to `delta-seconds`. 270 | ============================== ================================================ 271 | 272 | 273 | The header names can be customised if required by either using the flask configuration ( 274 | :attr:`RATELIMIT_HEADER_LIMIT`, 275 | :attr:`RATELIMIT_HEADER_RESET`, 276 | :attr:`RATELIMIT_HEADER_RETRY_AFTER`, 277 | :attr:`RATELIMIT_HEADER_REMAINING` 278 | ) 279 | values or by providing the :paramref:`~flask_limiter.Limiter.header_name_mapping` argument 280 | to the extension constructor as follows:: 281 | 282 | from flask_limiter import Limiter, HEADERS 283 | limiter = Limiter(header_name_mapping={ 284 | HEADERS.LIMIT : "X-My-Limit", 285 | HEADERS.RESET : "X-My-Reset", 286 | HEADERS.REMAINING: "X-My-Remaining" 287 | } 288 | ) 289 | 290 | 291 | 292 | 293 | 294 | 295 | -------------------------------------------------------------------------------- /doc/source/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | The source is available on `Github `_ 5 | 6 | To get started 7 | 8 | .. code:: console 9 | 10 | $ git clone git://github.com/alisaifee/flask-limiter.git 11 | $ cd flask-limiter 12 | $ pip install -r requirements/dev.txt 13 | 14 | Tests 15 | ----- 16 | Since some of the tests rely on having a redis & memcached instance available, 17 | you will need a working docker installation to run all the tests. 18 | 19 | .. code:: console 20 | 21 | $ pytest 22 | 23 | 24 | Running the tests will automatically invoke :program:`docker-compose` with the following config (:githubsrc:`docker-compose.yml`) 25 | 26 | .. literalinclude:: ../../docker-compose.yml 27 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. _pymemcache: https://pypi.python.org/pypi/pymemcache 2 | .. _redis: https://pypi.python.org/pypi/redis 3 | .. _github issue #41: https://github.com/alisaifee/flask-limiter/issues/41 4 | .. _flask apps and ip spoofing: http://esd.io/blog/flask-apps-heroku-real-ip-spoofing.html 5 | 6 | .. image:: _static/logo.png 7 | :target: / 8 | :width: 600px 9 | :align: center 10 | :class: logo 11 | 12 | ============= 13 | Flask-Limiter 14 | ============= 15 | 16 | .. currentmodule:: flask_limiter 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | :hidden: 21 | 22 | strategies 23 | configuration 24 | recipes 25 | cli 26 | api 27 | development 28 | changelog 29 | misc 30 | 31 | 32 | .. container:: badges 33 | 34 | .. image:: https://img.shields.io/github/last-commit/alisaifee/flask-limiter?logo=github&style=for-the-badge&labelColor=#282828 35 | :target: https://github.com/alisaifee/flask-limiter 36 | :class: header-badge 37 | .. image:: https://img.shields.io/github/actions/workflow/status/alisaifee/flask-limiter/main.yml?logo=github&style=for-the-badge&labelColor=#282828 38 | :target: https://github.com/alisaifee/flask-limiter/actions/workflows/main.yml 39 | :class: header-badge 40 | .. image:: https://img.shields.io/codecov/c/github/alisaifee/flask-limiter?logo=codecov&style=for-the-badge&labelColor=#282828 41 | :target: https://app.codecov.io/gh/alisaifee/flask-limiter 42 | :class: header-badge 43 | .. image:: https://img.shields.io/pypi/pyversions/flask-limiter?style=for-the-badge&logo=pypi 44 | :target: https://pypi.org/project/flask-limiter 45 | :class: header-badge 46 | 47 | **Flask-Limiter** adds rate limiting to :class:`~flask.Flask` applications. 48 | 49 | By adding the extension to your flask application, you can configure various 50 | rate limits at different levels (e.g. application wide, per :class:`~flask.Blueprint`, 51 | routes, resource etc). 52 | 53 | **Flask-Limiter** can be configured to persist the rate limit state to many 54 | commonly used storage backends via the :doc:`limits:index` library. 55 | 56 | 57 | Let's get started! 58 | 59 | Installation 60 | ============ 61 | 62 | **Flask-Limiter** can be installed via :program:`pip`. 63 | 64 | .. code:: console 65 | 66 | $ pip install Flask-Limiter 67 | 68 | To include extra dependencies for a specific storage backend you can add the 69 | specific backend name via the ``extras`` notation. For example: 70 | 71 | .. tab:: Redis 72 | 73 | .. code:: console 74 | 75 | $ pip install Flask-Limiter[redis] 76 | 77 | .. tab:: Memcached 78 | 79 | .. code:: console 80 | 81 | $ pip install Flask-Limiter[memcached] 82 | 83 | .. tab:: MongoDB 84 | 85 | .. code:: console 86 | 87 | $ pip install Flask-Limiter[mongodb] 88 | 89 | .. tab:: Valkey 90 | 91 | .. code:: console 92 | 93 | $ pip install Flask-Limiter[valkey] 94 | 95 | 96 | Quick start 97 | =========== 98 | A very basic setup can be achieved as follows: 99 | 100 | .. literalinclude:: ../../examples/sample.py 101 | :language: py 102 | 103 | The above Flask app will have the following rate limiting characteristics: 104 | 105 | * Use an in-memory storage provided by :class:`limits.storage.MemoryStorage`. 106 | 107 | .. note:: This is only meant for testing/development and should be replaced with 108 | an appropriate storage of your choice before moving to production. 109 | * Rate limiting by the ``remote_address`` of the request 110 | * A default rate limit of 200 per day, and 50 per hour applied to all routes. 111 | * The ``slow`` route having an explicit rate limit decorator will bypass the default 112 | rate limit and only allow 1 request per day. 113 | * The ``medium`` route inherits the default limits and adds on a decorated limit 114 | of 1 request per second. 115 | * The ``ping`` route will be exempt from any default rate limits. 116 | 117 | .. tip:: The built in flask static files routes are also exempt from rate limits. 118 | 119 | Every time a request exceeds the rate limit, the view function will not get called and instead 120 | a `429 `_ http error will be raised. 121 | 122 | The extension adds a ``limiter`` subcommand to the :doc:`Flask CLI ` which can be used to inspect 123 | the effective configuration and applied rate limits (See :ref:`cli:Command Line Interface` for more details). 124 | 125 | Given the quick start example above: 126 | 127 | 128 | .. code-block:: shell 129 | 130 | $ flask limiter config 131 | 132 | .. program-output:: FLASK_APP=../../examples/sample.py:app flask limiter config 133 | :shell: 134 | 135 | .. code-block:: shell 136 | 137 | $ flask limiter limits 138 | 139 | .. program-output:: FLASK_APP=../../examples/sample.py:app flask limiter limits 140 | :shell: 141 | 142 | The Flask-Limiter extension 143 | --------------------------- 144 | The extension can be initialized with the :class:`flask.Flask` application 145 | in the usual ways. 146 | 147 | Using the constructor 148 | 149 | .. code-block:: python 150 | 151 | from flask_limiter import Limiter 152 | from flask_limiter.util import get_remote_address 153 | .... 154 | 155 | limiter = Limiter(get_remote_address, app=app) 156 | 157 | Deferred app initialization using :meth:`~flask_limiter.Limiter.init_app` 158 | 159 | .. code-block:: python 160 | 161 | limiter = Limiter(get_remote_address) 162 | limiter.init_app(app) 163 | 164 | At this point it might be a good idea to look at the configuration options 165 | available in the extension in the :ref:`configuration:using flask config` section and the 166 | :class:`flask_limiter.Limiter` class documentation. 167 | 168 | ----------------------------- 169 | Configuring a storage backend 170 | ----------------------------- 171 | 172 | The extension can be configured to use any storage supported by :pypi:`limits`. 173 | Here are a few common examples: 174 | 175 | .. tab:: Memcached 176 | 177 | Any additional parameters provided in :paramref:`~Limiter.storage_options` 178 | will be passed to the constructor of the memcached client 179 | (either :class:`~pymemcache.client.base.PooledClient` or :class:`~pymemcache.client.hash.HashClient`). 180 | For more details see :class:`~limits.storage.MemcachedStorage`. 181 | 182 | .. code-block:: python 183 | 184 | from flask_limiter import Limiter 185 | from flask_limiter.util import get_remote_address 186 | .... 187 | 188 | limiter = Limiter( 189 | get_remote_address, 190 | app=app, 191 | storage_uri="memcached://localhost:11211", 192 | storage_options={} 193 | ) 194 | 195 | .. tab:: Redis 196 | 197 | Any additional parameters provided in :paramref:`~Limiter.storage_options` 198 | will be passed to :meth:`redis.Redis.from_url` as keyword arguments. 199 | For more details see :class:`~limits.storage.RedisStorage` 200 | 201 | .. code-block:: python 202 | 203 | from flask_limiter import Limiter 204 | from flask_limiter.util import get_remote_address 205 | .... 206 | 207 | limiter = Limiter( 208 | get_remote_address, 209 | app=app, 210 | storage_uri="redis://localhost:6379", 211 | storage_options={"socket_connect_timeout": 30}, 212 | strategy="fixed-window", # or "moving-window" or "sliding-window-counter" 213 | ) 214 | 215 | .. tab:: Redis (reused connection pool) 216 | 217 | If you wish to reuse a :class:`redis.connection.ConnectionPool` instance 218 | you can pass that in :paramref:`~Limiter.storage_option` 219 | 220 | .. code-block:: python 221 | 222 | import redis 223 | from flask_limiter import Limiter 224 | from flask_limiter.util import get_remote_address 225 | .... 226 | 227 | pool = redis.connection.BlockingConnectionPool.from_url("redis://.....") 228 | limiter = Limiter( 229 | get_remote_address, 230 | app=app, 231 | storage_uri="redis://", 232 | storage_options={"connection_pool": pool}, 233 | strategy="fixed-window", # or "moving-window" or "sliding-window-counter" 234 | ) 235 | 236 | .. tab:: Redis Cluster 237 | 238 | Any additional parameters provided in :paramref:`~Limiter.storage_options` 239 | will be passed to :class:`~redis.cluster.RedisCluster` as keyword arguments. 240 | For more details see :class:`~limits.storage.RedisClusterStorage` 241 | 242 | .. code-block:: python 243 | 244 | from flask_limiter import Limiter 245 | from flask_limiter.util import get_remote_address 246 | .... 247 | 248 | limiter = Limiter( 249 | get_remote_address, 250 | app=app, 251 | storage_uri="redis+cluster://localhost:7000,localhost:7001,localhost:7002", 252 | storage_options={"socket_connect_timeout": 30}, 253 | strategy="fixed-window", # or "moving-window" or "sliding-window-counter" 254 | ) 255 | 256 | .. tab:: MongoDB 257 | 258 | .. code-block:: python 259 | 260 | from flask_limiter import Limiter 261 | from flask_limiter.util import get_remote_address 262 | .... 263 | 264 | limiter = Limiter( 265 | get_remote_address, 266 | app=app, 267 | storage_uri="mongodb://localhost:27017", 268 | strategy="fixed-window", # or "moving-window" or "sliding-window-counter" 269 | ) 270 | 271 | The :paramref:`~Limiter.storage_uri` and :paramref:`~Limiter.storage_options` parameters 272 | can also be provided by :ref:`configuration:using flask config` variables. The different 273 | configuration options for each storage can be found in the :doc:`storage backend documentation for limits ` 274 | as that is delegated to the :pypi:`limits` library. 275 | 276 | .. _ratelimit-domain: 277 | 278 | Rate Limit Domain 279 | ----------------- 280 | Each :class:`~flask_limiter.Limiter` instance must be initialized with a 281 | :paramref:`~Limiter.key_func` that returns the bucket in which each request 282 | is put into when evaluating whether it is within the rate limit or not. 283 | 284 | For simple setups a utility function is provided: 285 | :func:`~flask_limiter.util.get_remote_address` which uses the 286 | :attr:`~flask.Request.remote_addr` from :class:`flask.Request`. 287 | 288 | Please refer to :ref:`deploy-behind-proxy` for an example. 289 | 290 | 291 | Decorators to declare rate limits 292 | ================================= 293 | Decorators made available as instance methods of the :class:`~flask_limiter.Limiter` 294 | instance to be used with the :class:`flask.Flask` application. 295 | 296 | .. _ratelimit-decorator-limit: 297 | 298 | Route specific limits 299 | --------------------- 300 | 301 | .. automethod:: Limiter.limit 302 | :noindex: 303 | 304 | There are a few ways of using the :meth:`~flask_limiter.Limiter.limit` decorator 305 | depending on your preference and use-case. 306 | 307 | ---------------- 308 | Single decorator 309 | ---------------- 310 | 311 | The limit string can be a single limit or a delimiter separated string 312 | 313 | .. code-block:: python 314 | 315 | @app.route("....") 316 | @limiter.limit("100/day;10/hour;1/minute") 317 | def my_route() 318 | ... 319 | 320 | ------------------- 321 | Multiple decorators 322 | ------------------- 323 | 324 | The limit string can be a single limit or a delimiter separated string 325 | or a combination of both. 326 | 327 | .. code-block:: python 328 | 329 | @app.route("....") 330 | @limiter.limit("100/day") 331 | @limiter.limit("10/hour") 332 | @limiter.limit("1/minute") 333 | def my_route(): 334 | ... 335 | 336 | ---------------------- 337 | Custom keying function 338 | ---------------------- 339 | 340 | By default rate limits are applied based on the key function that the :class:`~flask_limiter.Limiter` instance 341 | was initialized with. You can implement your own function to retrieve the key to rate limit by 342 | when decorating individual routes. Take a look at :ref:`keyfunc-customization` for some examples.. 343 | 344 | .. code-block:: python 345 | 346 | def my_key_func(): 347 | ... 348 | 349 | @app.route("...") 350 | @limiter.limit("100/day", my_key_func) 351 | def my_route(): 352 | ... 353 | 354 | .. note:: The key function is called from within a 355 | :doc:`flask request context `. 356 | 357 | ---------------------------------- 358 | Dynamically loaded limit string(s) 359 | ---------------------------------- 360 | 361 | There may be situations where the rate limits need to be retrieved from 362 | sources external to the code (database, remote api, etc...). This can be 363 | achieved by providing a callable to the decorator. 364 | 365 | 366 | .. code-block:: python 367 | 368 | def rate_limit_from_config(): 369 | return current_app.config.get("CUSTOM_LIMIT", "10/s") 370 | 371 | @app.route("...") 372 | @limiter.limit(rate_limit_from_config) 373 | def my_route(): 374 | ... 375 | 376 | .. warning:: The provided callable will be called for every request 377 | on the decorated route. For expensive retrievals, consider 378 | caching the response. 379 | 380 | 381 | .. note:: The callable is called from within a 382 | :doc:`flask request context ` during the 383 | `before_request` phase. 384 | 385 | 386 | -------------------- 387 | Exemption conditions 388 | -------------------- 389 | 390 | Each limit can be exempted when given conditions are fulfilled. These 391 | conditions can be specified by supplying a callable as an 392 | :attr:`exempt_when` argument when defining the limit. 393 | 394 | .. code-block:: python 395 | 396 | @app.route("/expensive") 397 | @limiter.limit("100/day", exempt_when=lambda: current_user.is_admin) 398 | def expensive_route(): 399 | ... 400 | 401 | .. _ratelimit-decorator-shared-limit: 402 | 403 | Reusable limits 404 | --------------- 405 | 406 | For scenarios where a rate limit should be shared by multiple routes 407 | (For example when you want to protect routes using the same resource 408 | with an umbrella rate limit). 409 | 410 | .. automethod:: Limiter.shared_limit 411 | :noindex: 412 | 413 | 414 | ------------------ 415 | Named shared limit 416 | ------------------ 417 | 418 | .. code-block:: python 419 | 420 | mysql_limit = limiter.shared_limit("100/hour", scope="mysql") 421 | 422 | @app.route("..") 423 | @mysql_limit 424 | def r1(): 425 | ... 426 | 427 | @app.route("..") 428 | @mysql_limit 429 | def r2(): 430 | ... 431 | 432 | 433 | -------------------- 434 | Dynamic shared limit 435 | -------------------- 436 | 437 | When a callable is passed as scope, the return value 438 | of the function will be used as the scope. Note that the callable takes one argument: a string representing 439 | the request endpoint. 440 | 441 | .. code-block:: python 442 | 443 | def host_scope(endpoint_name): 444 | return request.host 445 | host_limit = limiter.shared_limit("100/hour", scope=host_scope) 446 | 447 | @app.route("..") 448 | @host_limit 449 | def r1(): 450 | ... 451 | 452 | @app.route("..") 453 | @host_limit 454 | def r2(): 455 | ... 456 | 457 | 458 | .. _ratelimit-decorator-exempt: 459 | 460 | Decorators for skipping rate limits 461 | ----------------------------------- 462 | 463 | Registering exemptions from rate limits 464 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 465 | 466 | .. automethod:: Limiter.exempt 467 | :noindex: 468 | 469 | .. _ratelimit-decorator-request-filter: 470 | 471 | Skipping a rate limit based on a request 472 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 473 | 474 | This decorator marks a function as a filter for requests that are going to be tested for rate limits. If any of the request filters return ``True`` no 475 | rate limiting will be performed for that request. This mechanism can be used to 476 | create custom white lists. 477 | 478 | .. automethod:: Limiter.request_filter 479 | :noindex: 480 | 481 | .. code-block:: python 482 | 483 | @limiter.request_filter 484 | def header_whitelist(): 485 | return request.headers.get("X-Internal", "") == "true" 486 | 487 | @limiter.request_filter 488 | def ip_whitelist(): 489 | return request.remote_addr == "127.0.0.1" 490 | 491 | In the above example, any request that contains the header ``X-Internal: true`` 492 | or originates from localhost will not be rate limited. 493 | 494 | 495 | For more complex use cases, refer to the :ref:`recipes:recipes` section. 496 | -------------------------------------------------------------------------------- /doc/source/misc.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Appendix 3 | ======== 4 | 5 | References 6 | ========== 7 | * `Redis rate limiting pattern #2 `_ 8 | * `DomainTools redis rate limiter `_ 9 | * `limits: python rate limiting utilities `_ 10 | 11 | .. include:: ../../CONTRIBUTIONS.rst 12 | -------------------------------------------------------------------------------- /doc/source/recipes.rst: -------------------------------------------------------------------------------- 1 | Recipes 2 | ======= 3 | .. currentmodule:: flask_limiter 4 | 5 | .. _keyfunc-customization: 6 | 7 | Rate Limit Key Functions 8 | ------------------------- 9 | 10 | You can easily customize your rate limits to be based on any 11 | characteristic of the incoming request. Both the :class:`~Limiter` constructor 12 | and the :meth:`~Limiter.limit` decorator accept a keyword argument 13 | ``key_func`` that should return a string (or an object that has a string representation). 14 | 15 | Rate limiting a route by current user (using Flask-Login):: 16 | 17 | 18 | @route("/test") 19 | @login_required 20 | @limiter.limit("1 per day", key_func = lambda : current_user.username) 21 | def test_route(): 22 | return "42" 23 | 24 | 25 | 26 | Rate limiting all requests by country:: 27 | 28 | from flask import request, Flask 29 | import GeoIP 30 | gi = GeoIP.open("GeoLiteCity.dat", GeoIP.GEOIP_INDEX_CACHE | GeoIP.GEOIP_CHECK_CACHE) 31 | 32 | def get_request_country(): 33 | return gi.record_by_name(request.remote_addr)['region_name'] 34 | 35 | app = Flask(__name__) 36 | limiter = Limiter(get_request_country, app=app, default_limits=["10/hour"]) 37 | 38 | 39 | 40 | Custom Rate limit exceeded responses 41 | ------------------------------------ 42 | The default configuration results in a :exc:`RateLimitExceeded` exception being 43 | thrown (**which effectively halts any further processing and a response with status `429`**). 44 | 45 | The exceeded limit is added to the response and results in an response body that looks something like: 46 | 47 | .. code:: html 48 | 49 | 50 | 429 Too Many Requests 51 |

Too Many Requests

52 |

1 per 1 day

53 | 54 | For all routes that are rate limited 55 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 56 | If you want to configure the response you can register an error handler for the 57 | ``429`` error code in a manner similar to the following example, which returns a 58 | json response instead:: 59 | 60 | @app.errorhandler(429) 61 | def ratelimit_handler(e): 62 | return make_response( 63 | jsonify(error=f"ratelimit exceeded {e.description}") 64 | , 429 65 | ) 66 | 67 | .. versionadded:: 2.6.0 68 | 69 | The same effect can be achieved by using the :paramref:`~Limiter.on_breach` parameter 70 | when initializing the :class:`Limiter`. If the callback passed to this parameter 71 | returns an instance of :class:`~flask.Response` that response will be the one embedded 72 | into the :exc:`RateLimitExceeded` exception that is raised. 73 | 74 | For example:: 75 | 76 | from flask import make_response, render_template 77 | from flask_limiter import Limiter, RequestLimit 78 | 79 | def default_error_responder(request_limit: RequestLimit): 80 | return make_response( 81 | render_template("my_ratelimit_template.tmpl", request_limit=request_limit), 82 | 429 83 | ) 84 | 85 | app = Limiter( 86 | key_func=..., 87 | default_limits=["100/minute"], 88 | on_breach=default_error_responder 89 | ) 90 | 91 | .. tip:: If you have specified both an :paramref:`~Limiter.on_breach` callback 92 | and registered a callback using the :meth:`~flask.Flask.errorhandler` decorator, the one 93 | registered for ``429`` errors will still be called and could end up ignoring 94 | the response returned by the :paramref:`~Limiter.on_breach` callback. 95 | 96 | There may be legitimate reasons to do this (for example if your application raises 97 | ``429`` errors by itself or through another middleware). 98 | 99 | This can be managed in the callback registered with :meth:`~flask.Flask.errorhandler` 100 | by checking if the incoming error has a canned response and using that instead of building 101 | a new one:: 102 | 103 | @app.errorhandler(429) 104 | def careful_ratelimit_handler(error): 105 | return error.get_response() or make_response( 106 | jsonify( 107 | error=f"ratelimit exceeded {e.description}" 108 | ), 109 | 429 110 | ) 111 | 112 | .. note:: 113 | .. versionchanged:: 2.8.0 114 | Any errors encountered when calling an :paramref:`~Limiter.on_breach` callback will 115 | be re-raised unless :paramref:`~Limiter.swallow_errors` is set to ``True`` 116 | 117 | For specific rate limit decorated routes 118 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 119 | .. versionadded:: 2.6.0 120 | 121 | If the objective is to only customize rate limited error responses for certain 122 | rate limited routes this can be achieved in a similar manner as above, 123 | through the :paramref:`~Limiter.limit.on_breach` parameter of the rate limit decorator. 124 | 125 | Following the example from above where the extension was initialized with an :paramref:`~Limiter.on_breach` 126 | callback, the ``index`` route below declares it's own :paramref:`~Limiter.limiter.on_breach` callback which 127 | instead of rendering a template returns a json response (with a ``200`` status code):: 128 | 129 | app = Limiter( 130 | key_func=..., 131 | default_limits=["100/minute"], 132 | on_breach=default_error_responder 133 | ) 134 | 135 | def index_ratelimit_error_responder(request_limit: RequestLimit): 136 | return jsonify({"error": "rate_limit_exceeded"}) 137 | 138 | @app.route("/") 139 | @limiter.limit("10/minute", on_breach=index_ratelimit_error_responder) 140 | def index(): 141 | ... 142 | 143 | The above example also demonstrates the subtle implementation detail that the 144 | response from :paramref:`Limiter.limiter.on_breach` callback (if provided) will 145 | take priority over the response from the :paramref:`Limiter.on_breach` callback if 146 | there is one. 147 | 148 | Meta limits 149 | ----------- 150 | .. versionadded:: 3.5.0 151 | 152 | Meta limits can be used for an additional layer of protection (for example 153 | against denial of service attacks) by limiting the number of times a requesting 154 | client can hit any rate limit in the application within configured time slices. 155 | 156 | These can be configured by using the :paramref:`~flask_limiter.Limiter.meta_limits` 157 | constructor argument (or the associated :data:`RATELIMIT_META` flask 158 | config attribute). 159 | 160 | 161 | Consider the following application & limiter configuration:: 162 | 163 | app = Limiter( 164 | key_func=get_remote_address, 165 | meta_limits=["2/hour", "4/day"], 166 | default_limits=["10/minute"], 167 | ) 168 | 169 | @app.route("/fast") 170 | def fast(): 171 | return "fast" 172 | 173 | @app.route("/slow") 174 | @limiter.limit("1/minute") 175 | def slow(): 176 | return "slow" 177 | 178 | 179 | The ``2/hour, 4/day`` value of :paramref:`~flask_limiter.Limiter.meta_limits` ensures that if 180 | any of the ``default_limits`` or per route limit of ``1/minute`` is exceeded more than 181 | **twice an hour** or **four times a day**, a :class:`~flask_limiter.RateLimitExceeded` exception will be 182 | raised (i.e. a ``429`` response will be returned) for any subsequent request until the ``meta_limit`` is reset. 183 | 184 | For example 185 | 186 | .. code-block:: shell 187 | 188 | $ curl localhost:5000/fast 189 | fast 190 | $ curl localhost:5000/slow 191 | slow 192 | $ curl localhost:5000/slow 193 | 194 | 195 | 429 Too Many Requests 196 |

Too Many Requests

197 |

1 per 1 minute

198 | 199 | After a minute the ``slow`` endpoint can be accessed again once per minute 200 | 201 | .. code-block:: shell 202 | 203 | $ sleep 60 204 | $ curl localhost:5000/slow 205 | slow 206 | $ curl localhost:5000/slow 207 | 208 | 209 | 429 Too Many Requests 210 |

Too Many Requests

211 |

1 per 1 minute

212 | 213 | Now, even after waiting a minute both the ``slow`` and ``fast`` endpoints 214 | are rejected due to the ``2/hour`` meta limit. 215 | 216 | .. code-block:: shell 217 | 218 | $ sleep 60 219 | $ curl localhost:5000/slow 220 | 221 | 222 | 429 Too Many Requests 223 |

Too Many Requests

224 |

2 per 1 hour

225 | $ curl localhost:5000/fast 226 | 227 | 228 | 429 Too Many Requests 229 |

Too Many Requests

230 |

2 per 1 hour

231 | 232 | Customizing the cost of a request 233 | --------------------------------- 234 | By default whenever a request is served a **cost** of ``1`` is charged for 235 | each rate limit that applies within the context of that request. 236 | 237 | There may be situations where a different value should be used. 238 | 239 | The :meth:`~flask_limiter.Limiter.limit` and :meth:`~flask_limiter.Limiter.shared_limit` 240 | decorators both accept a ``cost`` parameter which accepts either a static :class:`int` or 241 | a callable that returns an :class:`int`. 242 | 243 | As an example, the following configuration will result in a double penalty whenever 244 | ``Some reason`` is true :: 245 | 246 | from flask import request, current_app 247 | 248 | def my_cost_function() -> int: 249 | if .....: # Some reason 250 | return 2 251 | return 1 252 | 253 | @app.route("/") 254 | @limiter.limit("100/second", cost=my_cost_function) 255 | def root(): 256 | ... 257 | 258 | A similar approach can be used for both default and application level limits by 259 | providing either a cost function to the :class:`~flask_limiter.Limiter` constructor 260 | via the :paramref:`~flask_limiter.Limiter.default_limits_cost` or 261 | :paramref:`~flask_limiter.Limiter.application_limits_cost` parameters. 262 | 263 | Customizing rate limits based on response 264 | ----------------------------------------- 265 | For scenarios where the decision to count the current request towards a rate limit 266 | can only be made after the request has completed, a callable that accepts the current 267 | :class:`flask.Response` object as its argument can be provided to the :meth:`~Limiter.limit` or 268 | :meth:`~Limiter.shared_limit` decorators through the ``deduct_when`` keyword argument. 269 | A truthy response from the callable will result in a deduction from the rate limit. 270 | 271 | As an example, to only count non `200` responses towards the rate limit 272 | 273 | 274 | .. code-block:: python 275 | 276 | @app.route("..") 277 | @limiter.limit( 278 | "1/second", 279 | deduct_when=lambda response: response.status_code != 200 280 | ) 281 | def route(): 282 | ... 283 | 284 | 285 | `deduct_when` can also be provided for default limits by providing the 286 | :paramref:`~flask_limiter.Limiter.default_limits_deduct_when` parameter 287 | to the :class:`~flask_limiter.Limiter` constructor. 288 | 289 | 290 | .. note:: All requests will be tested for the rate limit and rejected accordingly 291 | if the rate limit is already hit. The provision of the `deduct_when` 292 | argument only changes whether the request will count towards depleting the rate limit. 293 | 294 | 295 | .. _using-flask-pluggable-views: 296 | 297 | Rate limiting Class-based Views 298 | ------------------------------- 299 | 300 | If you are taking a class based approach for defining views, 301 | the recommended method (:doc:`flask:views`) of adding decorators is 302 | to add the :meth:`~Limiter.limit` decorator to :attr:`~flask.views.View.decorators` in your view subclass as shown in the 303 | example below 304 | 305 | 306 | .. code-block:: python 307 | 308 | app = Flask(__name__) 309 | limiter = Limiter(get_remote_address, app=app) 310 | 311 | class MyView(flask.views.MethodView): 312 | decorators = [limiter.limit("10/second")] 313 | 314 | def get(self): 315 | return "get" 316 | 317 | def put(self): 318 | return "put" 319 | 320 | 321 | .. note:: This approach is limited to either sharing the same rate limit for 322 | all http methods of a given :class:`flask.views.View` or applying the declared 323 | rate limit independently for each http method (to accomplish this, pass in ``True`` to 324 | the ``per_method`` keyword argument to :meth:`~Limiter.limit`). Alternatively, the limit 325 | can be restricted to only certain http methods by passing them as a list to the `methods` 326 | keyword argument. 327 | 328 | 329 | Rate limiting all routes in a :class:`~flask.Blueprint` 330 | ------------------------------------------------------- 331 | 332 | .. warning:: :class:`~flask.Blueprint` instances that are registered on another blueprint 333 | instead of on the main :class:`~flask.Flask` instance had not been considered 334 | upto :ref:`changelog:v2.3.0`. Effectively **they neither inherited** the rate limits 335 | explicitly registered on the parent :class:`~flask.Blueprint` **nor were they 336 | exempt** from rate limits if the parent had been marked exempt. 337 | (See :issue:`326`, and the :ref:`recipes:nested blueprints` section below). 338 | 339 | :meth:`~Limiter.limit`, :meth:`~Limiter.shared_limit` & 340 | :meth:`~Limiter.exempt` can all be applied to :class:`flask.Blueprint` instances as well. 341 | In the following example the ``login`` Blueprint has a special rate limit applied to all its routes, while 342 | the ``doc`` Blueprint is exempt from all rate limits. The ``regular`` Blueprint follows the default rate limits. 343 | 344 | 345 | .. code-block:: python 346 | 347 | 348 | app = Flask(__name__) 349 | login = Blueprint("login", __name__, url_prefix = "/login") 350 | regular = Blueprint("regular", __name__, url_prefix = "/regular") 351 | doc = Blueprint("doc", __name__, url_prefix = "/doc") 352 | 353 | @doc.route("/") 354 | def doc_index(): 355 | return "doc" 356 | 357 | @regular.route("/") 358 | def regular_index(): 359 | return "regular" 360 | 361 | @login.route("/") 362 | def login_index(): 363 | return "login" 364 | 365 | 366 | limiter = Limiter(get_remote_address, app=app, default_limits = ["1/second"]) 367 | limiter.limit("60/hour")(login) 368 | limiter.exempt(doc) 369 | 370 | app.register_blueprint(doc) 371 | app.register_blueprint(login) 372 | app.register_blueprint(regular) 373 | 374 | 375 | Nested Blueprints 376 | ^^^^^^^^^^^^^^^^^ 377 | .. versionadded:: 2.3.0 378 | 379 | `Nested Blueprints `__ 380 | require some special considerations. 381 | 382 | ===================================== 383 | Exempting routes in nested Blueprints 384 | ===================================== 385 | 386 | Expanding the example from the Flask documentation:: 387 | 388 | parent = Blueprint('parent', __name__, url_prefix='/parent') 389 | child = Blueprint('child', __name__, url_prefix='/child') 390 | parent.register_blueprint(child) 391 | 392 | limiter.exempt(parent) 393 | 394 | app.register_blueprint(parent) 395 | 396 | Routes under the ``child`` blueprint **do not** automatically get exempted by default 397 | and have to be marked exempt explicitly. This behavior is to maintain backward compatibility 398 | and can be opted out of by adding :attr:`~flask_limiter.ExemptionScope.DESCENDENTS` 399 | to :paramref:`~Limiter.exempt.flags` when calling :meth:`Limiter.exempt`:: 400 | 401 | limiter.exempt( 402 | parent, 403 | flags=ExemptionScope.DEFAULT | ExemptionScope.APPLICATION | ExemptionScope.DESCENDENTS 404 | ) 405 | 406 | =========================================================== 407 | Explicitly setting limits / exemptions on nested Blueprints 408 | =========================================================== 409 | 410 | Using combinations of :paramref:`~Limiter.limit.override_defaults` parameter 411 | when explicitly declaring limits on Blueprints and the :paramref:`~Limiter.exempt.flags` 412 | parameter when exempting Blueprints with :meth:`~Limiter.exempt` 413 | the resolution of inherited and descendent limits within the scope of a Blueprint 414 | can be controlled. 415 | 416 | Here's a slightly involved example:: 417 | 418 | limiter = Limiter( 419 | ..., 420 | default_limits = ["100/hour"], 421 | application_limits = ["100/minute"] 422 | ) 423 | 424 | parent = Blueprint('parent', __name__, url_prefix='/parent') 425 | child = Blueprint('child', __name__, url_prefix='/child') 426 | grandchild = Blueprint('grandchild', __name__, url_prefix='/grandchild') 427 | 428 | health = Blueprint('health', __name__, url_prefix='/health') 429 | 430 | parent.register_blueprint(child) 431 | parent.register_blueprint(health) 432 | child.register_blueprint(grandchild) 433 | child.register_blueprint(health) 434 | grandchild.register_blueprint(health) 435 | 436 | app.register_blueprint(parent) 437 | 438 | limiter.limit("2/minute")(parent) 439 | limiter.limit("1/second", override_defaults=False)(child) 440 | limiter.limit("10/minute")(grandchild) 441 | 442 | limiter.exempt( 443 | health, 444 | flags=ExemptionScope.DEFAULT|ExemptionScope.APPLICATION|ExemptionScope.ANCESTORS 445 | ) 446 | 447 | Effectively this means: 448 | 449 | #. Routes under ``parent`` will override the application defaults and will be 450 | limited to ``2 per minute`` 451 | 452 | #. Routes under ``child`` will respect both the parent and the application defaults 453 | and effectively be limited to ``At most 1 per second, 2 per minute and 100 per hour`` 454 | 455 | #. Routes under ``grandchild`` will not inherit either the limits from `child` or `parent` 456 | or the application defaults and allow ``10 per minute`` 457 | 458 | #. All calls to ``/health/`` will be exempt from all limits (including any limits that would 459 | otherwise be inherited from the Blueprints it is nested under due to the addition of the 460 | :class:`~ExemptionScope.ANCESTORS` flag). 461 | 462 | .. note:: Only calls to `/health` will be exempt from the application wide global 463 | limit of `100/minute`. 464 | 465 | .. _logging: 466 | 467 | Logging 468 | ------- 469 | Each :class:`~Limiter` instance has a registered :class:`~logging.Logger` named ``flask-limiter`` 470 | that is by default **not** configured with a handler. 471 | 472 | This can be configured according to your needs:: 473 | 474 | import logging 475 | limiter_logger = logging.getLogger("flask-limiter") 476 | 477 | # force DEBUG logging 478 | limiter_logger.setLevel(logging.DEBUG) 479 | 480 | # restrict to only error level 481 | limiter_logger.setLevel(logging.ERROR) 482 | 483 | # Add a filter 484 | limiter_logger.addFilter(SomeFilter) 485 | 486 | # etc .. 487 | 488 | 489 | 490 | Custom error messages 491 | --------------------- 492 | :meth:`~Limiter.limit` & :meth:`~Limiter.shared_limit` can be provided with an `error_message` 493 | argument to over ride the default `n per x` error message that is returned to the calling client. 494 | The `error_message` argument can either be a simple string or a callable that returns one. 495 | 496 | .. code-block:: python 497 | 498 | 499 | app = Flask(__name__) 500 | limiter = Limiter(get_remote_address, app=app) 501 | 502 | def error_handler(): 503 | return app.config.get("DEFAULT_ERROR_MESSAGE") 504 | 505 | @app.route("/") 506 | @limiter.limit("1/second", error_message='chill!') 507 | def index(): 508 | .... 509 | 510 | @app.route("/ping") 511 | @limiter.limit("10/second", error_message=error_handler) 512 | def ping(): 513 | .... 514 | 515 | Custom rate limit headers 516 | ------------------------- 517 | Though you can get pretty far with configuring the standard headers associated 518 | with rate limiting using configuration parameters available as described under 519 | :ref:`configuration:rate-limiting headers` - this may not be sufficient for your use case. 520 | 521 | For such cases you can access the :attr:`~Limiter.current_limit` 522 | property from the :class:`~Limiter` instance from anywhere within a :doc:`request context `. 523 | 524 | As an example you could leave the built in header population disabled 525 | and add your own with an :meth:`~flask.Flask.after_request` hook:: 526 | 527 | 528 | app = Flask(__name__) 529 | limiter = Limiter(get_remote_address, app=app) 530 | 531 | 532 | @app.route("/") 533 | @limiter.limit("1/second") 534 | def index(): 535 | .... 536 | 537 | @app.after_request 538 | def add_headers(response): 539 | if limiter.current_limit: 540 | response.headers["RemainingLimit"] = limiter.current_limit.remaining 541 | response.headers["ResetAt"] = limiter.current_limit.reset_at 542 | response.headers["MaxRequests"] = limiter.current_limit.limit.amount 543 | response.headers["WindowSize"] = limiter.current_limit.limit.get_expiry() 544 | response.headers["Breached"] = limiter.current_limit.breached 545 | return response 546 | 547 | This will result in headers along the lines of:: 548 | 549 | < RemainingLimit: 0 550 | < ResetAt: 1641691205 551 | < MaxRequests: 1 552 | < WindowSize: 1 553 | < Breached: True 554 | 555 | .. _deploy-behind-proxy: 556 | 557 | Deploying an application behind a proxy 558 | --------------------------------------- 559 | 560 | If your application is behind a proxy and you are using werkzeug > 0.9+ you can use the :class:`werkzeug.middleware.proxy_fix.ProxyFix` 561 | fixer to reliably get the remote address of the user, while protecting your application against ip spoofing via headers. 562 | 563 | 564 | .. code-block:: python 565 | 566 | from flask import Flask 567 | from flask_limiter import Limiter 568 | from flask_limiter.util import get_remote_address 569 | from werkzeug.middleware.proxy_fix import ProxyFix 570 | 571 | app = Flask(__name__) 572 | # for example if the request goes through one proxy 573 | # before hitting your application server 574 | app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) 575 | limiter = Limiter(get_remote_address, app=app) 576 | -------------------------------------------------------------------------------- /doc/source/strategies.rst: -------------------------------------------------------------------------------- 1 | .. _ratelimit-strategy: 2 | 3 | Rate limiting strategies 4 | ======================== 5 | Flask-Limiter delegates the implementation of rate limiting strategies 6 | to the :doc:`limits:index` library. 7 | 8 | The strategy can be selected by setting the :paramref:`flask_limiter.Limiter.strategy` 9 | constructor argument or the :data:`RATELIMIT_STRATEGY` config. 10 | 11 | 12 | .. note:: For more details about the implementation of each strategy 13 | refer to the :pypi:`limits` documentation for :doc:`limits:strategies`. 14 | 15 | 16 | Fixed Window 17 | ------------ 18 | This strategy is the most memory‑efficient because it uses a single counter 19 | per resource and rate limit. When the first request arrives, a window is started 20 | for a fixed duration (e.g., for a rate limit of 10 requests per minute the window 21 | expires in 60 seconds from the first request). 22 | All requests in that window increment the counter and when the window expires, 23 | the counter resets 24 | 25 | See the :ref:`limits:strategies:fixed window` documentation in the :doc:`limits:index` library 26 | for more details. 27 | 28 | To select this strategy, set :paramref:`flask_limiter.Limiter.strategy` or 29 | :data:`RATELIMIT_STRATEGY` to ``fixed-window`` 30 | 31 | Moving Window 32 | ------------- 33 | 34 | This strategy adds each request’s timestamp to a log if the ``nth`` oldest entry (where ``n`` 35 | is the limit) is either not present or is older than the duration of the window (for example with a rate limit of 36 | ``10 requests per minute`` if there are either less than 10 entries or the 10th oldest entry is atleast 37 | 60 seconds old). Upon adding a new entry to the log "expired" entries are truncated. 38 | 39 | See the :ref:`limits:strategies:moving window` documentation in the :doc:`limits:index` library 40 | for more details. 41 | 42 | To select this strategy, set :paramref:`flask_limiter.Limiter.strategy` or 43 | :data:`RATELIMIT_STRATEGY` to ``moving-window`` 44 | 45 | 46 | Sliding Window 47 | -------------- 48 | 49 | This strategy approximates the moving window while using less memory by maintaining 50 | two counters: 51 | 52 | - **Current bucket:** counts requests in the ongoing period. 53 | - **Previous bucket:** counts requests in the immediately preceding period. 54 | 55 | A weighted sum of these counters is computed based on the elapsed time in the current 56 | bucket. 57 | 58 | See the :ref:`limits:strategies:sliding window counter` documentation in the :doc:`limits:index` library 59 | for more details. 60 | 61 | To select this strategy, set :paramref:`flask_limiter.Limiter.strategy` or 62 | :data:`RATELIMIT_STRATEGY` to ``sliding-window-counter`` 63 | -------------------------------------------------------------------------------- /doc/source/theme_config.py: -------------------------------------------------------------------------------- 1 | colors = { 2 | "bg0": " #fbf1c7", 3 | "bg1": " #ebdbb2", 4 | "bg2": " #d5c4a1", 5 | "bg3": " #bdae93", 6 | "bg4": " #a89984", 7 | "gry": " #928374", 8 | "fg4": " #7c6f64", 9 | "fg3": " #665c54", 10 | "fg2": " #504945", 11 | "fg1": " #3c3836", 12 | "fg0": " #282828", 13 | "red": " #cc241d", 14 | "red2": " #9d0006", 15 | "orange": " #d65d0e", 16 | "orange2": " #af3a03", 17 | "yellow": " #d79921", 18 | "yellow2": " #b57614", 19 | "green": " #98971a", 20 | "green2": " #79740e", 21 | "aqua": " #689d6a", 22 | "aqua2": " #427b58", 23 | "blue": " #458588", 24 | "blue2": " #076678", 25 | "purple": " #b16286", 26 | "purple2": " #8f3f71", 27 | } 28 | 29 | html_theme = "furo" 30 | html_theme_options = { 31 | "light_css_variables": { 32 | "font-stack": "Fira Sans, sans-serif", 33 | "font-stack--monospace": "Fira Code, monospace", 34 | "color-brand-primary": colors["purple2"], 35 | "color-brand-content": colors["blue2"], 36 | }, 37 | "dark_css_variables": { 38 | "color-brand-primary": colors["purple"], 39 | "color-brand-content": colors["blue"], 40 | "color-background-primary": colors["fg1"], 41 | "color-background-secondary": colors["fg0"], 42 | "color-foreground-primary": colors["bg0"], 43 | "color-foreground-secondary": colors["bg1"], 44 | "color-highlighted-background": colors["yellow"], 45 | "color-highlight-on-target": colors["fg2"], 46 | }, 47 | } 48 | 49 | highlight_language = "python3" 50 | pygments_style = "gruvbox-light" 51 | pygments_dark_style = "gruvbox-dark" 52 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | memcached: 3 | image: memcached 4 | ports: 5 | - 31211:11211 6 | redis: 7 | image: redis 8 | ports: 9 | - 46379:6379 10 | mongodb: 11 | image: mongo 12 | ports: 13 | - '47017:27017' 14 | -------------------------------------------------------------------------------- /examples/kitchensink.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import jinja2 6 | from flask import Blueprint, Flask, jsonify, make_response, render_template, request 7 | from flask.views import View 8 | 9 | import flask_limiter 10 | from flask_limiter import ExemptionScope, Limiter 11 | from flask_limiter.util import get_remote_address 12 | 13 | 14 | def index_error_responder(request_limit): 15 | error_template = jinja2.Environment().from_string( 16 | """ 17 |

Breached rate limit of: {{request_limit.limit}}

18 |

Path: {{request.path}}

19 | """ 20 | ) 21 | return make_response(render_template(error_template, request_limit=request_limit)) 22 | 23 | 24 | def app(): 25 | def default_limit_extra(): 26 | if request.headers.get("X-Evil"): 27 | return "100/minute" 28 | return "200/minute" 29 | 30 | def default_cost(): 31 | if request.headers.get("X-Evil"): 32 | return 2 33 | return 1 34 | 35 | limiter = Limiter( 36 | get_remote_address, 37 | default_limits=["20/hour", "1000/hour", default_limit_extra], 38 | default_limits_exempt_when=lambda: request.headers.get("X-Internal"), 39 | default_limits_deduct_when=lambda response: response.status_code == 200, 40 | default_limits_cost=default_cost, 41 | application_limits=["5000/hour"], 42 | meta_limits=["2/day"], 43 | headers_enabled=True, 44 | storage_uri=os.environ.get("FLASK_RATELIMIT_STORAGE_URI", "memory://"), 45 | ) 46 | 47 | app = Flask(__name__) 48 | app.config.from_prefixed_env() 49 | 50 | @app.errorhandler(429) 51 | def handle_error(e): 52 | return e.get_response() or make_response( 53 | jsonify(error="ratelimit exceeded %s" % e.description) 54 | ) 55 | 56 | @app.route("/") 57 | @limiter.limit("10/minute", on_breach=index_error_responder) 58 | def root(): 59 | """ 60 | Custom rate limit of 10/minute which overrides the default limits. 61 | The error page displayed on rate limit breached is also customized by using 62 | an `on_breach` callback to render a template 63 | """ 64 | return "42" 65 | 66 | @app.route("/version") 67 | @limiter.exempt 68 | def version(): 69 | """ 70 | Exempt from all rate limits 71 | """ 72 | return flask_limiter.__version__ 73 | 74 | health_blueprint = Blueprint("health", __name__, url_prefix="/health") 75 | 76 | @health_blueprint.route("/") 77 | def health(): 78 | return "ok" 79 | 80 | app.register_blueprint(health_blueprint) 81 | 82 | #: Exempt from default, application and ancestor rate limits (effectively all) 83 | limiter.exempt( 84 | health_blueprint, 85 | flags=ExemptionScope.DEFAULT | ExemptionScope.APPLICATION | ExemptionScope.ANCESTORS, 86 | ) 87 | 88 | class ResourceView(View): 89 | methods = ["GET", "POST"] 90 | 91 | @staticmethod 92 | def json_error_responder(request_limit): 93 | return jsonify({"limit": str(request_limit.limit)}) 94 | 95 | #: Custom rate limit of 5/second by http method type for all routes under this 96 | #: resource view. The error response is also customized by using the `on_breach` 97 | #: callback to return a json error response 98 | decorators = [limiter.limit("5/second", per_method=True, on_breach=json_error_responder)] 99 | 100 | def dispatch_request(self): 101 | return request.method.lower() 102 | 103 | app.add_url_rule("/resource", view_func=ResourceView.as_view("resource")) 104 | 105 | limiter.init_app(app) 106 | 107 | return app 108 | 109 | 110 | if __name__ == "__main__": 111 | app().run() 112 | -------------------------------------------------------------------------------- /examples/sample.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask import Flask 4 | 5 | from flask_limiter import Limiter 6 | from flask_limiter.util import get_remote_address 7 | 8 | app = Flask(__name__) 9 | limiter = Limiter( 10 | get_remote_address, 11 | app=app, 12 | default_limits=["200 per day", "50 per hour"], 13 | storage_uri="memory://", 14 | ) 15 | 16 | 17 | @app.route("/slow") 18 | @limiter.limit("1 per day") 19 | def slow(): 20 | return ":(" 21 | 22 | 23 | @app.route("/medium") 24 | @limiter.limit("1/second", override_defaults=False) 25 | def medium(): 26 | return ":|" 27 | 28 | 29 | @app.route("/fast") 30 | def fast(): 31 | return ":)" 32 | 33 | 34 | @app.route("/ping") 35 | @limiter.exempt 36 | def ping(): 37 | return "PONG" 38 | -------------------------------------------------------------------------------- /flask_limiter/__init__.py: -------------------------------------------------------------------------------- 1 | """Flask-Limiter extension for rate limiting.""" 2 | 3 | from __future__ import annotations 4 | 5 | from . import _version 6 | from .constants import ExemptionScope, HeaderNames 7 | from .errors import RateLimitExceeded 8 | from .extension import Limiter, RequestLimit 9 | from .limits import ( 10 | ApplicationLimit, 11 | Limit, 12 | MetaLimit, 13 | RouteLimit, 14 | ) 15 | 16 | __all__ = [ 17 | "ExemptionScope", 18 | "HeaderNames", 19 | "Limiter", 20 | "Limit", 21 | "RouteLimit", 22 | "ApplicationLimit", 23 | "MetaLimit", 24 | "RateLimitExceeded", 25 | "RequestLimit", 26 | ] 27 | 28 | __version__ = _version.get_versions()["version"] 29 | -------------------------------------------------------------------------------- /flask_limiter/_compat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask 4 | from flask.ctx import RequestContext 5 | 6 | # flask.globals.request_ctx is only available in Flask >= 2.2.0 7 | try: 8 | from flask.globals import request_ctx 9 | except ImportError: 10 | request_ctx = None 11 | 12 | 13 | def request_context() -> RequestContext: 14 | if request_ctx is None: 15 | return flask._request_ctx_stack.top 16 | return request_ctx 17 | -------------------------------------------------------------------------------- /flask_limiter/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | 5 | 6 | class ConfigVars: 7 | ENABLED = "RATELIMIT_ENABLED" 8 | KEY_FUNC = "RATELIMIT_KEY_FUNC" 9 | KEY_PREFIX = "RATELIMIT_KEY_PREFIX" 10 | FAIL_ON_FIRST_BREACH = "RATELIMIT_FAIL_ON_FIRST_BREACH" 11 | ON_BREACH = "RATELIMIT_ON_BREACH_CALLBACK" 12 | SWALLOW_ERRORS = "RATELIMIT_SWALLOW_ERRORS" 13 | APPLICATION_LIMITS = "RATELIMIT_APPLICATION" 14 | APPLICATION_LIMITS_PER_METHOD = "RATELIMIT_APPLICATION_PER_METHOD" 15 | APPLICATION_LIMITS_EXEMPT_WHEN = "RATELIMIT_APPLICATION_EXEMPT_WHEN" 16 | APPLICATION_LIMITS_DEDUCT_WHEN = "RATELIMIT_APPLICATION_DEDUCT_WHEN" 17 | APPLICATION_LIMITS_COST = "RATELIMIT_APPLICATION_COST" 18 | DEFAULT_LIMITS = "RATELIMIT_DEFAULT" 19 | DEFAULT_LIMITS_PER_METHOD = "RATELIMIT_DEFAULTS_PER_METHOD" 20 | DEFAULT_LIMITS_EXEMPT_WHEN = "RATELIMIT_DEFAULTS_EXEMPT_WHEN" 21 | DEFAULT_LIMITS_DEDUCT_WHEN = "RATELIMIT_DEFAULTS_DEDUCT_WHEN" 22 | DEFAULT_LIMITS_COST = "RATELIMIT_DEFAULTS_COST" 23 | REQUEST_IDENTIFIER = "RATELIMIT_REQUEST_IDENTIFIER" 24 | STRATEGY = "RATELIMIT_STRATEGY" 25 | STORAGE_URI = "RATELIMIT_STORAGE_URI" 26 | STORAGE_OPTIONS = "RATELIMIT_STORAGE_OPTIONS" 27 | HEADERS_ENABLED = "RATELIMIT_HEADERS_ENABLED" 28 | HEADER_LIMIT = "RATELIMIT_HEADER_LIMIT" 29 | HEADER_REMAINING = "RATELIMIT_HEADER_REMAINING" 30 | HEADER_RESET = "RATELIMIT_HEADER_RESET" 31 | HEADER_RETRY_AFTER = "RATELIMIT_HEADER_RETRY_AFTER" 32 | HEADER_RETRY_AFTER_VALUE = "RATELIMIT_HEADER_RETRY_AFTER_VALUE" 33 | IN_MEMORY_FALLBACK = "RATELIMIT_IN_MEMORY_FALLBACK" 34 | IN_MEMORY_FALLBACK_ENABLED = "RATELIMIT_IN_MEMORY_FALLBACK_ENABLED" 35 | META_LIMITS = "RATELIMIT_META" 36 | ON_META_BREACH = "RATELIMIT_ON_META_BREACH_CALLBACK" 37 | 38 | 39 | class HeaderNames(enum.Enum): 40 | """ 41 | Enumeration of supported rate limit related headers to 42 | be used when configuring via :paramref:`~flask_limiter.Limiter.header_name_mapping` 43 | """ 44 | 45 | #: Timestamp at which this rate limit will be reset 46 | RESET = "X-RateLimit-Reset" 47 | #: Remaining number of requests within the current window 48 | REMAINING = "X-RateLimit-Remaining" 49 | #: Total number of allowed requests within a window 50 | LIMIT = "X-RateLimit-Limit" 51 | #: Number of seconds to retry after at 52 | RETRY_AFTER = "Retry-After" 53 | 54 | 55 | class ExemptionScope(enum.Flag): 56 | """ 57 | Flags used to configure the scope of exemption when used 58 | in conjunction with :meth:`~flask_limiter.Limiter.exempt`. 59 | """ 60 | 61 | NONE = 0 62 | 63 | #: Exempt from application wide "global" limits 64 | APPLICATION = enum.auto() 65 | #: Exempts from meta limits 66 | META = enum.auto() 67 | #: Exempt from default limits configured on the extension 68 | DEFAULT = enum.auto() 69 | #: Exempts any nested blueprints. See :ref:`recipes:nested blueprints` 70 | DESCENDENTS = enum.auto() 71 | #: Exempt from any rate limits inherited from ancestor blueprints. 72 | #: See :ref:`recipes:nested blueprints` 73 | ANCESTORS = enum.auto() 74 | 75 | 76 | MAX_BACKEND_CHECKS = 5 77 | -------------------------------------------------------------------------------- /flask_limiter/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | """Contributed 'recipes'""" 2 | -------------------------------------------------------------------------------- /flask_limiter/contrib/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask import request 4 | 5 | 6 | def get_remote_address_cloudflare() -> str: 7 | """ 8 | :return: the ip address for the current request from the CF-Connecting-IP header 9 | (or 127.0.0.1 if none found) 10 | 11 | """ 12 | return request.headers["CF-Connecting-IP"] or "127.0.0.1" 13 | -------------------------------------------------------------------------------- /flask_limiter/errors.py: -------------------------------------------------------------------------------- 1 | """errors and exceptions.""" 2 | 3 | from __future__ import annotations 4 | 5 | from flask.wrappers import Response 6 | from werkzeug import exceptions 7 | 8 | from .limits import RuntimeLimit 9 | 10 | 11 | class RateLimitExceeded(exceptions.TooManyRequests): 12 | """Exception raised when a rate limit is hit.""" 13 | 14 | def __init__(self, limit: RuntimeLimit, response: Response | None = None) -> None: 15 | """ 16 | :param limit: The actual rate limit that was hit. This is used to construct the default 17 | response message 18 | :param response: Optional pre constructed response. If provided it will be rendered by 19 | flask instead of the default error response of :class:`~werkzeug.exceptions.HTTPException` 20 | """ 21 | self.limit = limit 22 | self.response = response 23 | if limit.error_message: 24 | description = ( 25 | limit.error_message if not callable(limit.error_message) else limit.error_message() 26 | ) 27 | else: 28 | description = str(limit.limit) 29 | super().__init__(description=description, response=response) 30 | -------------------------------------------------------------------------------- /flask_limiter/limits.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import itertools 5 | import traceback 6 | import weakref 7 | from functools import wraps 8 | from types import TracebackType 9 | from typing import TYPE_CHECKING, cast, overload 10 | 11 | import flask 12 | from flask import request 13 | from flask.wrappers import Response 14 | from limits import RateLimitItem, parse_many 15 | 16 | from .typing import Callable, Iterable, Iterator, P, R, Self, Sequence 17 | from .util import get_qualified_name 18 | 19 | if TYPE_CHECKING: 20 | from flask_limiter import Limiter, RequestLimit 21 | 22 | 23 | @dataclasses.dataclass(eq=True, unsafe_hash=True) 24 | class RuntimeLimit: 25 | """ 26 | Final representation of a rate limit before it is triggered during a request 27 | """ 28 | 29 | limit: RateLimitItem 30 | key_func: Callable[[], str] 31 | scope: str | Callable[[str], str] | None 32 | per_method: bool = False 33 | methods: Sequence[str] | None = None 34 | error_message: str | Callable[[], str] | None = None 35 | exempt_when: Callable[[], bool] | None = None 36 | override_defaults: bool | None = False 37 | deduct_when: Callable[[Response], bool] | None = None 38 | on_breach: Callable[[RequestLimit], Response | None] | None = None 39 | cost: Callable[[], int] | int = 1 40 | shared: bool = False 41 | meta_limits: tuple[RuntimeLimit, ...] | None = None 42 | 43 | def __post_init__(self) -> None: 44 | if self.methods: 45 | self.methods = tuple([k.lower() for k in self.methods]) 46 | 47 | @property 48 | def is_exempt(self) -> bool: 49 | """Check if the limit is exempt.""" 50 | 51 | if self.exempt_when: 52 | return self.exempt_when() 53 | 54 | return False 55 | 56 | @property 57 | def deduction_amount(self) -> int: 58 | """How much to deduct from the limit""" 59 | 60 | return self.cost() if callable(self.cost) else self.cost 61 | 62 | @property 63 | def method_exempt(self) -> bool: 64 | """Check if the limit is not applicable for this method""" 65 | 66 | return self.methods is not None and request.method.lower() not in self.methods 67 | 68 | def scope_for(self, endpoint: str, method: str | None) -> str: 69 | """ 70 | Derive final bucket (scope) for this limit given the endpoint and request method. 71 | If the limit is shared between multiple routes, the scope does not include the endpoint. 72 | """ 73 | limit_scope = self.scope(request.endpoint or "") if callable(self.scope) else self.scope 74 | 75 | if limit_scope: 76 | if self.shared: 77 | scope = limit_scope 78 | else: 79 | scope = f"{endpoint}:{limit_scope}" 80 | else: 81 | scope = endpoint 82 | 83 | if self.per_method: 84 | assert method 85 | scope += f":{method.upper()}" 86 | 87 | return scope 88 | 89 | 90 | @dataclasses.dataclass(eq=True, unsafe_hash=True) 91 | class Limit: 92 | """ 93 | The definition of a rate limit to be used by the extension as a default limit:: 94 | 95 | 96 | def default_key_function(): 97 | return request.remote_addr 98 | 99 | def username_key_function(): 100 | return request.headers.get("username", "guest") 101 | 102 | limiter = flask_limiter.Limiter( 103 | default_key_function, 104 | default_limits = [ 105 | # 10/second by username 106 | flask_limiter.Limit("10/second", key_function=username_key_function), 107 | # 100/second by ip (i.e. default_key_function) 108 | flask_limiter.Limit("100/second), 109 | 110 | ] 111 | ) 112 | limit.init_app(app) 113 | 114 | - For application wide limits see :class:`ApplicationLimit` 115 | - For meta limits see :class:`MetaLimit` 116 | """ 117 | 118 | #: Rate limit string or a callable that returns a string. 119 | #: :ref:`ratelimit-string` for more details. 120 | limit_provider: Callable[[], str] | str 121 | #: Callable to extract the unique identifier for the rate limit. 122 | #: If not provided the key_function will default to the key function 123 | #: that the :class:`Limiter` was initialized with (:paramref:`Limiter.key_func`) 124 | key_function: Callable[[], str] | None = None 125 | #: A string or callable that returns a unique scope for the rate limit. 126 | #: The scope is combined with current endpoint of the request if 127 | #: :paramref:`shared` is ``False`` 128 | scope: str | Callable[[str], str] | None = None 129 | #: The cost of a hit or a function that 130 | #: takes no parameters and returns the cost as an integer (Default: ``1``). 131 | cost: Callable[[], int] | int | None = None 132 | #: If this a shared limit (i.e. to be used by different endpoints) 133 | shared: bool = False 134 | #: If specified, only the methods in this list will 135 | #: be rate limited. 136 | methods: Sequence[str] | None = None 137 | #: Whether the limit is sub categorized into the 138 | #: http method of the request. 139 | per_method: bool = False 140 | #: String (or callable that returns one) to override 141 | #: the error message used in the response. 142 | error_message: str | Callable[[], str] | None = None 143 | #: Meta limits to trigger everytime this rate limit definition is exceeded 144 | meta_limits: Iterable[Callable[[], str] | str | MetaLimit] | None = None 145 | #: Callable used to decide if the rate 146 | #: limit should skipped. 147 | exempt_when: Callable[[], bool] | None = None 148 | #: A function that receives the current 149 | #: :class:`flask.Response` object and returns True/False to decide if a 150 | #: deduction should be done from the rate limit 151 | deduct_when: Callable[[Response], bool] | None = None 152 | #: A function that will be called when this limit 153 | #: is breached. If the function returns an instance of :class:`flask.Response` 154 | #: that will be the response embedded into the :exc:`RateLimitExceeded` exception 155 | #: raised. 156 | on_breach: Callable[[RequestLimit], Response | None] | None = None 157 | #: Whether the decorated limit overrides 158 | #: the default limits (Default: ``True``). 159 | #: 160 | #: .. note:: When used with a :class:`~flask.Blueprint` the meaning 161 | #: of the parameter extends to any parents the blueprint instance is 162 | #: registered under. For more details see :ref:`recipes:nested blueprints` 163 | #: 164 | #: :meta private: 165 | override_defaults: bool | None = dataclasses.field(default=False, init=False) 166 | #: Weak reference to the limiter that this limit definition is bound to 167 | #: 168 | #: :meta private: 169 | limiter: weakref.ProxyType[Limiter] = dataclasses.field( 170 | init=False, hash=False, kw_only=True, repr=False 171 | ) 172 | 173 | def __post_init__(self) -> None: 174 | if self.methods: 175 | self.methods = tuple([k.lower() for k in self.methods]) 176 | 177 | if self.meta_limits: 178 | self.meta_limits = tuple(self.meta_limits) 179 | 180 | def __iter__(self) -> Iterator[RuntimeLimit]: 181 | limit_str = self.limit_provider() if callable(self.limit_provider) else self.limit_provider 182 | limit_items = parse_many(limit_str) if limit_str else [] 183 | meta_limits: tuple[RuntimeLimit, ...] = () 184 | 185 | if self.meta_limits: 186 | meta_limits = tuple( 187 | itertools.chain( 188 | *[ 189 | list( 190 | MetaLimit(meta_limit).bind_parent(self) 191 | if not isinstance(meta_limit, MetaLimit) 192 | else meta_limit 193 | ) 194 | for meta_limit in self.meta_limits 195 | ] 196 | ) 197 | ) 198 | 199 | for limit in limit_items: 200 | yield RuntimeLimit( 201 | limit, 202 | self.limit_by, 203 | scope=self.scope, 204 | per_method=self.per_method, 205 | methods=self.methods, 206 | error_message=self.error_message, 207 | exempt_when=self.exempt_when, 208 | deduct_when=self.deduct_when, 209 | override_defaults=self.override_defaults, 210 | on_breach=self.on_breach, 211 | cost=self.cost or 1, 212 | shared=self.shared, 213 | meta_limits=meta_limits, 214 | ) 215 | 216 | @property 217 | def limit_by(self) -> Callable[[], str]: 218 | return self.key_function or self.limiter._key_func 219 | 220 | def bind(self: Self, limiter: Limiter) -> Self: 221 | """ 222 | Returns an instance of the limit definition that binds to a weak reference of an instance 223 | of :class:`Limiter`. 224 | 225 | :meta private: 226 | """ 227 | self.limiter = weakref.proxy(limiter) 228 | [ 229 | meta_limit.bind(limiter) 230 | for meta_limit in self.meta_limits or () 231 | if isinstance(meta_limit, MetaLimit) 232 | ] 233 | 234 | return self 235 | 236 | 237 | @dataclasses.dataclass(unsafe_hash=True, kw_only=True) 238 | class RouteLimit(Limit): 239 | """ 240 | A variant of :class:`Limit` that can be used to to decorate a flask route or blueprint directly 241 | instead of by using :meth:`Limiter.limit` or :meth:`Limiter.shared_limit`. 242 | 243 | Decorating individual routes:: 244 | 245 | limiter = flask_limiter.Limiter(.....) 246 | limiter.init_app(app) 247 | 248 | @app.route("/") 249 | @flask_limiter.RouteLimit("2/second", limiter=limiter) 250 | def view_function(): 251 | ... 252 | 253 | """ 254 | 255 | #: Whether the decorated limit overrides 256 | #: the default limits (Default: ``True``). 257 | #: 258 | #: .. note:: When used with a :class:`~flask.Blueprint` the meaning 259 | #: of the parameter extends to any parents the blueprint instance is 260 | #: registered under. For more details see :ref:`recipes:nested blueprints` 261 | override_defaults: bool | None = False 262 | 263 | limiter: dataclasses.InitVar[Limiter] = dataclasses.field(hash=False) 264 | 265 | def __post_init__(self, limiter: Limiter) -> None: 266 | self.bind(limiter) 267 | super().__post_init__() 268 | 269 | def __enter__(self) -> None: 270 | tb = traceback.extract_stack(limit=2) 271 | qualified_location = f"{tb[0].filename}:{tb[0].name}:{tb[0].lineno}" 272 | 273 | # TODO: if use as a context manager becomes interesting/valuable 274 | # a less hacky approach than using the traceback and piggy backing 275 | # on the limit manager's knowledge of decorated limits might be worth it. 276 | self.limiter.limit_manager.add_decorated_limit(qualified_location, self, override=True) 277 | 278 | self.limiter.limit_manager.add_endpoint_hint( 279 | self.limiter.identify_request(), qualified_location 280 | ) 281 | 282 | self.limiter._check_request_limit(in_middleware=False, callable_name=qualified_location) 283 | 284 | def __exit__( 285 | self, 286 | exc_type: type[BaseException] | None, 287 | exc_value: BaseException | None, 288 | traceback: TracebackType | None, 289 | ) -> None: ... 290 | 291 | @overload 292 | def __call__(self, obj: Callable[P, R]) -> Callable[P, R]: ... 293 | 294 | @overload 295 | def __call__(self, obj: flask.Blueprint) -> None: ... 296 | 297 | def __call__(self, obj: Callable[P, R] | flask.Blueprint) -> Callable[P, R] | None: 298 | if isinstance(obj, flask.Blueprint): 299 | name = obj.name 300 | else: 301 | name = get_qualified_name(obj) 302 | 303 | if isinstance(obj, flask.Blueprint): 304 | self.limiter.limit_manager.add_blueprint_limit(name, self) 305 | 306 | return None 307 | else: 308 | self.limiter._marked_for_limiting.add(name) 309 | self.limiter.limit_manager.add_decorated_limit(name, self) 310 | 311 | @wraps(obj) 312 | def __inner(*a: P.args, **k: P.kwargs) -> R: 313 | if not getattr(obj, "__wrapper-limiter-instance", None) == self.limiter: 314 | identity = self.limiter.identify_request() 315 | 316 | if identity: 317 | view_func = flask.current_app.view_functions.get(identity, None) 318 | 319 | if view_func and not get_qualified_name(view_func) == name: 320 | self.limiter.limit_manager.add_endpoint_hint(identity, name) 321 | 322 | self.limiter._check_request_limit(in_middleware=False, callable_name=name) 323 | 324 | return cast(R, flask.current_app.ensure_sync(obj)(*a, **k)) 325 | 326 | # mark this wrapper as wrapped by a decorator from the limiter 327 | # from which the decorator was created. This ensures that stacked 328 | # decorations only trigger rate limiting from the inner most 329 | # decorator from each limiter instance (the weird need for 330 | # keeping track of the instance is to handle cases where multiple 331 | # limiter extensions are registered on the same application). 332 | setattr(__inner, "__wrapper-limiter-instance", self.limiter) 333 | 334 | return __inner 335 | 336 | 337 | @dataclasses.dataclass(kw_only=True, unsafe_hash=True) 338 | class ApplicationLimit(Limit): 339 | """ 340 | Variant of :class:`Limit` to be used for declaring an application wide limit that can be passed 341 | to :class:`Limiter` as one of the members of :paramref:`Limiter.application_limits` 342 | """ 343 | 344 | #: The scope to use for the application wide limit 345 | scope: str | Callable[[str], str] | None = dataclasses.field(default="global") 346 | #: Application limits are always "shared" 347 | #: 348 | #: :meta private: 349 | shared: bool = dataclasses.field(init=False, default=True) 350 | 351 | 352 | @dataclasses.dataclass(kw_only=True, unsafe_hash=True) 353 | class MetaLimit(Limit): 354 | """ 355 | Variant of :class:`Limit` to be used for declaring a meta limit that can be passed to 356 | either :class:`Limiter` as one of the members of :paramref:`Limiter.meta_limits` or to another 357 | instance of :class:`Limit` as a member of :paramref:`Limit.meta_limits` 358 | """ 359 | 360 | #: The scope to use for the meta limit 361 | scope: str | Callable[[str], str] | None = dataclasses.field(default="meta") 362 | #: meta limits can't have meta limits - at least here :) 363 | #: 364 | #: :meta private: 365 | meta_limits: Sequence[Callable[[], str] | str | MetaLimit] | None = dataclasses.field( 366 | init=False, default=None 367 | ) 368 | #: The rate limit this meta limit is limiting. 369 | #: 370 | # :meta private: 371 | parent_limit: Limit | None = dataclasses.field(init=False, default=None) 372 | #: Meta limits are always "shared" 373 | #: 374 | #: :meta private: 375 | shared: bool = dataclasses.field(init=False, default=True) 376 | #: Meta limits can't have conditional deductions 377 | #: 378 | #: :meta private: 379 | deduct_when: Callable[[Response], bool] | None = dataclasses.field(init=False, default=None) 380 | #: Callable to extract the unique identifier for the rate limit. 381 | #: If not provided the key_function will fallback to: 382 | #: 383 | #: - the key function of the parent limit this meta limit is declared for 384 | #: - the key function for the :class:`Limiter` instance this meta limit 385 | #: is eventually used with. 386 | key_function: Callable[[], str] | None = None 387 | 388 | @property 389 | def limit_by(self) -> Callable[[], str]: 390 | return ( 391 | self.key_function 392 | or self.parent_limit 393 | and self.parent_limit.key_function 394 | or self.limiter._key_func 395 | ) 396 | 397 | def bind_parent(self: Self, parent: Limit) -> Self: 398 | """ 399 | Binds this meta limit to be associated as a child of the ``parent`` limit. 400 | 401 | :meta private: 402 | """ 403 | self.parent_limit = parent 404 | return self 405 | -------------------------------------------------------------------------------- /flask_limiter/manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | import logging 5 | from collections.abc import Iterable 6 | from typing import TYPE_CHECKING 7 | 8 | import flask 9 | from ordered_set import OrderedSet 10 | 11 | from .constants import ExemptionScope 12 | from .limits import ApplicationLimit, RuntimeLimit 13 | from .util import get_qualified_name 14 | 15 | if TYPE_CHECKING: 16 | from . import Limit 17 | 18 | 19 | class LimitManager: 20 | def __init__( 21 | self, 22 | application_limits: list[ApplicationLimit], 23 | default_limits: list[Limit], 24 | decorated_limits: dict[str, OrderedSet[Limit]], 25 | blueprint_limits: dict[str, OrderedSet[Limit]], 26 | route_exemptions: dict[str, ExemptionScope], 27 | blueprint_exemptions: dict[str, ExemptionScope], 28 | ) -> None: 29 | self._application_limits = application_limits 30 | self._default_limits = default_limits 31 | self._decorated_limits = decorated_limits 32 | self._blueprint_limits = blueprint_limits 33 | self._route_exemptions = route_exemptions 34 | self._blueprint_exemptions = blueprint_exemptions 35 | self._endpoint_hints: dict[str, OrderedSet[str]] = {} 36 | self._logger = logging.getLogger("flask-limiter") 37 | 38 | @property 39 | def application_limits(self) -> list[RuntimeLimit]: 40 | return list(itertools.chain(*self._application_limits)) 41 | 42 | @property 43 | def default_limits(self) -> list[RuntimeLimit]: 44 | return list(itertools.chain(*self._default_limits)) 45 | 46 | def set_application_limits(self, limits: list[ApplicationLimit]) -> None: 47 | self._application_limits = limits 48 | 49 | def set_default_limits(self, limits: list[Limit]) -> None: 50 | self._default_limits = limits 51 | 52 | def add_decorated_limit(self, route: str, limit: Limit | None, override: bool = False) -> None: 53 | if limit: 54 | if not override: 55 | self._decorated_limits.setdefault(route, OrderedSet()).add(limit) 56 | else: 57 | self._decorated_limits[route] = OrderedSet([limit]) 58 | 59 | def add_blueprint_limit(self, blueprint: str, limit: Limit | None) -> None: 60 | if limit: 61 | self._blueprint_limits.setdefault(blueprint, OrderedSet()).add(limit) 62 | 63 | def add_route_exemption(self, route: str, scope: ExemptionScope) -> None: 64 | self._route_exemptions[route] = scope 65 | 66 | def add_blueprint_exemption(self, blueprint: str, scope: ExemptionScope) -> None: 67 | self._blueprint_exemptions[blueprint] = scope 68 | 69 | def add_endpoint_hint(self, endpoint: str, callable: str) -> None: 70 | self._endpoint_hints.setdefault(endpoint, OrderedSet()).add(callable) 71 | 72 | def has_hints(self, endpoint: str) -> bool: 73 | return bool(self._endpoint_hints.get(endpoint)) 74 | 75 | def resolve_limits( 76 | self, 77 | app: flask.Flask, 78 | endpoint: str | None = None, 79 | blueprint: str | None = None, 80 | callable_name: str | None = None, 81 | in_middleware: bool = False, 82 | marked_for_limiting: bool = False, 83 | ) -> tuple[list[RuntimeLimit], ...]: 84 | before_request_context = in_middleware and marked_for_limiting 85 | decorated_limits = [] 86 | hinted_limits = [] 87 | if endpoint: 88 | if not in_middleware: 89 | if not callable_name: 90 | view_func = app.view_functions.get(endpoint, None) 91 | name = get_qualified_name(view_func) if view_func else "" 92 | else: 93 | name = callable_name 94 | decorated_limits.extend(self.decorated_limits(name)) 95 | 96 | for hint in self._endpoint_hints.get(endpoint, OrderedSet()): 97 | hinted_limits.extend(self.decorated_limits(hint)) 98 | 99 | if blueprint: 100 | if not before_request_context and ( 101 | not decorated_limits 102 | or all(not limit.override_defaults for limit in decorated_limits) 103 | ): 104 | decorated_limits.extend(self.blueprint_limits(app, blueprint)) 105 | exemption_scope = self.exemption_scope(app, endpoint, blueprint) 106 | 107 | all_limits = ( 108 | self.application_limits 109 | if in_middleware and not (exemption_scope & ExemptionScope.APPLICATION) 110 | else [] 111 | ) 112 | # all_limits += decorated_limits 113 | explicit_limits_exempt = all(limit.method_exempt for limit in decorated_limits) 114 | 115 | # all the decorated limits explicitly declared 116 | # that they don't override the defaults - so, they should 117 | # be included. 118 | combined_defaults = all(not limit.override_defaults for limit in decorated_limits) 119 | # previous requests to this endpoint have exercised decorated 120 | # rate limits on callables that are not view functions. check 121 | # if all of them declared that they don't override defaults 122 | # and if so include the default limits. 123 | hinted_limits_request_defaults = ( 124 | all(not limit.override_defaults for limit in hinted_limits) if hinted_limits else False 125 | ) 126 | if ( 127 | (explicit_limits_exempt or combined_defaults) 128 | and (not (before_request_context or exemption_scope & ExemptionScope.DEFAULT)) 129 | ) or hinted_limits_request_defaults: 130 | all_limits += self.default_limits 131 | return all_limits, decorated_limits 132 | 133 | def exemption_scope( 134 | self, app: flask.Flask, endpoint: str | None, blueprint: str | None 135 | ) -> ExemptionScope: 136 | view_func = app.view_functions.get(endpoint or "", None) 137 | name = get_qualified_name(view_func) if view_func else "" 138 | route_exemption_scope = self._route_exemptions.get(name, ExemptionScope.NONE) 139 | blueprint_instance = app.blueprints.get(blueprint) if blueprint else None 140 | 141 | if not blueprint_instance: 142 | return route_exemption_scope 143 | else: 144 | assert blueprint 145 | ( 146 | blueprint_exemption_scope, 147 | ancestor_exemption_scopes, 148 | ) = self._blueprint_exemption_scope(app, blueprint) 149 | if ( 150 | blueprint_exemption_scope & ~(ExemptionScope.DEFAULT | ExemptionScope.APPLICATION) 151 | or ancestor_exemption_scopes 152 | ): 153 | for exemption in ancestor_exemption_scopes.values(): 154 | blueprint_exemption_scope |= exemption 155 | return route_exemption_scope | blueprint_exemption_scope 156 | 157 | def decorated_limits(self, callable_name: str) -> list[RuntimeLimit]: 158 | limits = [] 159 | if not self._route_exemptions.get(callable_name, ExemptionScope.NONE): 160 | if callable_name in self._decorated_limits: 161 | for group in self._decorated_limits[callable_name]: 162 | try: 163 | for limit in group: 164 | limits.append(limit) 165 | except ValueError as e: 166 | self._logger.error( 167 | f"failed to load ratelimit for function {callable_name}: {e}", 168 | ) 169 | return limits 170 | 171 | def blueprint_limits(self, app: flask.Flask, blueprint: str) -> list[RuntimeLimit]: 172 | limits: list[RuntimeLimit] = [] 173 | 174 | blueprint_instance = app.blueprints.get(blueprint) if blueprint else None 175 | if blueprint_instance: 176 | blueprint_name = blueprint_instance.name 177 | blueprint_ancestory = set(blueprint.split(".") if blueprint else []) 178 | 179 | self_exemption, ancestor_exemptions = self._blueprint_exemption_scope(app, blueprint) 180 | 181 | if not (self_exemption & ~(ExemptionScope.DEFAULT | ExemptionScope.APPLICATION)): 182 | blueprint_self_limits = self._blueprint_limits.get(blueprint_name, OrderedSet()) 183 | blueprint_limits: Iterable[Limit] = ( 184 | itertools.chain( 185 | *( 186 | self._blueprint_limits.get(member, []) 187 | for member in blueprint_ancestory.intersection( 188 | self._blueprint_limits 189 | ).difference(ancestor_exemptions) 190 | ) 191 | ) 192 | if not ( 193 | blueprint_self_limits 194 | and all(limit.override_defaults for limit in blueprint_self_limits) 195 | ) 196 | and not self._blueprint_exemptions.get(blueprint_name, ExemptionScope.NONE) 197 | & ExemptionScope.ANCESTORS 198 | else blueprint_self_limits 199 | ) 200 | if blueprint_limits: 201 | for limit_group in blueprint_limits: 202 | try: 203 | limits.extend( 204 | [ 205 | RuntimeLimit( 206 | limit.limit, 207 | limit.key_func, 208 | limit.scope, 209 | limit.per_method, 210 | limit.methods, 211 | limit.error_message, 212 | limit.exempt_when, 213 | limit.override_defaults, 214 | limit.deduct_when, 215 | limit.on_breach, 216 | limit.cost, 217 | limit.shared, 218 | ) 219 | for limit in limit_group 220 | ] 221 | ) 222 | except ValueError as e: 223 | self._logger.error( 224 | f"failed to load ratelimit for blueprint {blueprint_name}: {e}", 225 | ) 226 | return limits 227 | 228 | def _blueprint_exemption_scope( 229 | self, app: flask.Flask, blueprint_name: str 230 | ) -> tuple[ExemptionScope, dict[str, ExemptionScope]]: 231 | name = app.blueprints[blueprint_name].name 232 | exemption = self._blueprint_exemptions.get(name, ExemptionScope.NONE) & ~( 233 | ExemptionScope.ANCESTORS 234 | ) 235 | 236 | ancestory = set(blueprint_name.split(".")) 237 | ancestor_exemption = { 238 | k for k, f in self._blueprint_exemptions.items() if f & ExemptionScope.DESCENDENTS 239 | }.intersection(ancestory) 240 | 241 | return exemption, { 242 | k: self._blueprint_exemptions.get(k, ExemptionScope.NONE) for k in ancestor_exemption 243 | } 244 | -------------------------------------------------------------------------------- /flask_limiter/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/flask_limiter/py.typed -------------------------------------------------------------------------------- /flask_limiter/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable, Generator, Iterable, Iterator, Sequence 4 | from typing import ( 5 | ParamSpec, 6 | TypeVar, 7 | cast, 8 | ) 9 | 10 | from typing_extensions import Self 11 | 12 | R = TypeVar("R") 13 | P = ParamSpec("P") 14 | 15 | __all__ = [ 16 | "Callable", 17 | "Generator", 18 | "Iterable", 19 | "Iterator", 20 | "P", 21 | "R", 22 | "Sequence", 23 | "Self", 24 | "TypeVar", 25 | "cast", 26 | ] 27 | -------------------------------------------------------------------------------- /flask_limiter/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from typing import Any 5 | 6 | from flask import request 7 | 8 | 9 | def get_remote_address() -> str: 10 | """ 11 | :return: the ip address for the current request (or 127.0.0.1 if none found) 12 | 13 | """ 14 | return request.remote_addr or "127.0.0.1" 15 | 16 | 17 | def get_qualified_name(callable: Callable[..., Any]) -> str: 18 | """ 19 | Generate the fully qualified name of a callable for use in storing mappings of decorated 20 | functions to rate limits 21 | 22 | The __qualname__ of the callable is appended in case there is a name clash in a module due to 23 | locally scoped functions that are decorated. 24 | 25 | TODO: Ideally __qualname__ should be enough, however view functions generated by class based 26 | views do not update that and therefore would not be uniquely identifiable unless 27 | __module__ & __name__ are inspected. 28 | 29 | :meta private: 30 | """ 31 | return f"{callable.__module__}.{callable.__name__}.{callable.__qualname__}" 32 | -------------------------------------------------------------------------------- /flask_limiter/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | empty file to be updated by versioneer 3 | """ 4 | -------------------------------------------------------------------------------- /push-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cur=$(git rev-parse --abbrev-ref HEAD) 3 | git checkout master 4 | git push origin master --tags 5 | git checkout stable 6 | git merge master 7 | git push origin stable 8 | git checkout $cur 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.versioneer] 2 | VCS = "git" 3 | style = "pep440-pre" 4 | versionfile_source = "flask_limiter/_version.py" 5 | versionfile_build = "flask_limiter/_version.py" 6 | parentdir_prefix = "flask-limiter-" 7 | tag_prefix = "" 8 | 9 | [tool.ruff] 10 | line-length=100 11 | indent-width = 4 12 | exclude = ["_version.py"] 13 | 14 | [tool.ruff.format] 15 | quote-style = "double" 16 | indent-style = "space" 17 | skip-magic-trailing-comma = false 18 | line-ending = "auto" 19 | 20 | [tool.ruff.lint.isort] 21 | required-imports = ["from __future__ import annotations"] 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = build *.egg 3 | markers = 4 | unit: mark a test as a unit test. 5 | addopts = 6 | --verbose 7 | --tb=short 8 | --capture=no 9 | -rfEsxX 10 | --cov=flask_limiter 11 | -------------------------------------------------------------------------------- /requirements/ci.txt: -------------------------------------------------------------------------------- 1 | -r dev.txt 2 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r test.txt 2 | -r docs.txt 3 | ruff 4 | keyring 5 | mypy 6 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | -r main.txt 2 | furo==2024.8.6 3 | Sphinx>4,<9 4 | sphinx-autobuild==2024.10.3 5 | sphinx-copybutton==0.5.2 6 | sphinx-inline-tabs==2023.4.21 7 | sphinx-issues==5.0.1 8 | sphinxext-opengraph==0.10.0 9 | sphinx-paramlinks==0.6.0 10 | sphinxcontrib-programoutput==0.18 11 | 12 | 13 | -------------------------------------------------------------------------------- /requirements/main.txt: -------------------------------------------------------------------------------- 1 | limits>=3.13 2 | Flask>=2 3 | ordered-set>4,<5 4 | typing_extensions>=4.3 5 | rich>=12,<15 6 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | -r main.txt 2 | # For interop / recipes 3 | Flask[async]>=2.0.0 4 | flask-restful 5 | flask-restx 6 | asgiref>=3.2 7 | 8 | # Storage related dependencies 9 | redis 10 | pymemcache 11 | pymongo 12 | 13 | # For the tests themselves 14 | coverage<8 15 | hiro>0.1.6 16 | pytest 17 | pytest-cov 18 | pytest-check 19 | pytest-mock 20 | lovely-pytest-docker 21 | -------------------------------------------------------------------------------- /scripts/github_release_notes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TAG=$(echo $GITHUB_REF | cut -d / -f 3) 4 | git format-patch -1 $TAG --stdout | grep -P '^\+' | \ 5 | sed '1,4d' | \ 6 | grep -v "Release Date" | \ 7 | sed -E -e 's/^\+(.*)/\1/' -e 's/^\*(.*)/## \1/' -e 's/^ //' -e 's/\:(.*)\:(.*)/\2/' | \ 8 | sed -E -e 's/`(.*) <(https.*)>`_/[\1](\2)/' 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = build/**,doc/**,_version.py,version.py,versioneer.py 3 | ignore = W503 4 | max_line_length=100 5 | 6 | [mypy] 7 | strict = True 8 | check_untyped_defs = True 9 | disallow_any_generics = True 10 | disallow_any_unimported = True 11 | disallow_incomplete_defs = True 12 | disallow_untyped_defs = True 13 | disallow_untyped_decorators = True 14 | show_error_codes = True 15 | warn_return_any = True 16 | warn_unused_ignores = True 17 | 18 | [mypy-werkzeug.*] 19 | no_implicit_reexport = False 20 | 21 | [mypy-flask_limiter._compat.*] 22 | ignore_errors = True 23 | 24 | [mypy-flask_limiter._version] 25 | ignore_errors = True 26 | 27 | 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | setup.py for Flask-Limiter 3 | 4 | 5 | """ 6 | __author__ = "Ali-Akber Saifee" 7 | __email__ = "ali@indydevs.org" 8 | __copyright__ = "Copyright 2023, Ali-Akber Saifee" 9 | 10 | import os 11 | 12 | from setuptools import find_packages, setup 13 | 14 | import versioneer 15 | 16 | this_dir = os.path.abspath(os.path.dirname(__file__)) 17 | REQUIREMENTS = filter( 18 | None, open(os.path.join(this_dir, "requirements", "main.txt")).read().splitlines() 19 | ) 20 | EXTRA_REQUIREMENTS = { 21 | "redis": ["limits[redis]"], 22 | "memcached": ["limits[memcached]"], 23 | "mongodb": ["limits[mongodb]"], 24 | "valkey": ["limits[valkey]"] 25 | } 26 | 27 | setup( 28 | name="Flask-Limiter", 29 | author=__author__, 30 | author_email=__email__, 31 | license="MIT", 32 | url="https://flask-limiter.readthedocs.org", 33 | project_urls={ 34 | "Source": "https://github.com/alisaifee/flask-limiter", 35 | }, 36 | zip_safe=False, 37 | version=versioneer.get_version(), 38 | cmdclass=versioneer.get_cmdclass(), 39 | install_requires=list(REQUIREMENTS), 40 | classifiers=[k for k in open("CLASSIFIERS").read().split("\n") if k], 41 | description="Rate limiting for flask applications", 42 | long_description=open("README.rst").read(), 43 | packages=find_packages(exclude=["tests*"]), 44 | python_requires=">=3.10", 45 | extras_require=EXTRA_REQUIREMENTS, 46 | include_package_data=True, 47 | package_data={ 48 | "flask_limiter": ["py.typed"], 49 | }, 50 | entry_points={ 51 | 'flask.commands': [ 52 | 'limiter=flask_limiter.commands:cli' 53 | ], 54 | }, 55 | ) 56 | -------------------------------------------------------------------------------- /tag.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | last_tag=$(git tag | sort -Vr | head -n 1) 3 | echo current version:$(python setup.py --version), current tag: $last_tag 4 | read -p "new version:" new_version 5 | last_portion=$(grep -P "^Changelog$" HISTORY.rst -5 | grep -P "^v\d+.\d+") 6 | changelog_file=/var/tmp/flask-limiter.newchangelog 7 | new_changelog_heading="v${new_version}" 8 | new_changelog_heading_sep=$(python -c "print('-'*len('$new_changelog_heading'))") 9 | echo $new_changelog_heading > $changelog_file 10 | echo $new_changelog_heading_sep >> $changelog_file 11 | echo "Release Date: `date +"%Y-%m-%d"`" >> $changelog_file 12 | python -c "print(open('HISTORY.rst').read().replace('$last_portion', open('$changelog_file').read() +'\n' + '$last_portion'))" > HISTORY.rst.new 13 | cp HISTORY.rst.new HISTORY.rst 14 | vim -O HISTORY.rst <(echo \# vim:filetype=git;git log $last_tag..HEAD --format='* %s (%h)%n%b' | sed -E '/^\*/! s/(.*)/ \1/g') 15 | if rst2html HISTORY.rst > /dev/null 16 | then 17 | echo "Tag $new_version" 18 | git add HISTORY.rst 19 | git commit -m "Update changelog for ${new_version}" 20 | git tag -s ${new_version} -m "Tag version ${new_version}" 21 | rm HISTORY.rst.new 22 | else 23 | echo changelog has errors. skipping tag. 24 | fi; 25 | 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import socket 4 | 5 | import pymemcache 6 | import pymongo 7 | import pytest 8 | import redis 9 | from flask import Blueprint, Flask, request 10 | from flask.views import View 11 | 12 | from flask_limiter import ExemptionScope, Limiter 13 | from flask_limiter.util import get_remote_address 14 | 15 | 16 | def ping_socket(host, port): 17 | try: 18 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 19 | s.connect((host, port)) 20 | 21 | return True 22 | except Exception: 23 | return False 24 | 25 | 26 | @pytest.fixture 27 | def redis_connection(docker_services): 28 | docker_services.start("redis") 29 | docker_services.wait_for_service("redis", 6379, ping_socket) 30 | r = redis.from_url("redis://localhost:46379") 31 | r.flushall() 32 | 33 | return r 34 | 35 | 36 | @pytest.fixture 37 | def memcached_connection(docker_services): 38 | docker_services.start("memcached") 39 | docker_services.wait_for_service("memcached", 11211, ping_socket) 40 | return pymemcache.Client(("localhost", 31211)) 41 | 42 | 43 | @pytest.fixture 44 | def mongo_connection(docker_services): 45 | docker_services.start("mongodb") 46 | docker_services.wait_for_service("mongodb", 27017, ping_socket) 47 | return pymongo.MongoClient("mongodb://localhost:47017") 48 | 49 | 50 | @pytest.fixture 51 | def extension_factory(): 52 | def _build_app_and_extension(config={}, **limiter_args): 53 | app = Flask(__name__) 54 | 55 | for k, v in config.items(): 56 | app.config.setdefault(k, v) 57 | key_func = limiter_args.pop("key_func", get_remote_address) 58 | limiter = Limiter(key_func, app=app, **limiter_args) 59 | 60 | return app, limiter 61 | 62 | return _build_app_and_extension 63 | 64 | 65 | @pytest.fixture 66 | def kitchensink_factory(extension_factory): 67 | def _(**kwargs): 68 | def dynamic_default(): 69 | if request.headers.get("X-Evil"): 70 | return "10/minute" 71 | return "20/minute" 72 | 73 | def dynamic_default_cost(): 74 | if request.headers.get("X-Evil"): 75 | return 2 76 | return 1 77 | 78 | app, limiter = extension_factory( 79 | default_limits=["10/second", "1000/hour", dynamic_default], 80 | default_limits_exempt_when=lambda: request.headers.get("X-Internal"), 81 | default_limits_deduct_when=lambda response: response.status_code != 200, 82 | default_limits_cost=dynamic_default_cost, 83 | application_limits=["5000/hour"], 84 | meta_limits=["2/day"], 85 | headers_enabled=True, 86 | **kwargs, 87 | ) 88 | 89 | @app.route("/") 90 | def root(): 91 | return "42" 92 | 93 | health_blueprint = Blueprint("health", __name__, url_prefix="/health") 94 | 95 | @health_blueprint.route("/") 96 | def health(): 97 | return "ok" 98 | 99 | app.register_blueprint(health_blueprint) 100 | 101 | class ResourceView(View): 102 | methods = ["GET", "POST"] 103 | decorators = [limiter.limit("5/second", per_method=True)] 104 | 105 | def dispatch_request(self): 106 | return request.method.lower() 107 | 108 | app.add_url_rule("/resource", view_func=ResourceView.as_view("resource")) 109 | 110 | limiter.exempt( 111 | health_blueprint, 112 | flags=ExemptionScope.DEFAULT | ExemptionScope.APPLICATION | ExemptionScope.ANCESTORS, 113 | ) 114 | 115 | return app, limiter 116 | 117 | return _ 118 | 119 | 120 | @pytest.fixture(scope="session") 121 | def docker_services_project_name(): 122 | return "flask-limiter" 123 | 124 | 125 | @pytest.fixture(scope="session") 126 | def docker_compose_files(pytestconfig): 127 | return ["docker-compose.yml"] 128 | -------------------------------------------------------------------------------- /tests/static/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alisaifee/flask-limiter/0d3c15186449718570fabfc6dd8916408209ccb3/tests/static/image.png -------------------------------------------------------------------------------- /tests/test_blueprints.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | 6 | import hiro 7 | from flask import Blueprint, Flask, current_app 8 | 9 | from flask_limiter import ExemptionScope, Limiter 10 | from flask_limiter.util import get_remote_address 11 | 12 | 13 | def test_blueprint(extension_factory): 14 | app, limiter = extension_factory(default_limits=["1/minute"]) 15 | bp = Blueprint("main", __name__) 16 | 17 | @bp.route("/t1") 18 | def t1(): 19 | return "test" 20 | 21 | @bp.route("/t2") 22 | @limiter.limit("10 per minute") 23 | def t2(): 24 | return "test" 25 | 26 | app.register_blueprint(bp) 27 | 28 | with app.test_client() as cli: 29 | assert cli.get("/t1").status_code == 200 30 | assert cli.get("/t1").status_code == 429 31 | for i in range(0, 10): 32 | assert cli.get("/t2").status_code == 200 33 | assert cli.get("/t2").status_code == 429 34 | 35 | 36 | def test_blueprint_static_exempt(extension_factory): 37 | app, limiter = extension_factory(default_limits=["1/minute"]) 38 | bp = Blueprint("main", __name__, static_folder="static") 39 | app.register_blueprint(bp, url_prefix="/bp") 40 | 41 | with app.test_client() as cli: 42 | assert cli.get("/bp/static/image.png").status_code == 200 43 | assert cli.get("/bp/static/image.png").status_code == 200 44 | 45 | 46 | def test_blueprint_limit_with_route_limits(extension_factory): 47 | app, limiter = extension_factory(default_limits=["1/minute"]) 48 | bp = Blueprint("main", __name__) 49 | 50 | @app.route("/") 51 | def root(): 52 | return "root" 53 | 54 | @bp.route("/t1") 55 | def t1(): 56 | return "test" 57 | 58 | @bp.route("/t2") 59 | @limiter.limit("10 per minute") 60 | def t2(): 61 | return "test" 62 | 63 | @bp.route("/t3") 64 | @limiter.limit("3 per hour", override_defaults=False) 65 | def t3(): 66 | return "test" 67 | 68 | limiter.limit("2/minute")(bp) 69 | 70 | app.register_blueprint(bp) 71 | 72 | with hiro.Timeline() as timeline: 73 | with app.test_client() as cli: 74 | assert cli.get("/").status_code == 200 75 | assert cli.get("/").status_code == 429 76 | assert cli.get("/t1").status_code == 200 77 | assert cli.get("/t1").status_code == 200 78 | assert cli.get("/t1").status_code == 429 79 | for i in range(0, 10): 80 | assert cli.get("/t2").status_code == 200 81 | assert cli.get("/t2").status_code == 429 82 | 83 | assert cli.get("/t3").status_code == 200 84 | assert cli.get("/t3").status_code == 200 85 | assert cli.get("/t3").status_code == 429 86 | timeline.forward(datetime.timedelta(minutes=1)) 87 | assert cli.get("/t3").status_code == 200 88 | timeline.forward(datetime.timedelta(minutes=1)) 89 | assert cli.get("/t3").status_code == 429 90 | 91 | 92 | def test_nested_blueprint_exemption_explicit(extension_factory): 93 | app, limiter = extension_factory(default_limits=["1/minute"]) 94 | parent_bp = Blueprint("parent", __name__, url_prefix="/parent") 95 | child_bp = Blueprint("child", __name__, url_prefix="/child") 96 | 97 | limiter.exempt(parent_bp) 98 | limiter.exempt(child_bp) 99 | 100 | @app.route("/") 101 | def root(): 102 | return "42" 103 | 104 | @parent_bp.route("/") 105 | def parent(): 106 | return "41" 107 | 108 | @child_bp.route("/") 109 | def child(): 110 | return "40" 111 | 112 | parent_bp.register_blueprint(child_bp) 113 | app.register_blueprint(parent_bp) 114 | 115 | with app.test_client() as cli: 116 | assert cli.get("/").status_code == 200 117 | assert cli.get("/").status_code == 429 118 | assert cli.get("/parent/").status_code == 200 119 | assert cli.get("/parent/").status_code == 200 120 | assert cli.get("/parent/child/").status_code == 200 121 | assert cli.get("/parent/child/").status_code == 200 122 | 123 | 124 | def test_nested_blueprint_exemption_legacy(extension_factory): 125 | """ 126 | To capture legacy behavior, exempting a blueprint 127 | will not automatically exempt nested blueprints 128 | """ 129 | app, limiter = extension_factory(default_limits=["1/minute"]) 130 | parent_bp = Blueprint("parent", __name__, url_prefix="/parent") 131 | child_bp = Blueprint("child", __name__, url_prefix="/child") 132 | 133 | limiter.exempt(parent_bp) 134 | 135 | @app.route("/") 136 | def root(): 137 | return "42" 138 | 139 | @parent_bp.route("/") 140 | def parent(): 141 | return "41" 142 | 143 | @child_bp.route("/") 144 | def child(): 145 | return "40" 146 | 147 | parent_bp.register_blueprint(child_bp) 148 | app.register_blueprint(parent_bp) 149 | 150 | with app.test_client() as cli: 151 | assert cli.get("/").status_code == 200 152 | assert cli.get("/").status_code == 429 153 | assert cli.get("/parent/").status_code == 200 154 | assert cli.get("/parent/").status_code == 200 155 | assert cli.get("/parent/child/").status_code == 200 156 | assert cli.get("/parent/child/").status_code == 429 157 | 158 | 159 | def test_nested_blueprint_exemption_nested(extension_factory): 160 | app, limiter = extension_factory(default_limits=["1/minute"]) 161 | parent_bp = Blueprint("parent", __name__, url_prefix="/parent") 162 | child_bp = Blueprint("child", __name__, url_prefix="/child") 163 | 164 | limiter.exempt(parent_bp, flags=ExemptionScope.DEFAULT | ExemptionScope.DESCENDENTS) 165 | 166 | @app.route("/") 167 | def root(): 168 | return "42" 169 | 170 | @parent_bp.route("/") 171 | def parent(): 172 | return "41" 173 | 174 | @child_bp.route("/") 175 | def child(): 176 | return "40" 177 | 178 | parent_bp.register_blueprint(child_bp) 179 | app.register_blueprint(parent_bp) 180 | 181 | with app.test_client() as cli: 182 | assert cli.get("/").status_code == 200 183 | assert cli.get("/").status_code == 429 184 | assert cli.get("/parent/").status_code == 200 185 | assert cli.get("/parent/").status_code == 200 186 | assert cli.get("/parent/child/").status_code == 200 187 | assert cli.get("/parent/child/").status_code == 200 188 | 189 | 190 | def test_nested_blueprint_exemption_ridiculous(extension_factory): 191 | app, limiter = extension_factory(default_limits=["1/minute"], application_limits=["5/day"]) 192 | n1 = Blueprint("n1", __name__, url_prefix="/n1") 193 | n2 = Blueprint("n2", __name__, url_prefix="/n2") 194 | n1_1 = Blueprint("n1_1", __name__, url_prefix="/n1_1") 195 | n2_1 = Blueprint("n2_1", __name__, url_prefix="/n2_1") 196 | n1_1_1 = Blueprint("n1_1_1", __name__, url_prefix="/n1_1_1") 197 | n1_1_2 = Blueprint("n1_1_2", __name__, url_prefix="/n1_1_2") 198 | n2_1_1 = Blueprint("n2_1_1", __name__, url_prefix="/n2_1_1") 199 | 200 | @app.route("/") 201 | def root(): 202 | return "42" 203 | 204 | @n1.route("/") 205 | def _n1(): 206 | return "n1" 207 | 208 | @n1_1.route("/") 209 | def _n1_1(): 210 | return "n1_1" 211 | 212 | @n1_1_1.route("/") 213 | def _n1_1_1(): 214 | return "n1_1_1" 215 | 216 | @n1_1_2.route("/") 217 | def _n1_1_2(): 218 | return "n1_1_2" 219 | 220 | @n2.route("/") 221 | def _n2(): 222 | return "n2" 223 | 224 | @n2_1.route("/") 225 | def _n2_1(): 226 | return "n2_1" 227 | 228 | @n2_1_1.route("/") 229 | def _n2_1_1(): 230 | return "n2_1_1" 231 | 232 | # All routes under n1, and it's descendents are exempt for default/application limits 233 | limiter.exempt( 234 | n1, 235 | flags=ExemptionScope.DEFAULT | ExemptionScope.APPLICATION | ExemptionScope.DESCENDENTS, 236 | ) 237 | # n1 descendents are exempt from application & defaults so need their own limits 238 | limiter.limit("2/minute")(n1_1) 239 | # n1_1_1 wants to not inherit n1_1's limits and is otherwise exempt from 240 | # application and defaults due to n1's exemptions. 241 | limiter.exempt(n1_1_1, flags=ExemptionScope.ANCESTORS) 242 | # n1_1_2 will not get it's parent (n1_1) limit and sets it's own 243 | limiter.limit("3/minute")(n1_1_2) 244 | 245 | # n2 overrides the default limits but still gets the application wide limits 246 | limiter.limit("2/minute")(n2) 247 | # n2_1 wants out of defaults and application limits 248 | limiter.exempt(n2_1, flags=ExemptionScope.DEFAULT | ExemptionScope.APPLICATION) 249 | # but want its own limits 250 | limiter.limit("3/minute")(n2_1) 251 | # n2_1_1 want's out of it's parent's limits only but wants to keep application/default limits 252 | limiter.exempt(n2_1_1, flags=ExemptionScope.ANCESTORS) 253 | 254 | n1.register_blueprint(n1_1) 255 | n1_1.register_blueprint(n1_1_1) 256 | n1_1.register_blueprint(n1_1_2) 257 | n2.register_blueprint(n2_1) 258 | n2_1.register_blueprint(n2_1_1) 259 | app.register_blueprint(n1) 260 | app.register_blueprint(n2) 261 | 262 | with hiro.Timeline() as timeline: 263 | with app.test_client() as cli: 264 | assert cli.get("/").status_code == 200 265 | assert cli.get("/").status_code == 429 # Default hit 266 | # application: exempt, default: exempt, explicit: none 267 | assert cli.get("/n1/").status_code == 200 268 | assert cli.get("/n1/").status_code == 200 269 | # application: exempt from n1, default exempt from n1 & overridden 270 | # by explicit: 2/minute 271 | assert cli.get("/n1/n1_1/").status_code == 200 272 | assert cli.get("/n1/n1_1/").status_code == 200 273 | assert cli.get("/n1/n1_1/").status_code == 429 274 | # application: exempt from n1, default: exempt from n1, inherited: exempt, 275 | # explicit: none 276 | assert cli.get("/n1/n1_1/n1_1_1/").status_code == 200 277 | assert cli.get("/n1/n1_1/n1_1_1/").status_code == 200 278 | assert cli.get("/n1/n1_1/n1_1_1/").status_code == 200 279 | # application: exempt from n1, default: exempt from n1, inherited: exempt, 280 | # explicit: 3/minute 281 | assert cli.get("/n1/n1_1/n1_1_2/").status_code == 200 282 | assert cli.get("/n1/n1_1/n1_1_2/").status_code == 200 283 | assert cli.get("/n1/n1_1/n1_1_2/").status_code == 200 284 | assert cli.get("/n1/n1_1/n1_1_2/").status_code == 429 285 | # application: active, default: exempt, explicit: 2/minute 286 | assert cli.get("/n2/").status_code == 200 287 | assert cli.get("/n2/").status_code == 200 288 | assert cli.get("/n2/").status_code == 429 289 | # application: exempt, default: exempt, explicit: 3/minute therefore overriding n2 290 | assert cli.get("/n2/n2_1/").status_code == 200 291 | assert cli.get("/n2/n2_1/").status_code == 200 292 | assert cli.get("/n2/n2_1/").status_code == 200 293 | assert cli.get("/n2/n2_1/").status_code == 429 294 | # almost there.. 295 | # application: active, default: active (1/minute), ancestors: exempt 296 | assert cli.get("/n2/n2_1/n2_1_1/").status_code == 200 297 | assert cli.get("/n2/n2_1/n2_1_1/").status_code == 429 298 | timeline.forward(60) 299 | assert cli.get("/n2/n2_1/n2_1_1/").status_code == 200 300 | assert cli.get("/n2/n2_1/n2_1_1/").status_code == 429 301 | timeline.forward(60) 302 | # application limit (5/day) gets this one. 303 | assert cli.get("/n2/n2_1/n2_1_1/").status_code == 429 304 | # but not those exempt from application limits 305 | assert cli.get("/n1/").status_code == 200 306 | assert cli.get("/n1/n1_1/").status_code == 200 307 | assert cli.get("/n1/n1_1/n1_1_1/").status_code == 200 308 | assert cli.get("/n1/n1_1/n1_1_2/").status_code == 200 309 | # but doesn't spare the ones that didn't opt out. 310 | assert cli.get("/").status_code == 429 311 | assert cli.get("/n2/").status_code == 429 312 | 313 | 314 | def test_nested_blueprint_exemption_child_only(extension_factory): 315 | app, limiter = extension_factory(default_limits=["1/minute"]) 316 | parent_bp = Blueprint("parent", __name__, url_prefix="/parent") 317 | child_bp = Blueprint("child", __name__, url_prefix="/child") 318 | 319 | limiter.exempt(child_bp) 320 | 321 | @app.route("/") 322 | def root(): 323 | return "42" 324 | 325 | @parent_bp.route("/") 326 | def parent(): 327 | return "41" 328 | 329 | @child_bp.route("/") 330 | def child(): 331 | return "40" 332 | 333 | parent_bp.register_blueprint(child_bp) 334 | app.register_blueprint(parent_bp) 335 | app.register_blueprint(child_bp) # weird 336 | 337 | with app.test_client() as cli: 338 | assert cli.get("/").status_code == 200 339 | assert cli.get("/").status_code == 429 340 | assert cli.get("/parent/").status_code == 200 341 | assert cli.get("/parent/").status_code == 429 342 | assert cli.get("/parent/child/").status_code == 200 343 | assert cli.get("/parent/child/").status_code == 200 344 | assert cli.get("/child/").status_code == 200 345 | assert cli.get("/child/").status_code == 200 346 | 347 | 348 | def test_nested_blueprint_child_explicit_limit(extension_factory): 349 | app, limiter = extension_factory(default_limits=["1/minute"]) 350 | parent_bp = Blueprint("parent", __name__, url_prefix="/parent") 351 | child_bp = Blueprint("child", __name__, url_prefix="/child") 352 | 353 | limiter.limit("2/minute")(child_bp) 354 | 355 | @app.route("/") 356 | def root(): 357 | return "42" 358 | 359 | @parent_bp.route("/") 360 | def parent(): 361 | return "41" 362 | 363 | @child_bp.route("/") 364 | def child(): 365 | return "40" 366 | 367 | parent_bp.register_blueprint(child_bp) 368 | app.register_blueprint(parent_bp) 369 | 370 | with app.test_client() as cli: 371 | assert cli.get("/").status_code == 200 372 | assert cli.get("/").status_code == 429 373 | assert cli.get("/parent/").status_code == 200 374 | assert cli.get("/parent/").status_code == 429 375 | assert cli.get("/parent/child/").status_code == 200 376 | assert cli.get("/parent/child/").status_code == 200 377 | assert cli.get("/parent/child/").status_code == 429 378 | 379 | 380 | def test_nested_blueprint_child_explicit_nested_limits(extension_factory): 381 | app, limiter = extension_factory(default_limits=["1/minute"]) 382 | parent_bp = Blueprint("parent", __name__, url_prefix="/parent") 383 | child_bp = Blueprint("child", __name__, url_prefix="/child") 384 | grand_child_bp = Blueprint("grand_child", __name__, url_prefix="/grand_child") 385 | 386 | limiter.limit("3/hour")(parent_bp) 387 | limiter.limit("2/minute")(child_bp) 388 | limiter.limit("5/day", override_defaults=False)(grand_child_bp) 389 | 390 | @app.route("/") 391 | def root(): 392 | return "42" 393 | 394 | @parent_bp.route("/") 395 | def parent(): 396 | return "41" 397 | 398 | @child_bp.route("/") 399 | def child(): 400 | return "40" 401 | 402 | @grand_child_bp.route("/") 403 | def grand_child(): 404 | return "39" 405 | 406 | child_bp.register_blueprint(grand_child_bp) 407 | parent_bp.register_blueprint(child_bp) 408 | app.register_blueprint(parent_bp) 409 | 410 | with hiro.Timeline() as timeline: 411 | with app.test_client() as cli: 412 | assert cli.get("/").status_code == 200 413 | assert cli.get("/").status_code == 429 414 | assert cli.get("/parent/").status_code == 200 415 | assert cli.get("/parent/").status_code == 200 416 | assert cli.get("/parent/").status_code == 200 417 | assert cli.get("/parent/").status_code == 429 418 | assert cli.get("/parent/child/").status_code == 200 419 | assert cli.get("/parent/child/").status_code == 200 420 | assert cli.get("/parent/child/").status_code == 429 421 | timeline.forward(datetime.timedelta(minutes=1)) 422 | assert cli.get("/parent/child/").status_code == 200 423 | # parent's limit is ignored as override_defaults is True by default 424 | assert cli.get("/parent/child/").status_code == 200 425 | assert cli.get("/parent/child/grand_child/").status_code == 200 426 | # global limit is ignored as parent override's default 427 | assert cli.get("/parent/child/grand_child/").status_code == 200 428 | # child's limit is not ignored as grandchild sets override default to False 429 | assert cli.get("/parent/child/grand_child/").status_code == 429 430 | timeline.forward(datetime.timedelta(minutes=1)) 431 | assert cli.get("/parent/child/grand_child/").status_code == 200 432 | assert cli.get("/parent/child/grand_child/").status_code == 429 433 | timeline.forward(datetime.timedelta(minutes=60)) 434 | assert cli.get("/parent/child/grand_child/").status_code == 200 435 | timeline.forward(datetime.timedelta(minutes=60)) 436 | assert cli.get("/parent/child/grand_child/").status_code == 200 437 | timeline.forward(datetime.timedelta(minutes=60)) 438 | # grand child's own limit kicks in 439 | assert cli.get("/parent/child/grand_child/").status_code == 429 440 | 441 | 442 | def test_register_blueprint(extension_factory): 443 | app, limiter = extension_factory(default_limits=["1/minute"]) 444 | bp_1 = Blueprint("bp1", __name__) 445 | bp_2 = Blueprint("bp2", __name__) 446 | bp_3 = Blueprint("bp3", __name__) 447 | bp_4 = Blueprint("bp4", __name__) 448 | 449 | @bp_1.route("/t1") 450 | def t1(): 451 | return "test" 452 | 453 | @bp_1.route("/t2") 454 | def t2(): 455 | return "test" 456 | 457 | @bp_2.route("/t3") 458 | def t3(): 459 | return "test" 460 | 461 | @bp_3.route("/t4") 462 | def t4(): 463 | return "test" 464 | 465 | @bp_4.route("/t5") 466 | def t5(): 467 | return "test" 468 | 469 | def dy_limit(): 470 | return "1/second" 471 | 472 | app.register_blueprint(bp_1) 473 | app.register_blueprint(bp_2) 474 | app.register_blueprint(bp_3) 475 | app.register_blueprint(bp_4) 476 | 477 | limiter.limit("1/second")(bp_1) 478 | limiter.exempt(bp_3) 479 | limiter.limit(dy_limit)(bp_4) 480 | 481 | with hiro.Timeline().freeze() as timeline: 482 | with app.test_client() as cli: 483 | assert cli.get("/t1").status_code == 200 484 | assert cli.get("/t1").status_code == 429 485 | timeline.forward(1) 486 | assert cli.get("/t1").status_code == 200 487 | assert cli.get("/t2").status_code == 200 488 | assert cli.get("/t2").status_code == 429 489 | timeline.forward(1) 490 | assert cli.get("/t2").status_code == 200 491 | 492 | assert cli.get("/t3").status_code == 200 493 | for i in range(0, 10): 494 | timeline.forward(1) 495 | assert cli.get("/t3").status_code == 429 496 | 497 | for i in range(0, 10): 498 | assert cli.get("/t4").status_code == 200 499 | 500 | assert cli.get("/t5").status_code == 200 501 | assert cli.get("/t5").status_code == 429 502 | 503 | 504 | def test_invalid_decorated_static_limit_blueprint(caplog): 505 | caplog.set_level(logging.INFO) 506 | app = Flask(__name__) 507 | limiter = Limiter(get_remote_address, app=app, default_limits=["1/second"]) 508 | bp = Blueprint("bp1", __name__) 509 | 510 | @bp.route("/t1") 511 | def t1(): 512 | return "42" 513 | 514 | limiter.limit("2/sec")(bp) 515 | app.register_blueprint(bp) 516 | 517 | with app.test_client() as cli: 518 | with hiro.Timeline().freeze(): 519 | assert cli.get("/t1").status_code == 200 520 | assert cli.get("/t1").status_code == 429 521 | assert "failed to load" in caplog.records[0].msg 522 | assert "exceeded at endpoint" in caplog.records[-1].msg 523 | 524 | 525 | def test_invalid_decorated_dynamic_limits_blueprint(caplog): 526 | caplog.set_level(logging.INFO) 527 | app = Flask(__name__) 528 | app.config.setdefault("X", "2 per sec") 529 | limiter = Limiter(get_remote_address, app=app, default_limits=["1/second"]) 530 | bp = Blueprint("bp1", __name__) 531 | 532 | @bp.route("/t1") 533 | def t1(): 534 | return "42" 535 | 536 | limiter.limit(lambda: current_app.config.get("X"))(bp) 537 | app.register_blueprint(bp) 538 | 539 | with app.test_client() as cli: 540 | with hiro.Timeline().freeze(): 541 | assert cli.get("/t1").status_code == 200 542 | assert cli.get("/t1").status_code == 429 543 | 544 | assert len(caplog.records) == 3 545 | assert "failed to load ratelimit" in caplog.records[0].msg 546 | assert "failed to load ratelimit" in caplog.records[1].msg 547 | assert "exceeded at endpoint" in caplog.records[2].msg 548 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import re 5 | 6 | import pytest 7 | from flask import Flask 8 | 9 | from flask_limiter.commands import cli 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def set_env(): 14 | os.environ["NO_COLOR"] = "True" 15 | 16 | 17 | def test_no_limiter(kitchensink_factory): 18 | app = Flask(__name__) 19 | runner = app.test_cli_runner() 20 | result = runner.invoke(cli, ["config"]) 21 | assert "No Flask-Limiter extension installed" in result.output 22 | result = runner.invoke(cli, ["limits"]) 23 | assert "No Flask-Limiter extension installed" in result.output 24 | 25 | 26 | def test_config(kitchensink_factory): 27 | app, limiter = kitchensink_factory() 28 | runner = app.test_cli_runner() 29 | result = runner.invoke(cli, ["config"]) 30 | assert re.compile("Enabled.*True").search(result.output) 31 | 32 | 33 | def test_no_config(extension_factory): 34 | app, limiter = extension_factory() 35 | runner = app.test_cli_runner() 36 | result = runner.invoke(cli, ["config"]) 37 | assert re.compile("Enabled.*True").search(result.output) 38 | 39 | 40 | def test_limits(kitchensink_factory): 41 | app, limiter = kitchensink_factory() 42 | runner = app.test_cli_runner() 43 | result = runner.invoke(cli, ["limits"]) 44 | assert "5000 per 1 hour" in result.output 45 | assert re.compile(r"health.health: /health/\n\s*└── Exempt", re.MULTILINE).search(result.output) 46 | 47 | 48 | def test_limits_filter_endpoint(kitchensink_factory): 49 | app, limiter = kitchensink_factory() 50 | runner = app.test_cli_runner() 51 | result = runner.invoke(cli, ["limits", "--endpoint=root"]) 52 | assert "root: /" in result.output 53 | result = runner.invoke(cli, ["limits", "--endpoint=groot"]) 54 | assert "groot not found" in result.output 55 | 56 | 57 | def test_limits_filter_path(kitchensink_factory): 58 | app, limiter = kitchensink_factory() 59 | runner = app.test_cli_runner() 60 | result = runner.invoke(cli, ["limits", "--path=/"]) 61 | assert "root: /" in result.output 62 | result = runner.invoke(cli, ["limits", "--path=/", "--method=POST"]) 63 | assert "POST: / could not be matched" in result.output 64 | result = runner.invoke(cli, ["limits", "--path=/groot"]) 65 | assert "groot could not be matched" in result.output 66 | 67 | 68 | def test_limits_with_test(kitchensink_factory, mocker): 69 | app, limiter = kitchensink_factory() 70 | runner = app.test_cli_runner() 71 | mt = mocker.spy(limiter.limiter, "test") 72 | mw = mocker.spy(limiter.limiter, "get_window_stats") 73 | result = runner.invoke(cli, ["limits", "--key=127.0.0.1"]) 74 | assert "5000 per 1 hour: Pass (5000 out of 5000 remaining)" in result.output 75 | mt.side_effect = lambda *a: False 76 | mw.side_effect = lambda *a: (0, 0) 77 | result = runner.invoke(cli, ["limits", "--key=127.0.0.1"]) 78 | assert "5000 per 1 hour: Fail (0 out of 5000 remaining)" in result.output 79 | assert re.compile(r"health.health: /health/\n\s*└── Exempt", re.MULTILINE).search(result.output) 80 | 81 | 82 | def test_limits_with_test_storage_down(kitchensink_factory, mocker): 83 | app, limiter = kitchensink_factory() 84 | ms = mocker.spy(list(app.extensions.get("limiter"))[0].storage, "check") 85 | ms.side_effect = lambda: False 86 | runner = app.test_cli_runner() 87 | result = runner.invoke(cli, ["limits", "--key=127.0.0.1"]) 88 | assert "Storage not available" in result.output 89 | result = runner.invoke(cli, ["config"]) 90 | assert re.compile("└── Status.*└── Error").search(result.output) 91 | 92 | 93 | def test_clear_limits_no_extension(): 94 | app = Flask(__name__) 95 | runner = app.test_cli_runner() 96 | result = runner.invoke(cli, ["clear", "--key=127.0.0.1", "-y"]) 97 | assert "No Flask-Limiter extension installed" in result.output 98 | 99 | 100 | def test_clear_limits(kitchensink_factory, redis_connection): 101 | app, limiter = kitchensink_factory(storage_uri="redis://localhost:46379") 102 | runner = app.test_cli_runner() 103 | with app.test_client() as client: 104 | [client.get("/") for _ in range(5)] 105 | [client.get("/resource") for _ in range(5)] 106 | [client.post("/resource") for _ in range(5)] 107 | result = runner.invoke(cli, ["limits", "--key=127.0.0.1"]) 108 | assert "Fail (0 out of 5 remaining)" in result.output 109 | result = runner.invoke(cli, ["clear", "--key=127.0.0.1", "-y"]) 110 | assert "5000 per 1 hour: Cleared" in result.output 111 | assert "5 per 1 second: Cleared" in result.output 112 | result = runner.invoke(cli, ["clear", "--key=127.0.0.1", "--endpoint=root", "-y"]) 113 | assert "5000 per 1 hour: Cleared" not in result.output 114 | assert "5 per 1 second: Cleared" not in result.output 115 | assert "10 per 1 second: Cleared" in result.output 116 | -------------------------------------------------------------------------------- /tests/test_configuration.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import time 5 | 6 | import hiro 7 | import pytest 8 | from flask import Flask 9 | from limits.errors import ConfigurationError 10 | from limits.storage import MemoryStorage 11 | from limits.strategies import MovingWindowRateLimiter 12 | 13 | from flask_limiter import HeaderNames 14 | from flask_limiter.constants import ConfigVars 15 | from flask_limiter.extension import Limiter 16 | from flask_limiter.util import get_remote_address 17 | 18 | 19 | def test_invalid_strategy(): 20 | app = Flask(__name__) 21 | app.config.setdefault(ConfigVars.STRATEGY, "fubar") 22 | with pytest.raises(ConfigurationError): 23 | Limiter(get_remote_address, app=app) 24 | 25 | 26 | def test_invalid_storage_string(): 27 | app = Flask(__name__) 28 | app.config.setdefault(ConfigVars.STORAGE_URI, "fubar://localhost:1234") 29 | with pytest.raises(ConfigurationError): 30 | Limiter(get_remote_address, app=app) 31 | 32 | 33 | def test_constructor_arguments_over_config(redis_connection): 34 | app = Flask(__name__) 35 | app.config.setdefault(ConfigVars.STRATEGY, "sliding-window-counter") 36 | limiter = Limiter(get_remote_address, strategy="moving-window") 37 | limiter.init_app(app) 38 | app.config.setdefault(ConfigVars.STORAGE_URI, "redis://localhost:46379") 39 | app.config.setdefault(ConfigVars.APPLICATION_LIMITS, "1/minute") 40 | app.config.setdefault(ConfigVars.META_LIMITS, "1/hour") 41 | assert type(limiter._limiter) is MovingWindowRateLimiter 42 | limiter = Limiter(get_remote_address, storage_uri="memory://") 43 | limiter.init_app(app) 44 | assert type(limiter._storage) is MemoryStorage 45 | 46 | @app.route("/") 47 | def root(): 48 | return "root" 49 | 50 | with hiro.Timeline().freeze() as timeline: 51 | with app.test_client() as cli: 52 | assert cli.get("/").status_code == 200 53 | assert cli.get("/").status_code == 429 54 | timeline.forward(60) 55 | assert cli.get("/").status_code == 429 56 | 57 | 58 | def test_header_names_config(): 59 | app = Flask(__name__) 60 | app.config.setdefault(ConfigVars.HEADER_LIMIT, "XX-Limit") 61 | app.config.setdefault(ConfigVars.HEADER_REMAINING, "XX-Remaining") 62 | app.config.setdefault(ConfigVars.HEADER_RESET, "XX-Reset") 63 | limiter = Limiter(get_remote_address, headers_enabled=True, default_limits=["1/second"]) 64 | limiter.init_app(app) 65 | 66 | @app.route("/") 67 | def root(): 68 | return "42" 69 | 70 | with app.test_client() as client: 71 | resp = client.get("/") 72 | assert resp.headers["XX-Limit"] == "1" 73 | assert resp.headers["XX-Remaining"] == "0" 74 | assert resp.headers["XX-Reset"] == str(math.ceil(time.time() + 1)) 75 | 76 | 77 | def test_header_names_constructor(): 78 | app = Flask(__name__) 79 | limiter = Limiter( 80 | get_remote_address, 81 | headers_enabled=True, 82 | default_limits=["1/second"], 83 | header_name_mapping={ 84 | HeaderNames.LIMIT: "XX-Limit", 85 | HeaderNames.REMAINING: "XX-Remaining", 86 | HeaderNames.RESET: "XX-Reset", 87 | }, 88 | ) 89 | limiter.init_app(app) 90 | 91 | @app.route("/") 92 | def root(): 93 | return "42" 94 | 95 | with app.test_client() as client: 96 | resp = client.get("/") 97 | assert resp.headers["XX-Limit"] == "1" 98 | assert resp.headers["XX-Remaining"] == "0" 99 | assert resp.headers["XX-Reset"] == str(math.ceil(time.time() + 1)) 100 | 101 | 102 | def test_invalid_config_with_disabled(): 103 | app = Flask(__name__) 104 | app.config.setdefault(ConfigVars.ENABLED, False) 105 | app.config.setdefault(ConfigVars.STORAGE_URI, "fubar://") 106 | 107 | limiter = Limiter(get_remote_address, app=app, default_limits=["1/hour"]) 108 | 109 | @app.route("/") 110 | def root(): 111 | return "root" 112 | 113 | @app.route("/explicit") 114 | @limiter.limit("2/hour") 115 | def explicit(): 116 | return "explicit" 117 | 118 | with app.test_client() as client: 119 | assert client.get("/").status_code == 200 120 | assert client.get("/").status_code == 200 121 | assert client.get("/explicit").status_code == 200 122 | assert client.get("/explicit").status_code == 200 123 | assert client.get("/explicit").status_code == 200 124 | 125 | 126 | def test_uninitialized_limiter(): 127 | app = Flask(__name__) 128 | limiter = Limiter(get_remote_address, default_limits=["1/hour"]) 129 | 130 | @app.route("/") 131 | @limiter.limit("2/hour") 132 | def root(): 133 | return "root" 134 | 135 | with app.test_client() as client: 136 | assert client.get("/").status_code == 200 137 | assert client.get("/").status_code == 200 138 | assert client.get("/").status_code == 200 139 | -------------------------------------------------------------------------------- /tests/test_context_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hiro 4 | 5 | from flask_limiter import RateLimitExceeded 6 | 7 | 8 | def test_static_limit(extension_factory): 9 | app, limiter = extension_factory() 10 | 11 | @app.route("/t1") 12 | def t1(): 13 | with limiter.limit("1/second"): 14 | resp = "ok" 15 | try: 16 | with limiter.limit("1/day"): 17 | resp += "maybe" 18 | except RateLimitExceeded: 19 | pass 20 | return resp 21 | 22 | with hiro.Timeline().freeze() as timeline: 23 | with app.test_client() as cli: 24 | response = cli.get("/t1") 25 | assert 200 == response.status_code 26 | assert "okmaybe" == response.text 27 | assert 429 == cli.get("/t1").status_code 28 | timeline.forward(1) 29 | response = cli.get("/t1") 30 | assert 200 == response.status_code 31 | assert "ok" == response.text 32 | 33 | 34 | def test_dynamic_limits(extension_factory): 35 | app, limiter = extension_factory() 36 | 37 | @app.route("/t1") 38 | def t1(): 39 | with limiter.limit(lambda: "1/second"): 40 | return "test" 41 | 42 | with hiro.Timeline().freeze(): 43 | with app.test_client() as cli: 44 | assert 200 == cli.get("/t1").status_code 45 | assert 429 == cli.get("/t1").status_code 46 | 47 | 48 | def test_scoped_context_manager(extension_factory): 49 | app, limiter = extension_factory() 50 | 51 | @app.route("/t1/") 52 | def t1(param: int): 53 | with limiter.limit("1/second", scope=param): 54 | return "p1" 55 | 56 | with hiro.Timeline().freeze(): 57 | with app.test_client() as cli: 58 | assert 200 == cli.get("/t1/1").status_code 59 | assert 429 == cli.get("/t1/1").status_code 60 | assert 200 == cli.get("/t1/2").status_code 61 | assert 429 == cli.get("/t1/2").status_code 62 | -------------------------------------------------------------------------------- /tests/test_error_handling.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from unittest.mock import patch 5 | 6 | import hiro 7 | from flask import make_response 8 | 9 | from flask_limiter.constants import ConfigVars 10 | 11 | 12 | def test_error_message(extension_factory): 13 | app, limiter = extension_factory({ConfigVars.DEFAULT_LIMITS: "1 per day"}) 14 | 15 | @app.route("/") 16 | def null(): 17 | return "" 18 | 19 | with app.test_client() as cli: 20 | 21 | @app.errorhandler(429) 22 | def ratelimit_handler(e): 23 | return make_response('{"error" : "rate limit %s"}' % str(e.description), 429) 24 | 25 | cli.get("/") 26 | assert "1 per 1 day" in cli.get("/").data.decode() 27 | 28 | assert {"error": "rate limit 1 per 1 day"} == json.loads(cli.get("/").data.decode()) 29 | 30 | 31 | def test_custom_error_message(extension_factory): 32 | app, limiter = extension_factory() 33 | 34 | @app.errorhandler(429) 35 | def ratelimit_handler(e): 36 | return make_response(e.description, 429) 37 | 38 | def l1(): 39 | return "1/second" 40 | 41 | def e1(): 42 | return "dos" 43 | 44 | @app.route("/t1") 45 | @limiter.limit("1/second", error_message="uno") 46 | def t1(): 47 | return "1" 48 | 49 | @app.route("/t2") 50 | @limiter.limit(l1, error_message=e1) 51 | def t2(): 52 | return "2" 53 | 54 | s1 = limiter.shared_limit("1/second", scope="error_message", error_message="tres") 55 | 56 | @app.route("/t3") 57 | @s1 58 | def t3(): 59 | return "3" 60 | 61 | with hiro.Timeline().freeze(): 62 | with app.test_client() as cli: 63 | cli.get("/t1") 64 | resp = cli.get("/t1") 65 | assert 429 == resp.status_code 66 | assert resp.data == b"uno" 67 | cli.get("/t2") 68 | resp = cli.get("/t2") 69 | assert 429 == resp.status_code 70 | assert resp.data == b"dos" 71 | cli.get("/t3") 72 | resp = cli.get("/t3") 73 | assert 429 == resp.status_code 74 | assert resp.data == b"tres" 75 | 76 | 77 | def test_swallow_error(extension_factory): 78 | app, limiter = extension_factory( 79 | { 80 | ConfigVars.DEFAULT_LIMITS: "1 per day", 81 | ConfigVars.HEADERS_ENABLED: True, 82 | ConfigVars.SWALLOW_ERRORS: True, 83 | } 84 | ) 85 | 86 | @app.route("/") 87 | def null(): 88 | return "ok" 89 | 90 | with app.test_client() as cli: 91 | with patch("limits.strategies.FixedWindowRateLimiter.hit") as hit: 92 | 93 | def raiser(*a, **k): 94 | raise Exception 95 | 96 | hit.side_effect = raiser 97 | assert "ok" in cli.get("/").data.decode() 98 | with patch("limits.strategies.FixedWindowRateLimiter.get_window_stats") as get_window_stats: 99 | 100 | def raiser(*a, **k): 101 | raise Exception 102 | 103 | get_window_stats.side_effect = raiser 104 | assert "ok" in cli.get("/").data.decode() 105 | 106 | 107 | def test_swallow_error_conditional_deduction(extension_factory): 108 | def conditional_deduct(_): 109 | return True 110 | 111 | app, limiter = extension_factory( 112 | { 113 | ConfigVars.DEFAULT_LIMITS: "1 per day", 114 | ConfigVars.SWALLOW_ERRORS: True, 115 | ConfigVars.DEFAULT_LIMITS_DEDUCT_WHEN: conditional_deduct, 116 | } 117 | ) 118 | 119 | @app.route("/") 120 | def null(): 121 | return "ok" 122 | 123 | with app.test_client() as cli: 124 | with patch("limits.strategies.FixedWindowRateLimiter.hit") as hit: 125 | 126 | def raiser(*a, **k): 127 | raise Exception 128 | 129 | hit.side_effect = raiser 130 | assert "ok" in cli.get("/").data.decode() 131 | 132 | 133 | def test_no_swallow_error(extension_factory): 134 | app, limiter = extension_factory( 135 | {ConfigVars.DEFAULT_LIMITS: "1 per day", ConfigVars.HEADERS_ENABLED: True} 136 | ) 137 | 138 | @app.route("/") 139 | def null(): 140 | return "ok" 141 | 142 | @app.errorhandler(500) 143 | def e500(e): 144 | return str(e.original_exception), 500 145 | 146 | def raiser(*a, **k): 147 | raise Exception("underlying") 148 | 149 | with app.test_client() as cli: 150 | with patch("limits.strategies.FixedWindowRateLimiter.hit") as hit: 151 | hit.side_effect = raiser 152 | assert 500 == cli.get("/").status_code 153 | assert "underlying" == cli.get("/").data.decode() 154 | with patch("limits.strategies.FixedWindowRateLimiter.get_window_stats") as get_window_stats: 155 | get_window_stats.side_effect = raiser 156 | assert 500 == cli.get("/").status_code 157 | assert "underlying" == cli.get("/").data.decode() 158 | 159 | 160 | def test_no_swallow_error_conditional_deduction(extension_factory): 161 | def conditional_deduct(_): 162 | return True 163 | 164 | app, limiter = extension_factory( 165 | { 166 | ConfigVars.DEFAULT_LIMITS: "1 per day", 167 | ConfigVars.SWALLOW_ERRORS: False, 168 | ConfigVars.DEFAULT_LIMITS_DEDUCT_WHEN: conditional_deduct, 169 | } 170 | ) 171 | 172 | @app.route("/") 173 | def null(): 174 | return "ok" 175 | 176 | with app.test_client() as cli: 177 | with patch("limits.strategies.FixedWindowRateLimiter.hit") as hit: 178 | 179 | def raiser(*a, **k): 180 | raise Exception 181 | 182 | hit.side_effect = raiser 183 | assert 500 == cli.get("/").status_code 184 | 185 | 186 | def test_fallback_to_memory_config(redis_connection, extension_factory): 187 | _, limiter = extension_factory( 188 | config={ConfigVars.ENABLED: True}, 189 | default_limits=["5/minute"], 190 | storage_uri="redis://localhost:46379", 191 | in_memory_fallback=["1/minute"], 192 | ) 193 | assert len(limiter._in_memory_fallback) == 1 194 | assert limiter._in_memory_fallback_enabled 195 | 196 | _, limiter = extension_factory( 197 | config={ConfigVars.ENABLED: True, ConfigVars.IN_MEMORY_FALLBACK: "1/minute"}, 198 | default_limits=["5/minute"], 199 | storage_uri="redis://localhost:46379", 200 | ) 201 | assert len(limiter._in_memory_fallback) == 1 202 | assert limiter._in_memory_fallback_enabled 203 | 204 | _, limiter = extension_factory( 205 | config={ConfigVars.ENABLED: True, ConfigVars.IN_MEMORY_FALLBACK_ENABLED: True}, 206 | default_limits=["5/minute"], 207 | storage_uri="redis://localhost:46379", 208 | ) 209 | assert limiter._in_memory_fallback_enabled 210 | 211 | _, limiter = extension_factory( 212 | config={ConfigVars.ENABLED: True}, 213 | default_limits=["5/minute"], 214 | storage_uri="redis://localhost:46379", 215 | in_memory_fallback_enabled=True, 216 | ) 217 | 218 | 219 | def test_fallback_to_memory_backoff_check(redis_connection, extension_factory): 220 | app, limiter = extension_factory( 221 | config={ConfigVars.ENABLED: True}, 222 | default_limits=["5/minute"], 223 | storage_uri="redis://localhost:46379", 224 | in_memory_fallback=["1/minute"], 225 | ) 226 | 227 | @app.route("/t1") 228 | def t1(): 229 | return "test" 230 | 231 | with app.test_client() as cli: 232 | 233 | def raiser(*a): 234 | raise Exception("redis dead") 235 | 236 | with hiro.Timeline() as timeline: 237 | with patch("redis.Redis.execute_command") as exec_command: 238 | exec_command.side_effect = raiser 239 | assert cli.get("/t1").status_code == 200 240 | assert cli.get("/t1").status_code == 429 241 | timeline.forward(1) 242 | assert cli.get("/t1").status_code == 429 243 | timeline.forward(2) 244 | assert cli.get("/t1").status_code == 429 245 | timeline.forward(4) 246 | assert cli.get("/t1").status_code == 429 247 | timeline.forward(8) 248 | assert cli.get("/t1").status_code == 429 249 | timeline.forward(16) 250 | assert cli.get("/t1").status_code == 429 251 | timeline.forward(32) 252 | assert cli.get("/t1").status_code == 200 253 | # redis back to normal, but exponential backoff will only 254 | # result in it being marked after pow(2,0) seconds and next 255 | # check 256 | assert cli.get("/t1").status_code == 429 257 | timeline.forward(2) 258 | assert cli.get("/t1").status_code == 200 259 | assert cli.get("/t1").status_code == 200 260 | assert cli.get("/t1").status_code == 200 261 | assert cli.get("/t1").status_code == 200 262 | assert cli.get("/t1").status_code == 200 263 | assert cli.get("/t1").status_code == 429 264 | 265 | 266 | def test_fallback_to_memory_with_global_override(redis_connection, extension_factory): 267 | app, limiter = extension_factory( 268 | config={ConfigVars.ENABLED: True}, 269 | default_limits=["5/minute"], 270 | storage_uri="redis://localhost:46379", 271 | in_memory_fallback=["1/minute"], 272 | ) 273 | 274 | @app.route("/t1") 275 | def t1(): 276 | return "test" 277 | 278 | @app.route("/t2") 279 | @limiter.limit("3 per minute") 280 | def t2(): 281 | return "test" 282 | 283 | with app.test_client() as cli: 284 | assert cli.get("/t1").status_code == 200 285 | assert cli.get("/t1").status_code == 200 286 | assert cli.get("/t1").status_code == 200 287 | assert cli.get("/t1").status_code == 200 288 | assert cli.get("/t1").status_code == 200 289 | assert cli.get("/t1").status_code == 429 290 | assert cli.get("/t2").status_code == 200 291 | assert cli.get("/t2").status_code == 200 292 | assert cli.get("/t2").status_code == 200 293 | assert cli.get("/t2").status_code == 429 294 | 295 | def raiser(*a): 296 | raise Exception("redis dead") 297 | 298 | with patch("redis.Redis.execute_command") as exec_command: 299 | exec_command.side_effect = raiser 300 | assert cli.get("/t1").status_code == 200 301 | assert cli.get("/t1").status_code == 429 302 | assert cli.get("/t2").status_code == 200 303 | assert cli.get("/t2").status_code == 429 304 | # redis back to normal, go back to regular limits 305 | with hiro.Timeline() as timeline: 306 | timeline.forward(2) 307 | limiter._storage.storage.flushall() 308 | assert cli.get("/t2").status_code == 200 309 | assert cli.get("/t2").status_code == 200 310 | assert cli.get("/t2").status_code == 200 311 | assert cli.get("/t2").status_code == 429 312 | 313 | 314 | def test_fallback_to_memory(extension_factory): 315 | app, limiter = extension_factory( 316 | config={ConfigVars.ENABLED: True}, 317 | default_limits=["2/minute"], 318 | storage_uri="redis://localhost:46379", 319 | in_memory_fallback_enabled=True, 320 | headers_enabled=True, 321 | ) 322 | 323 | @app.route("/t1") 324 | def t1(): 325 | return "test" 326 | 327 | @app.route("/t2") 328 | @limiter.limit("1 per minute") 329 | def t2(): 330 | return "test" 331 | 332 | with app.test_client() as cli: 333 | assert cli.get("/t1").status_code == 200 334 | assert cli.get("/t1").status_code == 200 335 | assert cli.get("/t1").status_code == 429 336 | assert cli.get("/t2").status_code == 200 337 | assert cli.get("/t2").status_code == 429 338 | 339 | def raiser(*a): 340 | raise Exception("redis dead") 341 | 342 | with patch("redis.Redis.execute_command") as exec_command: 343 | exec_command.side_effect = raiser 344 | assert cli.get("/t1").status_code == 200 345 | assert cli.get("/t1").status_code == 200 346 | assert cli.get("/t1").status_code == 429 347 | assert cli.get("/t2").status_code == 200 348 | assert cli.get("/t2").status_code == 429 349 | with hiro.Timeline() as timeline: 350 | timeline.forward(1) 351 | limiter._storage.storage.flushall() 352 | assert cli.get("/t2").status_code == 200 353 | assert cli.get("/t2").status_code == 429 354 | -------------------------------------------------------------------------------- /tests/test_regressions.py: -------------------------------------------------------------------------------- 1 | """ """ 2 | 3 | from __future__ import annotations 4 | 5 | import time 6 | 7 | import hiro 8 | from flask import Blueprint 9 | 10 | from flask_limiter.constants import ConfigVars 11 | 12 | 13 | def test_redis_request_slower_than_fixed_window(redis_connection, extension_factory): 14 | app, limiter = extension_factory( 15 | { 16 | ConfigVars.DEFAULT_LIMITS: "5 per second", 17 | ConfigVars.STORAGE_URI: "redis://localhost:46379", 18 | ConfigVars.STRATEGY: "fixed-window", 19 | ConfigVars.HEADERS_ENABLED: True, 20 | } 21 | ) 22 | 23 | @app.route("/t1") 24 | def t1(): 25 | time.sleep(1.1) 26 | return "t1" 27 | 28 | with app.test_client() as cli: 29 | resp = cli.get("/t1") 30 | assert resp.headers["X-RateLimit-Remaining"] == "5" 31 | 32 | 33 | def test_redis_request_slower_than_moving_window(redis_connection, extension_factory): 34 | app, limiter = extension_factory( 35 | { 36 | ConfigVars.DEFAULT_LIMITS: "5 per second", 37 | ConfigVars.STORAGE_URI: "redis://localhost:46379", 38 | ConfigVars.STRATEGY: "moving-window", 39 | ConfigVars.HEADERS_ENABLED: True, 40 | } 41 | ) 42 | 43 | @app.route("/t1") 44 | def t1(): 45 | time.sleep(1.1) 46 | return "t1" 47 | 48 | with app.test_client() as cli: 49 | resp = cli.get("/t1") 50 | assert resp.headers["X-RateLimit-Remaining"] == "5" 51 | 52 | 53 | def test_dynamic_limits(extension_factory): 54 | app, limiter = extension_factory( 55 | {ConfigVars.STRATEGY: "moving-window", ConfigVars.HEADERS_ENABLED: True} 56 | ) 57 | 58 | def func(*a): 59 | return "1/second; 2/minute" 60 | 61 | @app.route("/t1") 62 | @limiter.limit(func) 63 | def t1(): 64 | return "t1" 65 | 66 | with hiro.Timeline().freeze() as timeline: 67 | with app.test_client() as cli: 68 | assert cli.get("/t1").status_code == 200 69 | assert cli.get("/t1").status_code == 429 70 | timeline.forward(2) 71 | assert cli.get("/t1").status_code == 200 72 | assert cli.get("/t1").status_code == 429 73 | 74 | 75 | def test_invalid_ratelimit_key(extension_factory): 76 | app, limiter = extension_factory({ConfigVars.HEADERS_ENABLED: True}) 77 | 78 | def func(*a): 79 | return None 80 | 81 | @app.route("/t1") 82 | @limiter.limit("2/second", key_func=func) 83 | def t1(): 84 | return "t1" 85 | 86 | with app.test_client() as cli: 87 | cli.get("/t1") 88 | cli.get("/t1") 89 | cli.get("/t1") 90 | assert cli.get("/t1").status_code == 200 91 | limiter.limit("1/second", key_func=lambda: "key")(t1) 92 | cli.get("/t1") 93 | assert cli.get("/t1").status_code == 429 94 | 95 | 96 | def test_custom_key_prefix_with_headers(redis_connection, extension_factory): 97 | app1, limiter1 = extension_factory( 98 | key_prefix="moo", storage_uri="redis://localhost:46379", headers_enabled=True 99 | ) 100 | app2, limiter2 = extension_factory( 101 | key_prefix="cow", storage_uri="redis://localhost:46379", headers_enabled=True 102 | ) 103 | 104 | @app1.route("/test") 105 | @limiter1.limit("1/minute") 106 | def t1(): 107 | return "app1 test" 108 | 109 | @app2.route("/test") 110 | @limiter2.limit("1/minute") 111 | def t2(): 112 | return "app2 test" 113 | 114 | with app1.test_client() as cli: 115 | resp = cli.get("/test") 116 | assert 200 == resp.status_code 117 | resp = cli.get("/test") 118 | assert resp.headers.get("Retry-After") == str(60) 119 | assert 429 == resp.status_code 120 | with app2.test_client() as cli: 121 | resp = cli.get("/test") 122 | assert 200 == resp.status_code 123 | resp = cli.get("/test") 124 | assert resp.headers.get("Retry-After") == str(60) 125 | assert 429 == resp.status_code 126 | 127 | 128 | def test_default_limits_with_per_route_limit(extension_factory): 129 | app, limiter = extension_factory(application_limits=["3/minute"]) 130 | 131 | @app.route("/explicit") 132 | @limiter.limit("1/minute") 133 | def explicit(): 134 | return "explicit" 135 | 136 | @app.route("/default") 137 | def default(): 138 | return "default" 139 | 140 | with app.test_client() as cli: 141 | with hiro.Timeline().freeze() as timeline: 142 | assert 200 == cli.get("/explicit").status_code 143 | assert 429 == cli.get("/explicit").status_code 144 | assert 200 == cli.get("/default").status_code 145 | assert 429 == cli.get("/default").status_code 146 | timeline.forward(60) 147 | assert 200 == cli.get("/explicit").status_code 148 | assert 200 == cli.get("/default").status_code 149 | 150 | 151 | def test_application_limits_from_config(extension_factory): 152 | app, limiter = extension_factory( 153 | config={ 154 | ConfigVars.APPLICATION_LIMITS: "4/second", 155 | ConfigVars.DEFAULT_LIMITS: "1/second", 156 | ConfigVars.DEFAULT_LIMITS_PER_METHOD: True, 157 | } 158 | ) 159 | 160 | @app.route("/root") 161 | def root(): 162 | return "null" 163 | 164 | @app.route("/test", methods=["GET", "PUT"]) 165 | @limiter.limit("3/second", methods=["GET"]) 166 | def test(): 167 | return "test" 168 | 169 | with app.test_client() as cli: 170 | with hiro.Timeline() as timeline: 171 | assert cli.get("/root").status_code == 200 172 | assert cli.get("/root").status_code == 429 173 | assert cli.get("/test").status_code == 200 174 | assert cli.get("/test").status_code == 200 175 | assert cli.get("/test").status_code == 429 176 | timeline.forward(1) 177 | assert cli.get("/test").status_code == 200 178 | assert cli.get("/test").status_code == 200 179 | assert cli.get("/test").status_code == 200 180 | assert cli.get("/test").status_code == 429 181 | timeline.forward(1) 182 | assert cli.put("/test").status_code == 200 183 | assert cli.put("/test").status_code == 429 184 | assert cli.get("/test").status_code == 200 185 | assert cli.get("/root").status_code == 200 186 | assert cli.get("/test").status_code == 429 187 | 188 | 189 | def test_endpoint_with_dot_but_not_blueprint(extension_factory): 190 | """ 191 | https://github.com/alisaifee/flask-limiter/issues/336 192 | """ 193 | app, limiter = extension_factory(default_limits=["2/day"]) 194 | 195 | def route(): 196 | return "42" 197 | 198 | app.add_url_rule("/teapot/iam", "_teapot.iam", route) 199 | bp = Blueprint("teapot", __name__, url_prefix="/teapot") 200 | 201 | @bp.route("/") 202 | def bp_route(): 203 | return "43" 204 | 205 | app.register_blueprint(bp) 206 | limiter.limit("1/day")(bp) 207 | 208 | with app.test_client() as cli: 209 | assert cli.get("/teapot/iam").status_code == 200 210 | assert cli.get("/teapot/iam").status_code == 200 211 | assert cli.get("/teapot/iam").status_code == 429 212 | assert cli.get("/teapot/").status_code == 200 213 | assert cli.get("/teapot/").status_code == 429 214 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hiro 4 | import pytest 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def setup(redis_connection, memcached_connection, mongo_connection): 9 | redis_connection.flushall() 10 | memcached_connection.flush_all() 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "storage_uri", 15 | [ 16 | "memcached://localhost:31211", 17 | "redis://localhost:46379", 18 | "mongodb://localhost:47017", 19 | ], 20 | ) 21 | def test_fixed_window(extension_factory, storage_uri): 22 | app, limiter = extension_factory( 23 | application_limits=["2/minute"], 24 | storage_uri=storage_uri, 25 | strategy="fixed-window", 26 | ) 27 | 28 | @app.route("/t1") 29 | def t1(): 30 | return "route1" 31 | 32 | @app.route("/t2") 33 | def t2(): 34 | return "route2" 35 | 36 | with hiro.Timeline().freeze(): 37 | with app.test_client() as cli: 38 | assert 200 == cli.get("/t1").status_code 39 | assert 200 == cli.get("/t2").status_code 40 | assert 429 == cli.get("/t1").status_code 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "storage_uri", 45 | [ 46 | "redis://localhost:46379", 47 | "mongodb://localhost:47017", 48 | ], 49 | ) 50 | def test_moving_window(extension_factory, storage_uri): 51 | app, limiter = extension_factory( 52 | application_limits=["2/minute"], 53 | storage_uri=storage_uri, 54 | strategy="moving-window", 55 | ) 56 | 57 | @app.route("/t1") 58 | def t1(): 59 | return "route1" 60 | 61 | @app.route("/t2") 62 | def t2(): 63 | return "route2" 64 | 65 | with hiro.Timeline().freeze(): 66 | with app.test_client() as cli: 67 | assert 200 == cli.get("/t1").status_code 68 | assert 200 == cli.get("/t2").status_code 69 | assert 429 == cli.get("/t1").status_code 70 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask_restful 4 | import hiro 5 | import pytest 6 | from flask import request 7 | from flask.views import MethodView, View 8 | 9 | 10 | def test_pluggable_views(extension_factory): 11 | app, limiter = extension_factory(default_limits=["1/hour"]) 12 | 13 | class Va(View): 14 | methods = ["GET", "POST"] 15 | decorators = [limiter.limit("2/second")] 16 | 17 | def dispatch_request(self): 18 | return request.method.lower() 19 | 20 | class Vb(View): 21 | methods = ["GET"] 22 | decorators = [limiter.limit("1/second, 3/minute")] 23 | 24 | def dispatch_request(self): 25 | return request.method.lower() 26 | 27 | class Vc(View): 28 | methods = ["GET"] 29 | 30 | def dispatch_request(self): 31 | return request.method.lower() 32 | 33 | app.add_url_rule("/a", view_func=Va.as_view("a")) 34 | app.add_url_rule("/b", view_func=Vb.as_view("b")) 35 | app.add_url_rule("/c", view_func=Vc.as_view("c")) 36 | with hiro.Timeline().freeze() as timeline: 37 | with app.test_client() as cli: 38 | assert 200 == cli.get("/a").status_code 39 | assert 200 == cli.get("/a").status_code 40 | assert 429 == cli.post("/a").status_code 41 | assert 200 == cli.get("/b").status_code 42 | timeline.forward(1) 43 | assert 200 == cli.get("/b").status_code 44 | timeline.forward(1) 45 | assert 200 == cli.get("/b").status_code 46 | timeline.forward(1) 47 | assert 429 == cli.get("/b").status_code 48 | assert 200 == cli.get("/c").status_code 49 | assert 429 == cli.get("/c").status_code 50 | 51 | 52 | def test_pluggable_method_views(extension_factory): 53 | app, limiter = extension_factory(default_limits=["1/hour"]) 54 | 55 | class Va(MethodView): 56 | decorators = [limiter.limit("2/second")] 57 | 58 | def get(self): 59 | return request.method.lower() 60 | 61 | def post(self): 62 | return request.method.lower() 63 | 64 | class Vb(MethodView): 65 | decorators = [limiter.limit("1/second, 3/minute")] 66 | 67 | def get(self): 68 | return request.method.lower() 69 | 70 | class Vc(MethodView): 71 | def get(self): 72 | return request.method.lower() 73 | 74 | class Vd(MethodView): 75 | decorators = [limiter.limit("1/minute", methods=["get"])] 76 | 77 | def get(self): 78 | return request.method.lower() 79 | 80 | def post(self): 81 | return request.method.lower() 82 | 83 | app.add_url_rule("/a", view_func=Va.as_view("a")) 84 | app.add_url_rule("/b", view_func=Vb.as_view("b")) 85 | app.add_url_rule("/c", view_func=Vc.as_view("c")) 86 | app.add_url_rule("/d", view_func=Vd.as_view("d")) 87 | 88 | with hiro.Timeline().freeze() as timeline: 89 | with app.test_client() as cli: 90 | assert 200 == cli.get("/a").status_code 91 | assert 200 == cli.get("/a").status_code 92 | assert 429 == cli.get("/a").status_code 93 | assert 429 == cli.post("/a").status_code 94 | assert 200 == cli.get("/b").status_code 95 | timeline.forward(1) 96 | assert 200 == cli.get("/b").status_code 97 | timeline.forward(1) 98 | assert 200 == cli.get("/b").status_code 99 | timeline.forward(1) 100 | assert 429 == cli.get("/b").status_code 101 | assert 200 == cli.get("/c").status_code 102 | assert 429 == cli.get("/c").status_code 103 | assert 200 == cli.get("/d").status_code 104 | assert 429 == cli.get("/d").status_code 105 | assert 200 == cli.post("/d").status_code 106 | assert 429 == cli.post("/d").status_code 107 | timeline.forward(3600) 108 | assert 200 == cli.post("/d").status_code 109 | 110 | 111 | def test_flask_restful_resource(extension_factory): 112 | app, limiter = extension_factory(default_limits=["1/hour"]) 113 | api = flask_restful.Api(app) 114 | 115 | class Va(flask_restful.Resource): 116 | decorators = [limiter.limit("2/second")] 117 | 118 | def get(self): 119 | return request.method.lower() 120 | 121 | def post(self): 122 | return request.method.lower() 123 | 124 | class Vb(flask_restful.Resource): 125 | decorators = [limiter.limit("1/second, 3/minute")] 126 | 127 | def get(self): 128 | return request.method.lower() 129 | 130 | class Vc(flask_restful.Resource): 131 | def get(self): 132 | return request.method.lower() 133 | 134 | class Vd(flask_restful.Resource): 135 | decorators = [ 136 | limiter.limit("2/second", methods=["GET"]), 137 | limiter.limit("1/second", methods=["POST"]), 138 | ] 139 | 140 | def get(self): 141 | return request.method.lower() 142 | 143 | def post(self): 144 | return request.method.lower() 145 | 146 | api.add_resource(Va, "/a") 147 | api.add_resource(Vb, "/b") 148 | api.add_resource(Vc, "/c") 149 | api.add_resource(Vd, "/d") 150 | 151 | with hiro.Timeline().freeze() as timeline: 152 | with app.test_client() as cli: 153 | assert 200 == cli.get("/a").status_code 154 | assert 200 == cli.get("/a").status_code 155 | assert 429 == cli.get("/a").status_code 156 | assert 429 == cli.post("/a").status_code 157 | assert 200 == cli.get("/b").status_code 158 | assert 200 == cli.get("/d").status_code 159 | assert 200 == cli.get("/d").status_code 160 | assert 429 == cli.get("/d").status_code 161 | assert 200 == cli.post("/d").status_code 162 | assert 429 == cli.post("/d").status_code 163 | timeline.forward(1) 164 | assert 200 == cli.get("/b").status_code 165 | timeline.forward(1) 166 | assert 200 == cli.get("/b").status_code 167 | timeline.forward(1) 168 | assert 429 == cli.get("/b").status_code 169 | assert 200 == cli.get("/c").status_code 170 | assert 429 == cli.get("/c").status_code 171 | 172 | 173 | @pytest.mark.xfail 174 | def test_flask_restx_resource(extension_factory): 175 | import flask_restx 176 | 177 | app, limiter = extension_factory() 178 | api = flask_restx.Api(app) 179 | ns = api.namespace("test") 180 | 181 | @ns.route("/a") 182 | class Va(flask_restx.Resource): 183 | decorators = [limiter.limit("2/second", per_method=True)] 184 | 185 | def get(self): 186 | return request.method.lower() 187 | 188 | def post(self): 189 | return request.method.lower() 190 | 191 | with hiro.Timeline().freeze() as timeline: 192 | with app.test_client() as cli: 193 | assert 200 == cli.get("/test/a").status_code 194 | assert 200 == cli.get("/test/a").status_code 195 | assert 200 == cli.post("/test/a").status_code 196 | assert 200 == cli.post("/test/a").status_code 197 | assert 429 == cli.get("/test/a").status_code 198 | assert 429 == cli.post("/test/a").status_code 199 | timeline.forward(1) 200 | assert 200 == cli.get("/test/a").status_code 201 | assert 200 == cli.post("/test/a").status_code 202 | --------------------------------------------------------------------------------