├── .github ├── actions │ └── test │ │ └── action.yml └── workflows │ ├── ci.yml │ ├── codeql.yml │ └── release.yml ├── .gitignore ├── CHANGELOG ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_ratelimit ├── __init__.py ├── apps.py ├── checks.py ├── core.py ├── decorators.py ├── exceptions.py ├── middleware.py ├── models.py └── tests.py ├── docs ├── Makefile ├── conf.py ├── contributing.rst ├── cookbook │ ├── 429.rst │ ├── index.rst │ └── per-user.rst ├── index.rst ├── installation.rst ├── keys.rst ├── rates.rst ├── security.rst ├── settings.rst ├── upgrading.rst └── usage.rst ├── pyproject.toml ├── run.sh ├── setup.cfg ├── test_settings.py └── tox.ini /.github/actions/test/action.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | description: 'runs a test matrix' 3 | inputs: 4 | python-version: 5 | required: true 6 | django-version: 7 | required: true 8 | 9 | runs: 10 | using: "composite" 11 | steps: 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: ${{ inputs.python-version }} 16 | 17 | - name: Install dependencies 18 | shell: sh 19 | run: | 20 | python -m pip install --upgrade pip 21 | if [[ ${{ inputs.django-version }} != 'main' ]]; then pip install --pre -q "Django>=${{ inputs.django-version }},<${{ inputs.django-version }}.99"; fi 22 | if [[ ${{ inputs.django-version }} == 'main' ]]; then pip install https://github.com/django/django/archive/main.tar.gz; fi 23 | pip install flake8 django-redis pymemcache 24 | 25 | - name: Test 26 | shell: sh 27 | run: | 28 | ./run.sh test 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | schedule: 10 | - cron: '21 7 * * 0' # run weekly on sundays 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: 19 | - '3.7' 20 | - '3.8' 21 | - '3.9' 22 | - '3.10' 23 | - '3.11' 24 | django: 25 | - '3.2' 26 | - '4.0' 27 | - '4.1' 28 | - '4.2' 29 | - '5.0' 30 | - 'main' 31 | exclude: 32 | - python-version: '3.7' 33 | django: '4.0' 34 | - python-version: '3.7' 35 | django: '4.1' 36 | - python-version: '3.7' 37 | django: '4.2' 38 | - python-version: '3.7' 39 | django: '5.0' 40 | - python-version: '3.7' 41 | django: 'main' 42 | - python-version: '3.8' 43 | django: '5.0' 44 | - python-version: '3.9' 45 | django: '5.0' 46 | - python-version: '3.11' 47 | django: '3.2' 48 | - python-version: '3.11' 49 | django: '4.0' 50 | - python-version: '3.12' 51 | django: '3.2' 52 | - python-version: '3.12' 53 | django: '4.0' 54 | - python-version: '3.12' 55 | django: '4.1' 56 | - python-version: '3.12' 57 | django: '4.2' 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | 62 | - uses: ./.github/actions/test 63 | with: 64 | python-version: ${{ matrix.python-version }} 65 | django-version: ${{ matrix.django }} 66 | 67 | lint: 68 | runs-on: ubuntu-latest 69 | 70 | steps: 71 | - uses: actions/checkout@v4 72 | 73 | - uses: actions/setup-python@v4 74 | with: 75 | python-version: '3.11' 76 | 77 | - name: install flake8 78 | run: pip install flake8 79 | 80 | - name: Lint with flake8 81 | run: | 82 | ./run.sh flake8 83 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '17 15 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | python-version: 15 | - '3.7' 16 | - '3.8' 17 | - '3.9' 18 | - '3.10' 19 | - '3.11' 20 | django: 21 | - '3.2' 22 | - '4.0' 23 | - '4.1' 24 | - '4.2' 25 | - '5.0' 26 | - 'main' 27 | exclude: 28 | - python-version: '3.7' 29 | django: '4.0' 30 | - python-version: '3.7' 31 | django: '4.1' 32 | - python-version: '3.7' 33 | django: '4.2' 34 | - python-version: '3.7' 35 | django: '5.0' 36 | - python-version: '3.7' 37 | django: 'main' 38 | - python-version: '3.8' 39 | django: '5.0' 40 | - python-version: '3.9' 41 | django: '5.0' 42 | - python-version: '3.11' 43 | django: '3.2' 44 | - python-version: '3.11' 45 | django: '4.0' 46 | - python-version: '3.12' 47 | django: '3.2' 48 | - python-version: '3.12' 49 | django: '4.0' 50 | - python-version: '3.12' 51 | django: '4.1' 52 | - python-version: '3.12' 53 | django: '4.2' 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | 58 | - uses: ./.github/actions/test 59 | with: 60 | python-version: ${{ matrix.python-version }} 61 | django-version: ${{ matrix.django }} 62 | 63 | release: 64 | runs-on: ubuntu-latest 65 | needs: [test] 66 | steps: 67 | 68 | - uses: actions/checkout@v4 69 | 70 | - name: Set up Python 71 | uses: actions/setup-python@v4 72 | with: 73 | python-version: 3.11 74 | 75 | - name: install dependencies 76 | run: | 77 | python -m pip install --upgrade pip 78 | pip install build twine 79 | 80 | - name: build 81 | run: ./run.sh build 82 | 83 | - name: check 84 | run: ./run.sh check 85 | 86 | - name: release 87 | uses: pypa/gh-action-pypi-publish@release/v1 88 | with: 89 | password: ${{ secrets.PYPI_DEPLOY_TOKEN }} 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | dist 4 | build 5 | *.egg-info 6 | docs/_build/* 7 | .tox/ 8 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | ========== 2 | Change Log 3 | ========== 4 | 5 | UNRELEASED 6 | ========== 7 | 8 | v4.1 9 | ==== 10 | 11 | Additions: 12 | ---------- 13 | 14 | - Add RATELIMIT_HASH_ALGORITHM setting (#282, #285) 15 | 16 | Minor changes: 17 | -------------- 18 | 19 | - Fixed links in docs (#277) 20 | - Test on Django 4.2 (#284) 21 | 22 | v4.0 23 | ==== 24 | 25 | Breaking changes: 26 | ----------------- 27 | 28 | - Renamed the package from ratelimit to django_ratelimit (#226) 29 | - Changed the default value of the decorator's block kwarg to True (#271) 30 | - Dropped support for Django versions < 3.2 (#263) 31 | - Dropped support for Python versions < 3.7 (#263, #254, #266) 32 | 33 | Additions: 34 | ---------- 35 | 36 | - Add RATELIMIT_IP_META_KEY setting (#218) 37 | - Add RATELIMIT_EXCEPTION_CLASS setting (#247) 38 | - Add a system check for cache configuration (#268) 39 | 40 | Minor changes: 41 | -------------- 42 | 43 | - Factor up _get_ip() logic into a single place (#218) 44 | - Exception on empty REMOTE_ADDR is clearer (#220) 45 | - Moved CI process to GitHub Actions (#219, #225) 46 | - Automated release process (#273) 47 | 48 | v3.0.1 49 | ====== 50 | 51 | Bug fixes 52 | --------- 53 | 54 | - Fix import path values for rate= argument (#206) 55 | 56 | v3.0 57 | ==== 58 | 59 | Breaking changes: 60 | ----------------- 61 | 62 | - Drop Python 2 support (#167) 63 | - Drop Django < 2.1 support (#167, #198) 64 | - @ratelimit no longer directly supports class methods, use 65 | @method_decorator 66 | - Drop RatelimitMixin in favor of @method_decorator 67 | - Moved is_ratelimited to ratelimit.core from ratelimit.utils 68 | - Moved ratelimit.utils.get_usage_count to ratelimit.core.get_usage 69 | 70 | Additions: 71 | ---------- 72 | 73 | - Made ratelimit.core.get_usage a documented, public method. 74 | - Add IP address masking (#178) 75 | - Add "Recipes" section to documentation 76 | 77 | Minor changes: 78 | -------------- 79 | 80 | - Update RatelimitMiddleware to modern style (#168) 81 | - Refactor is_ratelimited and get_usage so is_ratelimited is a thinner 82 | wrapper 83 | 84 | v2.0.0 85 | ====== 86 | 87 | - A number of docs fixes 88 | - Fail open when cache is unavailable 89 | - Drop support for Django 1.8, 1.9, and 1.10 90 | - Fix Django 2.0 compatibility and update documentation 91 | - Test Django 2.1 support 92 | 93 | v1.1.0 94 | ====== 95 | 96 | - Test against Django 1.11 and 2.0b 97 | - Fix #85, explicitly set cache expiration slightly longer than cache 98 | window. 99 | - Add Django version classifiers. 100 | 101 | v1.0.1 102 | ====== 103 | 104 | - Added Django 1.10 support. 105 | 106 | v1.0.0 107 | ====== 108 | 109 | - Allow requests through when cache backend is unavailable. 110 | - Add support for Django 1.9, drop support for Django <=1.7. 111 | - Fix several small documentation issues. 112 | - Fix support for missing headers. 113 | 114 | v0.6 115 | ==== 116 | 117 | - Fix CBV inheritance. 118 | - Better Django 1.8 support, fixing deprecation warnings and testing. 119 | - Clean up some out-of-date docs. 120 | - Fix counting behavior around increment and new cache keys. 121 | - Correctly pass `group` to callable `key`s. 122 | 123 | v0.5 124 | ==== 125 | 126 | - Rates are now counted in fixed—instead of sliding—windows, except for 127 | per-second limits. See the Upgrade Notes. 128 | - Mixin renamed to `RatelimitMixin` (lowercase `l`) for consistency. 129 | - Dramatic rewrite. 130 | - `ip`, `field`, and `keys` arguments replaced with `key`. 131 | - well-known "key" values support. 132 | - Custom callable rate functions. 133 | - Support for "not limited" rate. 134 | - Replaces ``skip_if`` argument. 135 | 136 | v0.4 137 | ==== 138 | 139 | - (Sort of) make @ratelimit decorators stack. 140 | - Add RateLimitMixin for CBVs. 141 | - Fixes for Python <2.7. 142 | - Clean up Travis and tox tests. 143 | 144 | v0.3 145 | ==== 146 | 147 | - Drop the 'Backend' concept. 148 | - Add settings: RATELIMIT_USE_CACHE and RATELIMIT_CACHE_PREFIX. 149 | - Allow custom key functions. 150 | - Tests with Django 1.4.x and 1.5.x. 151 | - Refactor to simplify tests and development requirements. 152 | 153 | v0.2 154 | ==== 155 | 156 | - Added real docs. 157 | - Fix unicode field values. 158 | - Add real tests. 159 | - Use the Ratelimited exception, RatelimitMiddleware, and 160 | RATELIMIT_VIEW setting. 161 | - Add RATELIMIT_ENABLE setting. 162 | - Add the skip_if argument. 163 | - Always add request.limited. 164 | 165 | v0.1 166 | ==== 167 | 168 | - Initial release. 169 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | 6 | For set up, tests, and code standards, see `the documentation`_. 7 | 8 | 9 | Client IP Address 10 | ================= 11 | 12 | Because this comes up frequently: 13 | 14 | I will not accept a pull request or issue attempting to handle client 15 | IP address when Django is behind a proxy. 16 | 17 | *Ratelimit is the wrong place for this.* There are more details in the 18 | `security chapter`_ of the documentation. 19 | 20 | 21 | .. _the documentation: https://django-ratelimit.readthedocs.org/en/latest/contributing.html 22 | .. _security chapter: https://django-ratelimit.readthedocs.org/en/latest/security.html#client-ip-address 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, James Socol 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG 2 | include LICENSE 3 | include MANIFEST.in 4 | include README.rst 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Django Ratelimit 3 | ================ 4 | 5 | Django Ratelimit provides a decorator to rate-limit views. Limiting can 6 | be based on IP address or a field in the request--either a GET or POST 7 | variable. 8 | 9 | .. image:: https://github.com/jsocol/django-ratelimit/workflows/test/badge.svg?branch=main 10 | :target: https://github.com/jsocol/django-ratelimit/actions 11 | 12 | :Code: https://github.com/jsocol/django-ratelimit 13 | :License: Apache Software License 2.0; see LICENSE file 14 | :Issues: https://github.com/jsocol/django-ratelimit/issues 15 | :Documentation: http://django-ratelimit.readthedocs.io/ 16 | -------------------------------------------------------------------------------- /django_ratelimit/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (4, 1, 0) 2 | __version__ = '.'.join(map(str, VERSION)) 3 | 4 | ALL = (None,) # Sentinel value for all HTTP methods. 5 | UNSAFE = ['DELETE', 'PATCH', 'POST', 'PUT'] 6 | -------------------------------------------------------------------------------- /django_ratelimit/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoRatelimitConfig(AppConfig): 5 | name = 'django_ratelimit' 6 | label = 'ratelimit' 7 | default = True 8 | 9 | def ready(self): 10 | from . import checks # noqa: F401 11 | -------------------------------------------------------------------------------- /django_ratelimit/checks.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core import checks 3 | 4 | SUPPORTED_CACHE_BACKENDS = [ 5 | 'django.core.cache.backends.memcached.PyMemcacheCache', 6 | 'django.core.cache.backends.memcached.PyLibMCCache', 7 | 'django_redis.cache.RedisCache', 8 | ] 9 | 10 | CACHE_FAKE = 'is not a real cache' 11 | CACHE_NOT_SHARED = 'is not a shared cache' 12 | CACHE_NOT_ATOMIC = 'does not support atomic increment' 13 | 14 | KNOWN_BROKEN_CACHE_BACKENDS = { 15 | 'django.core.cache.backends.dummy.DummyCache': CACHE_FAKE, 16 | 'django.core.cache.backends.locmem.LocMemCache': CACHE_NOT_SHARED, 17 | 'django.core.cache.backends.filebased.FileBasedCache': CACHE_NOT_ATOMIC, 18 | 'django.core.cache.backends.db.DatabaseCache': CACHE_NOT_ATOMIC, 19 | } 20 | 21 | 22 | @checks.register(checks.Tags.caches, 'django_ratelimit') 23 | def check_caches(app_configs, **kwargs): 24 | errors = [] 25 | cache_name = getattr(settings, 'RATELIMIT_USE_CACHE', 'default') 26 | caches = getattr(settings, 'CACHES', None) 27 | if caches is None: 28 | errors.append( 29 | checks.Error( 30 | 'CACHES is not defined, django_ratelimit will not work', 31 | hint='Configure a default cache using memcached or redis.', 32 | id='django_ratelimit.E001', 33 | ) 34 | ) 35 | return errors 36 | 37 | if cache_name not in caches: 38 | errors.append( 39 | checks.Error( 40 | f'RATELIMIT_USE_CACHE value "{cache_name}"" does not ' 41 | f'appear in CACHES dictionary', 42 | hint='RATELIMIT_USE_CACHE must be set to a valid cache', 43 | id='django_ratelimit.E002', 44 | ) 45 | ) 46 | return errors 47 | 48 | cache_config = caches[cache_name] 49 | backend = cache_config['BACKEND'] 50 | 51 | reason = KNOWN_BROKEN_CACHE_BACKENDS.get(backend, None) 52 | if reason is not None: 53 | errors.append( 54 | checks.Error( 55 | f'cache backend {backend} {reason}', 56 | hint='Use a supported cache backend', 57 | id='django_ratelimit.E003', 58 | ) 59 | ) 60 | 61 | if backend not in SUPPORTED_CACHE_BACKENDS: 62 | errors.append( 63 | checks.Warning( 64 | f'cache backend {backend} is not officially supported', 65 | id='django_ratelimit.W001', 66 | ) 67 | ) 68 | 69 | return errors 70 | -------------------------------------------------------------------------------- /django_ratelimit/core.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import functools 3 | import hashlib 4 | import re 5 | import socket 6 | import time 7 | import zlib 8 | 9 | from django.conf import settings 10 | from django.core.cache import caches 11 | from django.core.exceptions import ImproperlyConfigured 12 | from django.utils.module_loading import import_string 13 | 14 | from django_ratelimit import ALL, UNSAFE 15 | 16 | 17 | __all__ = ['is_ratelimited', 'get_usage'] 18 | 19 | _PERIODS = { 20 | 's': 1, 21 | 'm': 60, 22 | 'h': 60 * 60, 23 | 'd': 24 * 60 * 60, 24 | } 25 | 26 | # Extend the expiration time by a few seconds to avoid misses. 27 | EXPIRATION_FUDGE = 5 28 | 29 | 30 | def _get_ip(request): 31 | ip_meta = getattr(settings, 'RATELIMIT_IP_META_KEY', None) 32 | if not ip_meta: 33 | ip = request.META['REMOTE_ADDR'] 34 | if not ip: 35 | raise ImproperlyConfigured( 36 | 'IP address in REMOTE_ADDR is empty. This can happen when ' 37 | 'using a reverse proxy and connecting to the app server with ' 38 | 'Unix sockets. See the documentation for ' 39 | 'RATELIMIT_IP_META_KEY: https://bit.ly/3iIpy2x') 40 | elif callable(ip_meta): 41 | ip = ip_meta(request) 42 | elif isinstance(ip_meta, str) and '.' in ip_meta: 43 | ip_meta_fn = import_string(ip_meta) 44 | ip = ip_meta_fn(request) 45 | elif ip_meta in request.META: 46 | ip = request.META[ip_meta] 47 | else: 48 | raise ImproperlyConfigured( 49 | 'Could not get IP address from "%s"' % ip_meta) 50 | 51 | if ':' in ip: 52 | # IPv6 53 | mask = getattr(settings, 'RATELIMIT_IPV6_MASK', 64) 54 | else: 55 | # IPv4 56 | mask = getattr(settings, 'RATELIMIT_IPV4_MASK', 32) 57 | 58 | network = ipaddress.ip_network(f'{ip}/{mask}', strict=False) 59 | 60 | return str(network.network_address) 61 | 62 | 63 | def user_or_ip(request): 64 | if request.user.is_authenticated: 65 | return str(request.user.pk) 66 | return _get_ip(request) 67 | 68 | 69 | _SIMPLE_KEYS = { 70 | 'ip': lambda r: _get_ip(r), 71 | 'user': lambda r: str(r.user.pk), 72 | 'user_or_ip': user_or_ip, 73 | } 74 | 75 | 76 | def get_header(request, header): 77 | key = 'HTTP_' + header.replace('-', '_').upper() 78 | return request.META.get(key, '') 79 | 80 | 81 | _ACCESSOR_KEYS = { 82 | 'get': lambda r, k: r.GET.get(k, ''), 83 | 'post': lambda r, k: r.POST.get(k, ''), 84 | 'header': get_header, 85 | } 86 | 87 | 88 | def _method_match(request, method=ALL): 89 | if method == ALL: 90 | return True 91 | if not isinstance(method, (list, tuple)): 92 | method = [method] 93 | return request.method in [m.upper() for m in method] 94 | 95 | 96 | rate_re = re.compile(r'([\d]+)/([\d]*)([smhd])?') 97 | 98 | 99 | def _split_rate(rate): 100 | if isinstance(rate, tuple): 101 | return rate 102 | count, multi, period = rate_re.match(rate).groups() 103 | count = int(count) 104 | if not period: 105 | period = 's' 106 | seconds = _PERIODS[period.lower()] 107 | if multi: 108 | seconds = seconds * int(multi) 109 | return count, seconds 110 | 111 | 112 | def _get_window(value, period): 113 | """ 114 | Given a value, and time period return when the end of the current time 115 | period for rate evaluation is. 116 | """ 117 | ts = int(time.time()) 118 | if period == 1: 119 | return ts 120 | if not isinstance(value, bytes): 121 | value = value.encode('utf-8') 122 | # This logic determines either the last or current end of a time period. 123 | # Subtracting (ts % period) gives us the a consistent edge from the epoch. 124 | # We use (zlib.crc32(value) % period) to add a consistent jitter so that 125 | # all time periods don't end at the same time. 126 | w = ts - (ts % period) + (zlib.crc32(value) % period) 127 | if w < ts: 128 | return w + period 129 | return w 130 | 131 | 132 | def _make_cache_key(group, window, rate, value, methods): 133 | count, period = _split_rate(rate) 134 | safe_rate = '%d/%ds' % (count, period) 135 | parts = [group, safe_rate, value, str(window)] 136 | if methods is not None: 137 | if methods == ALL: 138 | methods = '' 139 | elif isinstance(methods, (list, tuple)): 140 | methods = ''.join(sorted([m.upper() for m in methods])) 141 | parts.append(methods) 142 | prefix = getattr(settings, 'RATELIMIT_CACHE_PREFIX', 'rl:') 143 | attr = getattr(settings, 'RATELIMIT_HASH_ALGORITHM', hashlib.sha256) 144 | algo_cls = (import_string(f'{attr}') 145 | if isinstance(attr, str) 146 | else attr 147 | ) 148 | return prefix + algo_cls(''.join(parts).encode('utf-8')).hexdigest() 149 | 150 | 151 | def is_ratelimited(request, group=None, fn=None, key=None, rate=None, 152 | method=ALL, increment=False): 153 | usage = get_usage(request, group, fn, key, rate, method, increment) 154 | if usage is None: 155 | return False 156 | 157 | return usage['should_limit'] 158 | 159 | 160 | def get_usage(request, group=None, fn=None, key=None, rate=None, method=ALL, 161 | increment=False): 162 | if group is None and fn is None: 163 | raise ImproperlyConfigured('get_usage must be called with either ' 164 | '`group` or `fn` arguments') 165 | 166 | if not getattr(settings, 'RATELIMIT_ENABLE', True): 167 | return None 168 | 169 | if not _method_match(request, method): 170 | return None 171 | 172 | if group is None: 173 | parts = [] 174 | 175 | if isinstance(fn, functools.partial): 176 | fn = fn.func 177 | 178 | # Django <2.1 doesn't use a partial. This is ugly and inelegant, but 179 | # throwing __qualname__ into the list below helps. 180 | if fn.__name__ == 'bound_func': 181 | fn = fn.__closure__[0].cell_contents 182 | 183 | if hasattr(fn, '__module__'): 184 | parts.append(fn.__module__) 185 | 186 | if hasattr(fn, '__self__'): 187 | parts.append(fn.__self__.__class__.__name__) 188 | 189 | parts.append(fn.__qualname__) 190 | group = '.'.join(parts) 191 | 192 | if callable(rate): 193 | rate = rate(group, request) 194 | elif isinstance(rate, str) and '.' in rate: 195 | ratefn = import_string(rate) 196 | rate = ratefn(group, request) 197 | 198 | if rate is None: 199 | return None 200 | limit, period = _split_rate(rate) 201 | if period <= 0: 202 | raise ImproperlyConfigured('Ratelimit period must be greater than 0') 203 | 204 | if not key: 205 | raise ImproperlyConfigured('Ratelimit key must be specified') 206 | if callable(key): 207 | value = key(group, request) 208 | elif key in _SIMPLE_KEYS: 209 | value = _SIMPLE_KEYS[key](request) 210 | elif ':' in key: 211 | accessor, k = key.split(':', 1) 212 | if accessor not in _ACCESSOR_KEYS: 213 | raise ImproperlyConfigured('Unknown ratelimit key: %s' % key) 214 | value = _ACCESSOR_KEYS[accessor](request, k) 215 | elif '.' in key: 216 | keyfn = import_string(key) 217 | value = keyfn(group, request) 218 | else: 219 | raise ImproperlyConfigured( 220 | 'Could not understand ratelimit key: %s' % key) 221 | 222 | window = _get_window(value, period) 223 | initial_value = 1 if increment else 0 224 | 225 | cache_name = getattr(settings, 'RATELIMIT_USE_CACHE', 'default') 226 | cache = caches[cache_name] 227 | cache_key = _make_cache_key(group, window, rate, value, method) 228 | 229 | count = None 230 | try: 231 | added = cache.add(cache_key, initial_value, period + EXPIRATION_FUDGE) 232 | except socket.gaierror: # for redis 233 | added = False 234 | if added: 235 | count = initial_value 236 | else: 237 | if increment: 238 | try: 239 | # python3-memcached will throw a ValueError if the server is 240 | # unavailable or (somehow) the key doesn't exist. redis, on the 241 | # other hand, simply returns None. 242 | count = cache.incr(cache_key) 243 | except ValueError: 244 | pass 245 | else: 246 | count = cache.get(cache_key, initial_value) 247 | 248 | # Getting or setting the count from the cache failed 249 | if count is None or count is False: 250 | if getattr(settings, 'RATELIMIT_FAIL_OPEN', False): 251 | return None 252 | return { 253 | 'count': 0, 254 | 'limit': 0, 255 | 'should_limit': True, 256 | 'time_left': -1, 257 | } 258 | 259 | time_left = window - int(time.time()) 260 | return { 261 | 'count': count, 262 | 'limit': limit, 263 | 'should_limit': count > limit, 264 | 'time_left': time_left, 265 | } 266 | 267 | 268 | is_ratelimited.ALL = ALL 269 | is_ratelimited.UNSAFE = UNSAFE 270 | get_usage.ALL = ALL 271 | get_usage.UNSAFE = UNSAFE 272 | -------------------------------------------------------------------------------- /django_ratelimit/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.conf import settings 4 | from django.utils.module_loading import import_string 5 | 6 | from django_ratelimit import ALL, UNSAFE 7 | from django_ratelimit.exceptions import Ratelimited 8 | from django_ratelimit.core import is_ratelimited 9 | 10 | 11 | __all__ = ['ratelimit'] 12 | 13 | 14 | def ratelimit(group=None, key=None, rate=None, method=ALL, block=True): 15 | def decorator(fn): 16 | @wraps(fn) 17 | def _wrapped(request, *args, **kw): 18 | old_limited = getattr(request, 'limited', False) 19 | ratelimited = is_ratelimited(request=request, group=group, fn=fn, 20 | key=key, rate=rate, method=method, 21 | increment=True) 22 | request.limited = ratelimited or old_limited 23 | if ratelimited and block: 24 | cls = getattr( 25 | settings, 'RATELIMIT_EXCEPTION_CLASS', Ratelimited) 26 | raise (import_string(cls) if isinstance(cls, str) else cls)() 27 | return fn(request, *args, **kw) 28 | return _wrapped 29 | return decorator 30 | 31 | 32 | ratelimit.ALL = ALL 33 | ratelimit.UNSAFE = UNSAFE 34 | -------------------------------------------------------------------------------- /django_ratelimit/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | 3 | 4 | class Ratelimited(PermissionDenied): 5 | pass 6 | -------------------------------------------------------------------------------- /django_ratelimit/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | from django_ratelimit.exceptions import Ratelimited 5 | 6 | 7 | class RatelimitMiddleware: 8 | def __init__(self, get_response): 9 | self.get_response = get_response 10 | 11 | def __call__(self, request): 12 | return self.get_response(request) 13 | 14 | def process_exception(self, request, exception): 15 | if not isinstance(exception, Ratelimited): 16 | return None 17 | view = import_string(settings.RATELIMIT_VIEW) 18 | return view(request, exception) 19 | -------------------------------------------------------------------------------- /django_ratelimit/models.py: -------------------------------------------------------------------------------- 1 | # This module intentionally left blank. 2 | -------------------------------------------------------------------------------- /django_ratelimit/tests.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from django.core.cache import cache, InvalidCacheBackendError 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.test import RequestFactory, TestCase 6 | from django.test.utils import override_settings 7 | from django.utils.decorators import method_decorator 8 | from django.views.generic import View 9 | 10 | from django_ratelimit.decorators import ratelimit 11 | from django_ratelimit.exceptions import Ratelimited 12 | from django_ratelimit.core import (get_usage, is_ratelimited, 13 | _split_rate, _get_ip) 14 | 15 | 16 | rf = RequestFactory() 17 | 18 | 19 | class MockUser: 20 | def __init__(self, authenticated=False): 21 | self.pk = 1 22 | self.is_authenticated = authenticated 23 | 24 | 25 | class RateParsingTests(TestCase): 26 | def test_simple(self): 27 | tests = ( 28 | ('100/s', (100, 1)), 29 | ('100/10s', (100, 10)), 30 | ('100/10', (100, 10)), 31 | ('100/m', (100, 60)), 32 | ('400/10m', (400, 600)), 33 | ('1000/h', (1000, 3600)), 34 | ('800/d', (800, 24 * 60 * 60)), 35 | ) 36 | 37 | for i, o in tests: 38 | assert o == _split_rate(i) 39 | 40 | 41 | def callable_rate(group, request): 42 | if request.user.is_authenticated: 43 | return None 44 | return (0, 1) 45 | 46 | 47 | def mykey(group, request): 48 | return request.META['REMOTE_ADDR'][::-1] 49 | 50 | 51 | class CustomRatelimitedException(Exception): 52 | pass 53 | 54 | 55 | class RatelimitTests(TestCase): 56 | def setUp(self): 57 | cache.clear() 58 | 59 | def test_no_key(self): 60 | @ratelimit(rate='1/m') 61 | def view(request): 62 | return True 63 | 64 | req = rf.get('/') 65 | with self.assertRaises(ImproperlyConfigured): 66 | view(req) 67 | 68 | def test_ip(self): 69 | @ratelimit(key='ip', rate='1/m', block=False) 70 | def view(request): 71 | return request.limited 72 | 73 | assert not view(rf.get('/')), 'First request works.' 74 | assert view(rf.get('/')), 'Second request is limited' 75 | 76 | def test_block(self): 77 | @ratelimit(key='ip', rate='1/m') 78 | def blocked(request): 79 | return request.limited 80 | 81 | assert not blocked(rf.get('/')), 'First request works.' 82 | with self.assertRaises(Ratelimited): 83 | blocked(rf.get('/')), 'Second request is blocked.' 84 | 85 | def test_ratelimit_custom_string_exception_class(self): 86 | @ratelimit(key='ip', rate='1/m') 87 | def view(request): 88 | return request.limited 89 | 90 | with self.settings( 91 | RATELIMIT_EXCEPTION_CLASS=( 92 | "django_ratelimit.tests.CustomRatelimitedException" 93 | ) 94 | ): 95 | req = rf.get("") 96 | assert not view(req) 97 | with self.assertRaises(CustomRatelimitedException): 98 | view(req) 99 | 100 | def test_ratelimit_custom_exception_class(self): 101 | @ratelimit(key='ip', rate='1/m') 102 | def view(request): 103 | return request.limited 104 | 105 | with self.settings( 106 | RATELIMIT_EXCEPTION_CLASS=CustomRatelimitedException 107 | ): 108 | req = rf.get("") 109 | assert not view(req) 110 | with self.assertRaises(CustomRatelimitedException): 111 | view(req) 112 | 113 | def test_method(self): 114 | @ratelimit(key='ip', method='POST', rate='1/m', group='a', block=False) 115 | def limit_post(request): 116 | return request.limited 117 | 118 | assert not limit_post(rf.post('/')), 'Do not limit first POST.' 119 | assert limit_post(rf.post('/')), 'Limit second POST.' 120 | assert not limit_post(rf.get('/')), 'Do not limit GET.' 121 | 122 | def test_unsafe_methods(self): 123 | @ratelimit(key='ip', method=ratelimit.UNSAFE, rate='0/m', block=False) 124 | def limit_unsafe(request): 125 | return request.limited 126 | 127 | assert not limit_unsafe(rf.get('/')) 128 | assert not limit_unsafe(rf.head('/')) 129 | assert not limit_unsafe(rf.options('/')) 130 | assert limit_unsafe(rf.delete('/')) 131 | assert limit_unsafe(rf.post('/')) 132 | assert limit_unsafe(rf.put('/')) 133 | assert limit_unsafe(rf.patch('/')) 134 | 135 | def test_key_get(self): 136 | @ratelimit(key='get:foo', rate='1/m', method='GET', block=False) 137 | def view(request): 138 | return request.limited 139 | 140 | assert not view(rf.get('/', {'foo': 'a'})) 141 | assert view(rf.get('/', {'foo': 'a'})) 142 | assert not view(rf.get('/', {'foo': 'b'})) 143 | assert view(rf.get('/', {'foo': 'b'})) 144 | 145 | def test_key_post(self): 146 | @ratelimit(key='post:foo', rate='1/m', block=False) 147 | def view(request): 148 | return request.limited 149 | 150 | assert not view(rf.post('/', {'foo': 'a'})) 151 | assert view(rf.post('/', {'foo': 'a'})) 152 | assert not view(rf.post('/', {'foo': 'b'})) 153 | assert view(rf.post('/', {'foo': 'b'})) 154 | 155 | def test_key_header(self): 156 | def _req(): 157 | req = rf.post('/') 158 | req.META['HTTP_X_REAL_IP'] = '1.2.3.4' 159 | return req 160 | 161 | @ratelimit(key='header:x-real-ip', rate='1/m', block=False) 162 | @ratelimit(key='header:x-missing-header', rate='1/m', block=False) 163 | def view(request): 164 | return request.limited 165 | 166 | assert not view(_req()) 167 | assert view(_req()) 168 | 169 | def test_rate(self): 170 | @ratelimit(key='ip', rate='2/m', block=False) 171 | def twice(request): 172 | return request.limited 173 | 174 | assert not twice(rf.post('/')), 'First request is not limited.' 175 | assert not twice(rf.post('/')), 'Second request is not limited.' 176 | assert twice(rf.post('/')), 'Third request is limited.' 177 | 178 | def test_zero_rate(self): 179 | @ratelimit(key='ip', rate='0/m', block=False) 180 | def never(request): 181 | return request.limited 182 | 183 | assert never(rf.post('/')) 184 | 185 | def test_none_rate(self): 186 | @ratelimit(key='ip', rate=None, block=False) 187 | def always(request): 188 | return request.limited 189 | 190 | assert not always(rf.post('/')) 191 | assert not always(rf.post('/')) 192 | assert not always(rf.post('/')) 193 | assert not always(rf.post('/')) 194 | assert not always(rf.post('/')) 195 | assert not always(rf.post('/')) 196 | assert not always(rf.post('/')) 197 | 198 | def test_callable_rate(self): 199 | def _req(auth): 200 | req = rf.post('/') 201 | req.user = MockUser(authenticated=auth) 202 | return req 203 | 204 | def get_rate(group, request): 205 | if request.user.is_authenticated: 206 | return (2, 60) 207 | return (1, 60) 208 | 209 | @ratelimit(key='user_or_ip', rate=get_rate, block=False) 210 | def view(request): 211 | return request.limited 212 | 213 | assert not view(_req(auth=False)) 214 | assert view(_req(auth=False)) 215 | assert not view(_req(auth=True)) 216 | assert not view(_req(auth=True)) 217 | assert view(_req(auth=True)) 218 | 219 | def test_callable_rate_none(self): 220 | def _req(never_limit=False): 221 | req = rf.post('/') 222 | req.never_limit = never_limit 223 | return req 224 | 225 | get_rate = lambda g, r: None if r.never_limit else '1/m' 226 | 227 | @ratelimit(key='ip', rate=get_rate, block=False) 228 | def view(request): 229 | return request.limited 230 | 231 | assert not view(_req()) 232 | assert view(_req()) 233 | assert not view(_req(never_limit=True)) 234 | assert not view(_req(never_limit=True)) 235 | 236 | def test_callable_rate_zero(self): 237 | def _req(auth): 238 | req = rf.post('/') 239 | req.user = MockUser(authenticated=auth) 240 | return req 241 | 242 | def get_rate(group, request): 243 | if request.user.is_authenticated: 244 | return '1/m' 245 | return '0/m' 246 | 247 | @ratelimit(key='ip', rate=get_rate, block=False) 248 | def view(request): 249 | return request.limited 250 | 251 | assert view(_req(auth=False)) 252 | assert not view(_req(auth=True)) 253 | assert view(_req(auth=True)) 254 | 255 | def test_callable_rate_import(self): 256 | def _req(auth): 257 | req = rf.post('/') 258 | req.user = MockUser(authenticated=auth) 259 | return req 260 | 261 | @ratelimit(key='user_or_ip', 262 | rate='django_ratelimit.tests.callable_rate', 263 | block=False) 264 | def view(request): 265 | return request.limited 266 | 267 | assert view(_req(auth=False)) 268 | assert not view(_req(auth=True)) 269 | 270 | def test_user_or_ip(self): 271 | """Allow custom functions to set cache keys.""" 272 | 273 | def _req(auth): 274 | req = rf.post('/') 275 | req.user = MockUser(authenticated=auth) 276 | return req 277 | 278 | @ratelimit(key='user_or_ip', rate='1/m', block=False) 279 | def view(request): 280 | return request.limited 281 | 282 | assert not view(_req(auth=False)) 283 | assert view(_req(auth=False)) 284 | 285 | auth = rf.post('/') 286 | auth.user = MockUser(authenticated=True) 287 | 288 | assert not view(_req(auth=True)) 289 | assert view(_req(auth=True)) 290 | 291 | def test_callable_key_path(self): 292 | @ratelimit(key='django_ratelimit.tests.mykey', rate='1/m', block=False) 293 | def view(request): 294 | return request.limited 295 | 296 | assert not view(rf.post('/')) 297 | assert view(rf.post('/')) 298 | 299 | def test_callable_key(self): 300 | @ratelimit(key=mykey, rate='1/m', block=False) 301 | def view(request): 302 | return request.limited 303 | 304 | assert not view(rf.post('/')) 305 | assert view(rf.post('/')) 306 | 307 | def test_stacked_decorator(self): 308 | """Allow @ratelimit to be stacked.""" 309 | # Put the shorter one first and make sure the second one doesn't 310 | # reset request.limited back to False. 311 | @ratelimit(rate='1/m', block=False, key=lambda x, y: 'min') 312 | @ratelimit(rate='10/d', block=False, key=lambda x, y: 'day') 313 | def view(request): 314 | return request.limited 315 | 316 | assert not view(rf.post('/')) 317 | assert view(rf.post('/')) 318 | 319 | def test_stacked_methods(self): 320 | """Different methods should result in different counts.""" 321 | @ratelimit(rate='1/m', key='ip', method='GET', block=False) 322 | @ratelimit(rate='1/m', key='ip', method='POST', block=False) 323 | def view(request): 324 | return request.limited 325 | 326 | assert not view(rf.get('/')) 327 | assert not view(rf.post('/')) 328 | assert view(rf.get('/')) 329 | assert view(rf.post('/')) 330 | 331 | def test_sorted_methods(self): 332 | """Order of the methods shouldn't matter.""" 333 | @ratelimit(rate='1/m', key='ip', method=['GET', 'POST'], 334 | group='a', block=False) 335 | def get_post(request): 336 | return request.limited 337 | 338 | @ratelimit(rate='1/m', key='ip', method=['POST', 'GET'], 339 | group='a', block=False) 340 | def post_get(request): 341 | return request.limited 342 | 343 | assert not get_post(rf.get('/')) 344 | assert post_get(rf.get('/')) 345 | 346 | def test_ratelimit_full_mask_v4(self): 347 | @ratelimit(rate='1/m', key='ip', block=False) 348 | def view(request): 349 | return request.limited 350 | 351 | with self.settings(RATELIMIT_IPV4_MASK=32): 352 | req = rf.get('/') 353 | req.META['REMOTE_ADDR'] = '10.1.1.1' 354 | assert not view(req) 355 | assert view(req) 356 | 357 | req = rf.get('/') 358 | req.META['REMOTE_ADDR'] = '10.1.1.2' 359 | assert not view(req) 360 | 361 | def test_ratelimit_full_mask_v6(self): 362 | @ratelimit(rate='1/m', key='ip', block=False) 363 | def view(request): 364 | return request.limited 365 | 366 | with self.settings(RATELIMIT_IPV6_MASK=128): 367 | req = rf.get('/') 368 | req.META['REMOTE_ADDR'] = '2001:db8::1000' 369 | assert not view(req) 370 | assert view(req) 371 | 372 | req = rf.get('/') 373 | req.META['REMOTE_ADDR'] = '2001:db8::1001' 374 | assert not view(req) 375 | 376 | def test_ratelimit_mask_v4(self): 377 | @ratelimit(rate='1/m', key='ip', block=False) 378 | def view(request): 379 | return request.limited 380 | 381 | with self.settings(RATELIMIT_IPV4_MASK=16): 382 | req = rf.get('/') 383 | req.META['REMOTE_ADDR'] = '10.1.1.1' 384 | assert not view(req) 385 | assert view(req) 386 | 387 | req = rf.get('/') 388 | req.META['REMOTE_ADDR'] = '10.1.0.1' 389 | assert view(req) 390 | 391 | req = rf.get('/') 392 | req.META['REMOTE_ADDR'] = '192.168.1.1' 393 | assert not view(req) 394 | 395 | def test_ratelimit_mask_v6(self): 396 | @ratelimit(rate='1/m', key='ip', block=False) 397 | def view(request): 398 | return request.limited 399 | 400 | with self.settings(RATELIMIT_IPV6_MASK=64): 401 | req = rf.get('/') 402 | req.META['REMOTE_ADDR'] = '2001:db8::1000' 403 | assert not view(req) 404 | assert view(req) 405 | 406 | req = rf.get('/') 407 | req.META['REMOTE_ADDR'] = '2001:db8::1001' 408 | assert view(req) 409 | 410 | req = rf.get('/') 411 | req.META['REMOTE_ADDR'] = '2001:db9::1000' 412 | assert not view(req) 413 | 414 | 415 | class FunctionsTests(TestCase): 416 | def setUp(self): 417 | cache.clear() 418 | 419 | def test_is_ratelimited(self): 420 | not_increment = partial(is_ratelimited, increment=False, rate='1/m', 421 | method=is_ratelimited.ALL, key='ip', group='a') 422 | 423 | # Does not increment. Count still 0. Does not rate limit 424 | # because 0 < 1. 425 | assert not not_increment(rf.get('/')) 426 | 427 | # Does not increment. Count still 1. Not limited because 1 > 1 428 | # is false. 429 | assert not not_increment(rf.get('/')) 430 | 431 | def test_is_ratelimited_increment(self): 432 | do_increment = partial(is_ratelimited, increment=True, rate='1/m', 433 | method=is_ratelimited.ALL, key='ip', group='a') 434 | 435 | # Increments. Does not rate limit because 0 < 1. Count now 1. 436 | assert not do_increment(rf.get('/')) 437 | 438 | # Count = 2, 2 > 1. 439 | assert do_increment(rf.get('/')) 440 | 441 | def test_get_usage(self): 442 | _get_usage = partial(get_usage, method=get_usage.ALL, key='ip', 443 | rate='1/m', group='a') 444 | usage = _get_usage(rf.get('/')) 445 | 446 | self.assertEqual(usage['count'], 0) 447 | self.assertEqual(usage['limit'], 1) 448 | self.assertLessEqual(usage['time_left'], 60) 449 | self.assertFalse(usage['should_limit']) 450 | 451 | def test_get_usage_increment(self): 452 | _get_usage = partial(get_usage, method=get_usage.ALL, key='ip', 453 | rate='1/m', group='a', increment=True) 454 | _get_usage(rf.get('/')) 455 | usage = _get_usage(rf.get('/')) 456 | 457 | self.assertEqual(usage['count'], 2) 458 | self.assertEqual(usage['limit'], 1) 459 | self.assertLessEqual(usage['time_left'], 60) 460 | self.assertTrue(usage['should_limit']) 461 | 462 | def test_not_increment_after_increment(self): 463 | _get_usage = partial(get_usage, method=get_usage.ALL, key='ip', 464 | rate='1/m', group='a') 465 | _get_usage(rf.get('/'), increment=True) 466 | _get_usage(rf.get('/'), increment=True) 467 | usage = _get_usage(rf.get('/')) 468 | 469 | self.assertEqual(usage['count'], 2) 470 | self.assertEqual(usage['limit'], 1) 471 | self.assertLessEqual(usage['time_left'], 60) 472 | self.assertTrue(usage['should_limit']) 473 | 474 | def test_get_usage_called_without_group_or_fn(self): 475 | with self.assertRaises(ImproperlyConfigured): 476 | get_usage(rf.get('/'), key='ip') 477 | 478 | 479 | class RatelimitCBVTests(TestCase): 480 | def setUp(self): 481 | cache.clear() 482 | 483 | def test_method_decorator(self): 484 | class TestView(View): 485 | @method_decorator(ratelimit(key='ip', rate='1/m', block=False)) 486 | def post(self, request): 487 | return request.limited 488 | 489 | view = TestView.as_view() 490 | 491 | assert not view(rf.post('/')) 492 | assert view(rf.post('/')) 493 | 494 | def test_class_decorator(self): 495 | @method_decorator(ratelimit(key='ip', rate='1/m', block=False), 496 | name='get') 497 | class TestView(View): 498 | def get(self, request): 499 | return request.limited 500 | 501 | view = TestView.as_view() 502 | 503 | assert not view(rf.get('/')) 504 | assert view(rf.get('/')) 505 | 506 | def test_wrap_view(self): 507 | class TestView(View): 508 | def get(self, request): 509 | return request.limited 510 | 511 | view = TestView.as_view() 512 | wrapped = ratelimit(key='ip', rate='1/m', block=False)(view) 513 | 514 | assert not wrapped(rf.get('/')) 515 | assert wrapped(rf.get('/')) 516 | 517 | def test_methods_counted_separately(self): 518 | class TestView(View): 519 | @method_decorator(ratelimit(key='ip', rate='1/m', 520 | method='GET', block=False)) 521 | def get(self, request): 522 | return request.limited 523 | 524 | @method_decorator(ratelimit(key='ip', rate='1/m', 525 | method='POST', block=False)) 526 | def post(self, request): 527 | return request.limited 528 | 529 | view = TestView.as_view() 530 | 531 | assert not view(rf.get('/')) 532 | assert view(rf.get('/')) 533 | assert not view(rf.post('/')) 534 | 535 | def test_views_counted_separately(self): 536 | class TestView(View): 537 | @method_decorator(ratelimit(key='ip', rate='1/m', 538 | method='GET', block=False)) 539 | def get(self, request): 540 | return request.limited 541 | 542 | class AnotherTestView(View): 543 | @method_decorator(ratelimit(key='ip', rate='1/m', 544 | method='GET', block=False)) 545 | def get(self, request): 546 | return request.limited 547 | 548 | test_view = TestView.as_view() 549 | another_view = AnotherTestView.as_view() 550 | 551 | assert not test_view(rf.get('/')) 552 | assert test_view(rf.get('/')) 553 | assert not another_view(rf.get('/')) 554 | 555 | 556 | class CacheFailTests(TestCase): 557 | @override_settings(RATELIMIT_USE_CACHE='fake-cache') 558 | def test_bad_cache(self): 559 | @ratelimit(key='ip', rate='1/m', block=False) 560 | def view(request): 561 | return request.limited 562 | 563 | with self.assertRaises(InvalidCacheBackendError): 564 | view(rf.post('/')) 565 | 566 | @override_settings(RATELIMIT_USE_CACHE='connection-errors') 567 | def test_limit_on_cache_connection_error(self): 568 | @ratelimit(key='ip', rate='10/m', block=False) 569 | def view(request): 570 | return request.limited 571 | 572 | assert view(rf.post('/')) 573 | 574 | @override_settings(RATELIMIT_USE_CACHE='connection-errors', 575 | RATELIMIT_FAIL_OPEN=True) 576 | def test_fail_open_setting(self): 577 | @ratelimit(key='ip', rate='1/m', block=False) 578 | def view(request): 579 | return request.limited 580 | 581 | assert not view(rf.get('/')) 582 | assert not view(rf.get('/')) 583 | 584 | @override_settings(RATELIMIT_USE_CACHE='connection-errors') 585 | def test_is_ratelimited_cache_connection_error_without_increment(self): 586 | def not_increment(request): 587 | return is_ratelimited(request, increment=False, 588 | method=is_ratelimited.ALL, key='ip', 589 | rate='1/m', group='a') 590 | 591 | assert not not_increment(rf.get('/')) 592 | assert not not_increment(rf.get('/')) 593 | 594 | @override_settings(RATELIMIT_USE_CACHE='connection-errors') 595 | def test_is_ratelimited_cache_connection_error_with_increment(self): 596 | def do_increment(request): 597 | return is_ratelimited(request, increment=True, 598 | method=is_ratelimited.ALL, key='ip', 599 | rate='1/m', group='a') 600 | 601 | assert do_increment(rf.get('/')) 602 | assert do_increment(rf.get('/')) 603 | 604 | @override_settings(RATELIMIT_USE_CACHE='connection-errors-redis') 605 | def test_is_ratelimited_cache_connection_error_with_increment_redis(self): 606 | def do_increment(request): 607 | return is_ratelimited(request, increment=True, 608 | method=is_ratelimited.ALL, key='ip', 609 | rate='1/m', group='a') 610 | 611 | assert do_increment(rf.get('/')) 612 | assert do_increment(rf.get('/')) 613 | 614 | @override_settings(RATELIMIT_USE_CACHE='instant-expiration') 615 | def test_cache_timeout(self): 616 | @ratelimit(key='ip', rate='1/m') 617 | def view(request): 618 | return True 619 | 620 | assert view(rf.get('/')) 621 | assert view(rf.get('/')) 622 | 623 | 624 | def my_ip(req): 625 | return req.META['MY_THING'] 626 | 627 | 628 | class IpMetaTests(TestCase): 629 | def test_default(self): 630 | req = rf.get('/') 631 | req.META['REMOTE_ADDR'] = '1.2.3.4' 632 | 633 | assert '1.2.3.4' == _get_ip(req) 634 | 635 | @override_settings(RATELIMIT_IP_META_KEY='fake') 636 | def test_bad_config(self): 637 | req = rf.get('/') 638 | req.META['REMOTE_ADDR'] = '1.2.3.4' 639 | 640 | with self.assertRaises(ImproperlyConfigured): 641 | _get_ip(req) 642 | 643 | @override_settings(RATELIMIT_IP_META_KEY='HTTP_X_CLIENT_IP') 644 | def test_alternate_header(self): 645 | req = rf.get('/') 646 | req.META['REMOTE_ADDR'] = '1.2.3.4' 647 | req.META['HTTP_X_CLIENT_IP'] = '5.6.7.8' 648 | 649 | assert '5.6.7.8' == _get_ip(req) 650 | 651 | @override_settings(RATELIMIT_IP_META_KEY='django_ratelimit.tests.my_ip') 652 | def test_path_to_ip_key_callable(self): 653 | req = rf.get('/') 654 | req.META['REMOTE_ADDR'] = '1.2.3.4' 655 | req.META['MY_THING'] = '5.6.7.8' 656 | 657 | assert '5.6.7.8' == _get_ip(req) 658 | 659 | @override_settings(RATELIMIT_IP_META_KEY=my_ip) 660 | def test_callable_ip_key(self): 661 | req = rf.get('/') 662 | req.META['REMOTE_ADDR'] = '1.2.3.4' 663 | req.META['MY_THING'] = '5.6.7.8' 664 | 665 | assert '5.6.7.8' == _get_ip(req) 666 | 667 | def test_empty_ip(self): 668 | req = rf.get('/') 669 | req.META['REMOTE_ADDR'] = '' 670 | 671 | with self.assertRaises(ImproperlyConfigured): 672 | _get_ip(req) 673 | -------------------------------------------------------------------------------- /docs/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 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoRatelimit.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoRatelimit.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoRatelimit" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoRatelimit" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django Ratelimit documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jan 4 15:55:31 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = [] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'Django Ratelimit' 44 | copyright = u'2022, James Socol' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '4.1' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '4.1.0' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | highlight_language = 'python' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = 'default' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | #html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | #html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | #html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | #html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | #html_static_path = [] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | #html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | #html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | #html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | #html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | #html_domain_indices = True 143 | 144 | # If false, no index is generated. 145 | #html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | #html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | #html_show_sourcelink = True 152 | 153 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 154 | #html_show_sphinx = True 155 | 156 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 157 | #html_show_copyright = True 158 | 159 | # If true, an OpenSearch description file will be output, and all pages will 160 | # contain a tag referring to it. The value of this option must be the 161 | # base URL from which the finished HTML is served. 162 | #html_use_opensearch = '' 163 | 164 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 165 | #html_file_suffix = None 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = 'DjangoRatelimitdoc' 169 | 170 | 171 | # -- Options for LaTeX output -------------------------------------------------- 172 | 173 | latex_elements = { 174 | # The paper size ('letterpaper' or 'a4paper'). 175 | #'papersize': 'letterpaper', 176 | 177 | # The font size ('10pt', '11pt' or '12pt'). 178 | #'pointsize': '10pt', 179 | 180 | # Additional stuff for the LaTeX preamble. 181 | #'preamble': '', 182 | } 183 | 184 | # Grouping the document tree into LaTeX files. List of tuples 185 | # (source start file, target name, title, author, documentclass [howto/manual]). 186 | latex_documents = [ 187 | ('index', 'DjangoRatelimit.tex', u'Django Ratelimit Documentation', 188 | u'James Socol', 'manual'), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | #latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | #latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | #latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | #latex_show_urls = False 204 | 205 | # Documents to append as an appendix to all manuals. 206 | #latex_appendices = [] 207 | 208 | # If false, no module index is generated. 209 | #latex_domain_indices = True 210 | 211 | 212 | # -- Options for manual page output -------------------------------------------- 213 | 214 | # One entry per manual page. List of tuples 215 | # (source start file, name, description, authors, manual section). 216 | man_pages = [ 217 | ('index', 'djangoratelimit', u'Django Ratelimit Documentation', 218 | [u'James Socol'], 1) 219 | ] 220 | 221 | # If true, show URL addresses after external links. 222 | #man_show_urls = False 223 | 224 | 225 | # -- Options for Texinfo output ------------------------------------------------ 226 | 227 | # Grouping the document tree into Texinfo files. List of tuples 228 | # (source start file, target name, title, author, 229 | # dir menu entry, description, category) 230 | texinfo_documents = [ 231 | ('index', 'DjangoRatelimit', u'Django Ratelimit Documentation', 232 | u'James Socol', 'DjangoRatelimit', 'One line description of project.', 233 | 'Miscellaneous'), 234 | ] 235 | 236 | # Documents to append as an appendix to all manuals. 237 | #texinfo_appendices = [] 238 | 239 | # If false, no module index is generated. 240 | #texinfo_domain_indices = True 241 | 242 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 243 | #texinfo_show_urls = 'footnote' 244 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. _contributing-chapter: 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | 8 | Set Up 9 | ====== 10 | 11 | Create a virtualenv_ and install Django with pip_: 12 | 13 | .. code-block:: sh 14 | 15 | $ pip install Django 16 | 17 | 18 | Running the Tests 19 | ================= 20 | 21 | Running the tests is as easy as: 22 | 23 | .. code-block:: sh 24 | 25 | $ ./run.sh test 26 | 27 | You may also run the test on multiple versions of Django using tox. 28 | 29 | - First install tox: 30 | 31 | .. code-block:: sh 32 | 33 | $ pip install tox 34 | 35 | - Then run the tests with tox: 36 | 37 | .. code-block:: sh 38 | 39 | $ tox 40 | 41 | 42 | Code Standards 43 | ============== 44 | 45 | I ask two things for pull requests. 46 | 47 | * The flake8_ tool must not report any violations. 48 | * All tests, including new tests where appropriate, must pass. 49 | 50 | 51 | .. _virtualenv: http://www.virtualenv.org/en/latest/ 52 | .. _pip: http://www.pip-installer.org/en/latest/ 53 | .. _flake8: https://pypi.python.org/pypi/flake8 54 | -------------------------------------------------------------------------------- /docs/cookbook/429.rst: -------------------------------------------------------------------------------- 1 | .. _recipe-429: 2 | 3 | ================================= 4 | Sending ``429 Too Many Requests`` 5 | ================================= 6 | 7 | `RFC 6585`_ introduced a status code specific to rate-limiting 8 | situations: `HTTP 429 Too Many Requests`_. Here's one way to send this 9 | status with Django Ratelimit. 10 | 11 | 12 | Create a custom error view 13 | ========================== 14 | 15 | First, create a view that returns the correct type of response (e.g. 16 | content-type, shape, information, etc) for your application. For 17 | example, a JSON API may return something like ``{"error": 18 | "ratelimited"}``, while other applications may return XML, HTML, etc, as 19 | needed. Or you may need to decide based on the type of request. Set the 20 | status code of the response to 429. 21 | 22 | .. code-block:: python 23 | 24 | # myapp/views.py 25 | def ratelimited_error(request, exception): 26 | # e.g. to return HTML 27 | return render(request, 'ratelimited.html', status=429) 28 | 29 | def ratelimited_error(request, exception): 30 | # or other types: 31 | return JsonResponse({'error': 'ratelimited'}, status=429) 32 | 33 | In your app's settings, install the ``RatelimitMiddleware`` 34 | :ref:`middleware ` toward the bottom of the list. You 35 | must define ``RATELIMIT_VIEW`` as a dotted-path to your error view: 36 | 37 | .. code-block:: python 38 | 39 | MIDDLEWARE = ( 40 | # ... toward the bottom ... 41 | 'django_ratelimit.middleware.RatelimitMiddleware', 42 | # ... 43 | ) 44 | 45 | RATELIMIT_VIEW = 'myapp.views.ratelimited_error' 46 | 47 | 48 | That's it! If you already have :ref:`the decorator ` 49 | installed, you're good to go. Otherwise, you'll need to install it in 50 | order to trigger the error view. 51 | 52 | 53 | Check the exception type in ``handler403`` 54 | ========================================== 55 | 56 | Alternatively, if you already have a ``handler403`` view defined, you 57 | can check the exception type and return a specific status code: 58 | 59 | .. code-block:: python 60 | 61 | from django_ratelimit.exceptions import Ratelimited 62 | 63 | def my_403_handler(request, exception): 64 | if isinstance(exception, Ratelimited): 65 | return render(request, '429.html', status=429) 66 | return render(request, '403.html', status=403) 67 | 68 | 69 | Context 70 | ======= 71 | 72 | **Why doesn't Django Ratelimit handle this itself?** 73 | 74 | There are a couple of main reasons. The first is that Django has no 75 | built-in concept of a ratelimit exception, but it does have 76 | ``PermissionDenied``. When a view throws a ``PermissionDenied`` 77 | exception, Django has built-in facilities for handling it as a client 78 | error (it returns an HTTP 403) instead of a server error (i.e. a 5xx 79 | status code). 80 | 81 | The ``Ratelimited`` exception extends ``PermissionDenied`` so that, if 82 | nothing else, there should already be a way to make sure the application 83 | is sending a 4xx status code—even if it's not the most-correct status 84 | code available. ``Ratelimited`` should not be treated as a server error 85 | because the server is working correctly. (NB: That also means that the 86 | typical "error"-level logging is not invoked.) There is no way to 87 | convince the built-in handler to send any status besides 403. 88 | 89 | Furthermore, it's impossible for Django Ratelimit to provide a default 90 | view that does a better job guessing at the appropriate response type 91 | than Django's built-in ``PermissionDenied`` view already does. We could 92 | include a default ``429.html`` template with as little information as 93 | Django's built-in ``403.html``, but it would only be slightly more 94 | correct. 95 | 96 | The correct response for your users will depend on your application. 97 | This means creating the right content-type (e.g. JSON, XML, HTML, etc) 98 | and content (whether it's an API error response or a human-readable 99 | one). Django Ratelimit can't guess that, so it's up to you to define. 100 | 101 | Finally, a small historical note. Django Ratelimit actually predates RFC 102 | 6585 by about a year. At the time, 403 was as common as any status for 103 | ratelimit situations. Others were creating custom statuses, like 104 | Twitter's ``420 Enhance Your Calm``. 105 | 106 | .. _RFC 6585: https://tools.ietf.org/html/rfc6585 107 | .. _HTTP 429 Too Many Requests: https://tools.ietf.org/html/rfc6585#section-4 108 | -------------------------------------------------------------------------------- /docs/cookbook/index.rst: -------------------------------------------------------------------------------- 1 | .. _cookbook: 2 | 3 | ======== 4 | Cookbook 5 | ======== 6 | 7 | This section includes suggestions for common patterns that don't make 8 | sense to include in the main library because they depend on too many 9 | specific about consuming applications. These solutions may be close to 10 | copy-pastable, but in generally they are more directional and are 11 | provided under the same license as all other code in this repository. 12 | 13 | 14 | Recipes 15 | ======= 16 | 17 | .. toctree:: 18 | :maxdepth: 1 19 | 20 | 429 21 | per-user 22 | -------------------------------------------------------------------------------- /docs/cookbook/per-user.rst: -------------------------------------------------------------------------------- 1 | .. _recipe-per-user: 2 | 3 | =================== 4 | Per-User Ratelimits 5 | =================== 6 | 7 | One common business strategy includes adjusting rate limits for 8 | different types of users, or even different individual users for 9 | enterprise sales. With :ref:`callable rates ` it is 10 | possible to implement per-user or per-group rate limits. Here is one 11 | example of how to implement per-user rates. 12 | 13 | 14 | A ``Ratelimit`` model 15 | ===================== 16 | 17 | This example leverages the database to store per-user rate limits. Keep 18 | in mind the additional load this may place on your application's 19 | database—which may very well be the resource you intend to protect. 20 | Consider caching these types of queries. 21 | 22 | .. code-block:: python 23 | 24 | # myapp/models.py 25 | class Ratelimit(models.Model): 26 | group = models.CharField(db_index=True) 27 | user = models.ForeignKey(null=True) # One option for "default" 28 | rate = models.CharField() 29 | 30 | @classmethod 31 | def get(cls, group, user=None): 32 | # use cache if possible 33 | try: 34 | return cls.objects.get(group=group, user=user) 35 | except cls.DoesNotExist: 36 | return cls.objects.get(group=group, user=None) 37 | 38 | # myapp/ratelimits.py 39 | from myapp.models import Ratelimit 40 | def per_user(group, request): 41 | if request.user.is_authenticated: 42 | return Ratelimit.get(group, request.user) 43 | return Ratelimit.get(group) 44 | 45 | # myapp/views.py 46 | @login_required 47 | @ratelimit(group='search', key='user', 48 | rate='myapp.ratelimits.per_user') 49 | def search_view(request): 50 | # ... 51 | 52 | It would be important to consider how to handle defaults, cases where 53 | the rate is not defined in the database, or the group is new, etc. It 54 | would also be important to consider the performance impact of executing 55 | such a query as part of the rate limiting process and consider how to 56 | store this data. 57 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Django Ratelimit 3 | ================ 4 | 5 | Project 6 | ======= 7 | 8 | **Django Ratelimit** is a ratelimiting decorator for Django views, 9 | storing rate data in the configured `Django cache backend 10 | `__. 11 | 12 | .. image:: https://travis-ci.org/jsocol/django-ratelimit.png?branch=master 13 | :target: https://travis-ci.org/jsocol/django-ratelimit 14 | 15 | :Code: https://github.com/jsocol/django-ratelimit 16 | :License: Apache Software License 17 | :Issues: https://github.com/jsocol/django-ratelimit/issues 18 | :Documentation: http://django-ratelimit.readthedocs.org/ 19 | 20 | 21 | Quickstart 22 | ========== 23 | 24 | .. warning:: 25 | `django_ratelimit` requires a Django cache backend that supports `atomic 26 | increment`_ operations. The Memcached and Redis backends do, but the 27 | database backend does not. More information can be found in 28 | :ref:`Installation ` 29 | 30 | Install: 31 | 32 | .. code-block:: shell 33 | 34 | pip install django-ratelimit 35 | 36 | 37 | Use as a decorator in ``views.py``: 38 | 39 | .. code-block:: python 40 | 41 | from django_ratelimit.decorators import ratelimit 42 | 43 | @ratelimit(key='ip') 44 | def myview(request): 45 | # ... 46 | 47 | @ratelimit(key='ip', rate='100/h') 48 | def secondview(request): 49 | # ... 50 | 51 | Before activating django-ratelimit, you should ensure that your cache 52 | backend is setup to be both persistent and work across multiple 53 | deployment worker instances (for instance UWSGI workers). Read more in 54 | the Django docs on `caching 55 | `__. 56 | 57 | .. _PyPI: http://pypi.python.org/pypi/django-ratelimit 58 | .. _atomic increment: https://docs.djangoproject.com/en/4.1/topics/cache/#django.core.caches.cache.incr 59 | 60 | 61 | Contents 62 | ======== 63 | 64 | .. toctree:: 65 | :maxdepth: 2 66 | 67 | installation 68 | settings 69 | usage 70 | keys 71 | rates 72 | security 73 | upgrading 74 | contributing 75 | cookbook/index 76 | 77 | 78 | Indices and tables 79 | ================== 80 | 81 | * :ref:`genindex` 82 | * :ref:`modindex` 83 | * :ref:`search` 84 | 85 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation-chapter: 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | .. _installation-cache: 8 | 9 | Create or use a compatible cache 10 | ================================ 11 | 12 | ``django_ratelimit`` requires a cache backend that 13 | 14 | #. Is shared across any worker threads, processes, and application servers. 15 | Cache backends that use sharding can be used to help scale this. 16 | #. Implements *atomic increment*. 17 | 18 | `Redis`_ and `Memcached`_ backends have these features and are officially supported. 19 | Backends like `local memory`_ and `filesystem`_ are not shared across processes 20 | or servers. Notably, the `database`_ backend does **not** support atomic 21 | increments. 22 | 23 | If you do not have a compatible cache backend, you'll need to set one up, which 24 | is out of scope of this document, and then add it to the ``CACHES`` dictionary 25 | in `settings`_. 26 | 27 | .. warning:: 28 | Without atomic increment operations, ``django_ratelimit`` will appear to 29 | work, but there is a race condition between reading and writing usage count 30 | data that can result in undercounting usage and permitting more traffic than 31 | intended. 32 | 33 | .. _Redis: https://docs.djangoproject.com/en/4.1/topics/cache/#redis 34 | .. _Memcached: https://docs.djangoproject.com/en/4.1/topics/cache/#memcached 35 | .. _local memory: https://docs.djangoproject.com/en/4.1/topics/cache/#local-memory-caching 36 | .. _filesystem: https://docs.djangoproject.com/en/4.1/topics/cache/#filesystem-caching 37 | .. _database: https://docs.djangoproject.com/en/4.1/topics/cache/#database-caching 38 | .. _settings: https://docs.djangoproject.com/en/4.1/ref/settings/#std-setting-CACHES 39 | 40 | 41 | .. _installation-settings: 42 | 43 | Configuration 44 | ============= 45 | 46 | ``django_ratelimit`` has reasonable defaults, and if your ``default`` cache is 47 | compatible, and your application is not behind a reverse proxy, you can skip 48 | this section. 49 | 50 | For a complete list of configuration options, see :ref:`Settings 51 | `. 52 | 53 | .. _installation-settings-cache: 54 | 55 | Cache Settings 56 | -------------- 57 | 58 | If you have added an additional ``CACHES`` entry for ratelimiting, you'll need 59 | to tell ``django_ratelimit`` to use this via the ``RATELIMIT_USE_CACHE`` 60 | setting: 61 | 62 | .. code-block:: python 63 | 64 | # your_apps_settings.py 65 | CACHES = { 66 | 'default': {}, 67 | 'cache-for-ratelimiting': {}, 68 | } 69 | 70 | RATELIMIT_USE_CACHE = 'cache-for-ratelimiting' 71 | 72 | .. _installation-settings-ip: 73 | 74 | Reverse Proxies and Client IP Address 75 | ------------------------------------- 76 | 77 | ``django_ratelimit`` reads client IP address from 78 | ``request.META['REMOTE_ADDR']``. If your application is running behind a 79 | reverse proxy such as nginx or HAProxy, you will need to take steps to ensure 80 | you have access to the correct client IP address, rather than the address of 81 | the proxy. 82 | 83 | There are security risks for libraries to *assume* how your network is set up, 84 | and so ``django_ratelimit`` does not provide any built-in tools to address 85 | this. However, the :ref:`Security chapter ` does provide 86 | suggestions on how to approach this. 87 | 88 | 89 | .. _installation-enforcing: 90 | 91 | Enforcing Ratelimits 92 | ==================== 93 | 94 | The most common way to enforce ratelimits is via the ``ratelimit`` 95 | :ref:`decorator `: 96 | 97 | .. code-block:: python 98 | 99 | from django_ratelimit.decorators import ratelimit 100 | 101 | @ratelimit(key='user_or_ip', rate='10/m') 102 | def myview(request): 103 | # limited to 10 req/minute for a given user or client IP 104 | 105 | # or on class methods 106 | class MyView(View): 107 | @method_decorator(ratelimit(key='user_or_ip', rate='1/s')) 108 | def get(self, request): 109 | # limited to 1 req/second 110 | -------------------------------------------------------------------------------- /docs/keys.rst: -------------------------------------------------------------------------------- 1 | .. _keys-chapter: 2 | 3 | ============== 4 | Ratelimit Keys 5 | ============== 6 | 7 | The ``key=`` argument to the decorator takes either a string or a 8 | callable. 9 | 10 | 11 | .. _keys-common: 12 | 13 | Common keys 14 | =========== 15 | 16 | The following string values for ``key=`` provide shortcuts to commonly 17 | used ratelimit keys: 18 | 19 | - ``'ip'`` - Use the request IP address (i.e. 20 | ``request.META['REMOTE_ADDR']``) 21 | 22 | .. note:: 23 | If you are using a reverse proxy, make sure this value is correct 24 | or use an appropriate ``header:`` value. See the :ref:`security 25 | ` notes. 26 | - ``'get:X'`` - Use the value of ``request.GET.get('X', '')``. 27 | - ``'post:X'`` - Use the value of ``request.POST.get('X', '')``. 28 | - ``'header:x-x'`` - Use the value of 29 | ``request.META.get('HTTP_X_X', '')``. 30 | 31 | .. note:: 32 | The value right of the colon will be translated to all-caps and 33 | any dashes will be replaced with underscores, e.g.: x-client-ip 34 | => X_CLIENT_IP. 35 | - ``'user'`` - Use an appropriate value from ``request.user``. Do not use 36 | with unauthenticated users. 37 | - ``'user_or_ip'`` - Use an appropriate value from ``request.user`` if 38 | the user is authenticated, otherwise use 39 | ``request.META['REMOTE_ADDR']`` (see the note above about reverse 40 | proxies). 41 | 42 | .. note:: 43 | 44 | Missing headers, GET, and POST values will all be treated as empty 45 | strings, and ratelimited in the same bucket. 46 | 47 | .. warning:: 48 | 49 | Using user-supplied data, like data from GET and POST or headers 50 | directly from the User-Agent can allow users to trivially opt out of 51 | ratelimiting. See the note in :ref:`the security chapter 52 | `. 53 | 54 | 55 | .. _keys-strings: 56 | 57 | String values 58 | ============= 59 | 60 | Other string values not from the list above will be treated as the 61 | dotted Python path to a callable. See :ref:`below ` for 62 | more on callables. 63 | 64 | 65 | .. _keys-callable: 66 | 67 | Callable values 68 | =============== 69 | 70 | .. versionadded:: 0.3 71 | .. versionchanged:: 0.5 72 | Added support for python path to callables. 73 | .. versionchanged:: 0.6 74 | Callable was mistakenly only passed the ``request``, now also gets ``group`` as documented. 75 | 76 | If the value of ``key=`` is a callable, or the path to a callable, that 77 | callable will be called with two arguments, the :ref:`group 78 | ` and the ``request`` object. It should return a 79 | bytestring or unicode object, e.g.:: 80 | 81 | def my_key(group, request): 82 | return request.META['REMOTE_ADDR'] + request.user.username 83 | -------------------------------------------------------------------------------- /docs/rates.rst: -------------------------------------------------------------------------------- 1 | .. _rates-chapter: 2 | 3 | ===== 4 | Rates 5 | ===== 6 | 7 | 8 | .. _rates-simple: 9 | 10 | Simple rates 11 | ============ 12 | 13 | Simple rates are of the form ``X/u`` where ``X`` is a number of requests 14 | and ``u`` is a unit from this list: 15 | 16 | * ``s`` - second 17 | * ``m`` - minute 18 | * ``h`` - hour 19 | * ``d`` - day 20 | 21 | (For example, you can read ``5/s`` as "five per second.") 22 | 23 | .. note:: 24 | 25 | Setting a rate of 0 per any unit of time will disallow requests, 26 | e.g. ``0/s`` will prevent any requests to the endpoint. 27 | 28 | Rates may also be set to ``None``, which indicates "there is no limit." 29 | Usage will not be tracked. 30 | 31 | You may also specify a number of units, i.e.: ``X/Yu`` where ``Y`` is a 32 | number of units. If ``u`` is omitted, it is presumed to be seconds. So, 33 | the following are equivalent, and all mean "one hundred requests per 34 | five minutes": 35 | 36 | * ``100/5m`` 37 | * ``100/300s`` 38 | * ``100/300`` 39 | 40 | 41 | .. _rates-callable: 42 | 43 | Callables 44 | ========= 45 | 46 | .. versionadded:: 0.5 47 | 48 | Rates can also be callables (or dotted paths to callables, which are 49 | assumed if there is a ``.`` in the value). 50 | 51 | Callables receive two values, the :ref:`group ` and the 52 | ``request`` object. They should return a simple rate string, or a tuple 53 | of integers ``(count, seconds)``. For example:: 54 | 55 | def my_rate(group, request): 56 | if request.user.is_authenticated: 57 | return '1000/m' 58 | return '100/m' 59 | 60 | Or equivalently:: 61 | 62 | def my_rate_tuples(group, request): 63 | if request.user.is_authenticated: 64 | return (1000, 60) 65 | return (100, 60) 66 | 67 | Callables can return ``0`` in the first place to disallow any requests 68 | (e.g.: ``0/s``, ``(0, 60)``). They can return ``None`` for "no 69 | ratelimit". 70 | -------------------------------------------------------------------------------- /docs/security.rst: -------------------------------------------------------------------------------- 1 | .. _security-chapter: 2 | 3 | ======================= 4 | Security considerations 5 | ======================= 6 | 7 | 8 | .. _security-client-ip: 9 | 10 | Client IP address 11 | ================= 12 | 13 | IP address is an extremely common rate limit :ref:`key `, 14 | so it is important to configure correctly, especially in the 15 | equally-common case where Django is behind a load balancer or other 16 | reverse proxy. 17 | 18 | Django-Ratelimit is **not** the correct place to handle reverse proxies 19 | and adjust the IP address, and patches dealing with it will not be 20 | accepted. There is `too much variation`_ in the wild to handle it 21 | safely. 22 | 23 | This is the same reason `Django dropped`_ 24 | ``SetRemoteAddrFromForwardedFor`` middleware in 1.1: no such "mechanism 25 | can be made reliable enough for general-purpose use" and it "may lead 26 | developers to assume that the value of ``REMOTE_ADDR`` is 'safe'." 27 | 28 | 29 | Risks 30 | ----- 31 | 32 | Mishandling client IP data creates an IP spoofing vector that allows 33 | attackers to circumvent IP ratelimiting entirely. Consider an attacker 34 | with the real IP address 3.3.3.3 that adds the following to a request:: 35 | 36 | X-Forwarded-For: 1.2.3.4 37 | 38 | A misconfigured web server may pass the header value along, e.g.:: 39 | 40 | X-Forwarded-For: 3.3.3.3, 1.2.3.4 41 | 42 | Alternatively, if the web server sends a different header, like 43 | ``X-Cluster-Client-IP`` or ``X-Real-IP``, and passes along the 44 | spoofed ``X-Forwarded-For`` header unchanged, a mistake in ratelimit or 45 | a misconfiguration in Django could read the spoofed header instead of 46 | the intended one. 47 | 48 | 49 | Remediation 50 | ----------- 51 | 52 | There are two options, configuring django-ratelimit or adding global 53 | middleware. Which makes sense depends on your setup. 54 | 55 | 56 | Middleware 57 | ^^^^^^^^^^ 58 | 59 | Writing a small middleware class to set ``REMOTE_ADDR`` to the actual 60 | client IP address is generally simple:: 61 | 62 | def reverse_proxy(get_response): 63 | def process_request(request): 64 | request.META['REMOTE_ADDR'] = # [...] 65 | return get_response(request) 66 | return process_request 67 | 68 | where ``# [...]`` depends on your environment. This middleware should be 69 | close to the top of the list:: 70 | 71 | MIDDLEWARE = ( 72 | 'path.to.reverse_proxy', 73 | # ... 74 | ) 75 | 76 | Then the ``@ratelimit`` decorator can be used with the ``ip`` key:: 77 | 78 | @ratelimit(key='ip', rate='10/s') 79 | 80 | Ratelimit keys 81 | ^^^^^^^^^^^^^^ 82 | 83 | Alternatively, if the client IP address is in a simple header (i.e. a 84 | header like ``X-Real-IP`` that *only* contains the client IP, unlike 85 | ``X-Forwarded-For`` which may contain intermediate proxies) you can use 86 | a ``header:`` key:: 87 | 88 | @ratelimit(key='header:x-real-ip', rate='10/s') 89 | 90 | .. _too much variation: http://en.wikipedia.org/wiki/Talk:X-Forwarded-For#Variations 91 | .. _Django dropped: https://docs.djangoproject.com/en/2.1/releases/1.1/#removed-setremoteaddrfromforwardedfor-middleware 92 | 93 | 94 | .. _security-brute-force: 95 | 96 | Brute force attacks 97 | =================== 98 | 99 | One of the key uses of ratelimiting is preventing brute force or 100 | dictionary attacks against login forms. These attacks generally take one 101 | of a few forms: 102 | 103 | - One IP address trying one username with many passwords. 104 | - Many IP addresses trying one username with many passwords. 105 | - One IP address trying many usernames with a few common passwords. 106 | - Many IP addresses trying many usernames with one or a few common 107 | passwords. 108 | 109 | .. note:: 110 | Unfortunately, the fourth case of many IPs trying many usernames can 111 | be difficult to distinguish from regular user behavior and requires 112 | additional signals, such as a consistent user agent or a common 113 | network prefix. 114 | 115 | Protecting against the single IP address cases is easy:: 116 | 117 | @ratelimit(key='ip') 118 | def login_view(request): 119 | pass 120 | 121 | Also limiting by username provides better protection:: 122 | 123 | @ratelimit(key='ip') 124 | @ratelimit(key='post:username') 125 | def login_view(request): 126 | pass 127 | 128 | **Using passwords as key values is not recommended.** Key values are 129 | never stored in a raw form, even as cache keys, but they are constructed 130 | with a fast hash function. 131 | 132 | 133 | Denial of Service 134 | ----------------- 135 | 136 | However, limiting based on field values may open a `denial of service`_ 137 | vector against your users, preventing them from logging in. 138 | 139 | For pages like login forms, consider implementing a soft blocking 140 | mechanism, such as requiring a captcha, rather than a hard block with a 141 | ``PermissionDenied`` error. 142 | 143 | 144 | Network Address Translation 145 | --------------------------- 146 | 147 | Depending on your profile of your users, you may have many users behind 148 | NAT (e.g. users in schools or in corporate networks). It is reasonable 149 | to set a higher limit on a per-IP limit than on a username or password 150 | limit. 151 | 152 | .. _denial of service: http://en.wikipedia.org/wiki/Denial-of-service_attack?oldformat=true 153 | 154 | 155 | .. _security-user-supplied: 156 | 157 | User-supplied Data 158 | ================== 159 | 160 | Using data from GET (``key='get:X'``) POST (``key='post:X'``) or headers 161 | (``key='header:x-x'``) that are provided directly by the browser or 162 | other client presents a risk. Unless there is some requirement of the 163 | attack that requires the client *not* change the value (for example, 164 | attempting to brute force a password requires that the username be 165 | consistent) clients can trivially change these values on every request. 166 | 167 | Headers that are provided by web servers or reverse proxies should be 168 | independently audited to ensure they cannot be affected by clients. 169 | 170 | The ``User-Agent`` header is especially dangerous, since bad actors can 171 | change it on every request, and many good actors may share the same 172 | value. 173 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | .. _settings-chapter: 2 | 3 | ======== 4 | Settings 5 | ======== 6 | 7 | ``RATELIMIT_CACHE_PREFIX`` 8 | -------------------------- 9 | 10 | An optional cache prefix for ratelimit keys (in addition to the ``PREFIX`` 11 | value defined on the cache backend). Defaults to ``'rl:'``. 12 | 13 | ``RATELIMIT_HASH_ALGORITHM`` 14 | ----------------------------- 15 | 16 | An optional functionion to overide the default hashing algorithm used to derive the cache 17 | key. Defaults to ``'hashlib.sha256'``. 18 | 19 | ``RATELIMIT_ENABLE`` 20 | -------------------- 21 | 22 | Set to ``False`` to disable rate-limiting across the board. Defaults to 23 | ``True``. 24 | 25 | May be useful during tests with Django's |override_settings|_ testing tool, 26 | for example: 27 | 28 | .. code-block:: python 29 | 30 | from django.test import override_settings 31 | 32 | with override_settings(RATELIMIT_ENABLE=False): 33 | result = call_the_view() 34 | 35 | .. |override_settings| replace:: ``override_settings()`` 36 | .. _override_settings: https://docs.djangoproject.com/en/2.0/topics/testing/tools/#django.test.override_settings. 37 | 38 | ``RATELIMIT_USE_CACHE`` 39 | ----------------------- 40 | 41 | .. warning:: 42 | `django_ratelimit` requires a Django cache backend that supports _`atomic 43 | increment` operations. The Memcached and Redis backends do, but the database 44 | backend does not. 45 | 46 | The name of the cache (from the ``CACHES`` dict) to use. Defaults to 47 | ``'default'``. 48 | 49 | ``RATELIMIT_VIEW`` 50 | ------------------ 51 | 52 | The string import path to a view to use when a request is ratelimited, in 53 | conjunction with ``RatelimitMiddleware``, e.g. ``'myapp.views.ratelimited'``. 54 | Has no default - you must set this to use ``RatelimitMiddleware``. 55 | 56 | ``RATELIMIT_FAIL_OPEN`` 57 | ----------------------- 58 | 59 | Whether to allow requests when the cache backend fails. Defaults to ``False``. 60 | 61 | ``RATELIMIT_IP_META_KEY`` 62 | ------------------------- 63 | 64 | Set the source of the client IP address in the request.META object. Defaults to 65 | ``None``. 66 | 67 | There are several potential values: 68 | 69 | ``None`` 70 | Use ``request.META['REMOTE_ADDR']`` as the source of the client IP address. 71 | 72 | A callable object 73 | If set to a callable, the callable will be passed the full ``request`` 74 | object. The callable must return the client IP address. For example: 75 | ``RATELIMIT_IP_META_KEY = lambda r: r.META['HTTP_X_CLIENT_IP']`` 76 | 77 | A dotted path to a callable 78 | Any string containing a ``.`` will be treated as a dotted path to a callable, 79 | which will be imported and called on the ``request`` object, as above. 80 | 81 | Any other string 82 | Any other string will be treated as a key for the ``request.META`` object, 83 | e.g. ``RATELIMIT_IP_META_KEY = 'HTTP_X_REAL_IP'`` 84 | 85 | ``RATELIMIT_IPV4_MASK`` 86 | ----------------------- 87 | 88 | IPv4 mask for IP-based rate limit. Defaults to ``32`` (which is no masking) 89 | 90 | ``RATELIMIT_IPV6_MASK`` 91 | ----------------------- 92 | 93 | IPv6 mask for IP-based rate limit. Defaults to ``64`` (which mask the last 64 bits). 94 | Typical end site IPv6 assignment are from /48 to /64. 95 | 96 | ``RATELIMIT_EXCEPTION_CLASS`` 97 | ----------------------------- 98 | 99 | A custom exception class, or a dotted path to a custom exception class, that will be 100 | raised by ratelimit when a limit is exceeded and ``block=True``. 101 | -------------------------------------------------------------------------------- /docs/upgrading.rst: -------------------------------------------------------------------------------- 1 | .. _upgrading-chapter: 2 | 3 | ============= 4 | Upgrade Notes 5 | ============= 6 | 7 | See also the CHANGELOG_. 8 | 9 | .. _CHANGELOG: https://github.com/jsocol/django-ratelimit/blob/main/CHANGELOG 10 | 11 | .. _upgrading-4.0: 12 | 13 | From 3.x to 4.0 14 | =============== 15 | 16 | Quickly: 17 | 18 | - Rename imports from ``from ratelimit`` to ``from django_ratelimit`` 19 | - Check all uses of the ``@ratelimit`` decorator. If the ``block`` argument is 20 | not set, add ``block=False`` to retain the current behavior. ``block=True`` 21 | may optionally be removed. 22 | - Django versions below 3.2 and Python versions below 3.7 are no longer 23 | supported. 24 | 25 | Package name changed 26 | -------------------- 27 | 28 | To disambiguate with other ratelimit packages on PyPI and resolve distro 29 | packaging issues, the package name has been changed from ``ratelimit`` to 30 | ``django_ratelimit``. See `issue 214`_ for more information on this change. 31 | 32 | When upgrading, import paths need to change to use the new package name. 33 | 34 | Old: 35 | 36 | .. code-block:: python 37 | 38 | from ratelimit.decorators import ratelimit 39 | from ratelimit import ALL, UNSAFE 40 | 41 | New: 42 | 43 | .. code-block:: python 44 | 45 | from django_ratelimit.decorators import ratelimit 46 | from django_ratelimit import ALL, UNSAFE 47 | 48 | .. _issue 214: https://github.com/jsocol/django-ratelimit/issues/214 49 | 50 | 51 | Default decorator behavior changed 52 | ---------------------------------- 53 | 54 | In previous versions, the ``@ratelimit`` decorator did not block traffic that 55 | exceeded the rate limits by default. This has been reversed, and now the 56 | default behavior *is* to block requests once a rate limit has been exceeded. 57 | The old behavior of annotating the request object with a ``.limited`` property 58 | can be restored by explicitly setting ``block=False`` on the decorator. 59 | 60 | Historically, the first use cases Django Ratelimit was built to support were 61 | HTML views like login and password-reset pages, rather than APIs. In these 62 | cases, rate limiting is often done based on user input like the username or 63 | email address. Instead of blocking requests, which could lead to a 64 | denial-of-service (DOS) attack against particular users, it is common to 65 | trigger some additional security measures to prevent brute-force attacks, like 66 | a CAPTCHA, temporary account lock, or even notify those users via email. 67 | 68 | However, it has become obvious that the majority of views using the 69 | ``@ratelimit`` decorator tend to be either specific pages or API endpoints that 70 | do not present a DOS attack vector against other users, and that a more 71 | intuitive default behavior is to block requests that exceed the limits. Since 72 | there tend to only be a couple of pages or routes for uses like authentication, 73 | it makes more sense to opt those uses *out* of blocking, than opt all the 74 | others *in*. 75 | 76 | 77 | .. _upgrading-3.0: 78 | 79 | From 2.0 to 3.0 80 | =============== 81 | 82 | Quickly: 83 | 84 | - Ratelimit now supports Django >=1.11 and Python >=3.4. 85 | - ``@ratelimit`` no longer works directly on class methods, add 86 | ``@method_decorator``. 87 | - ``RatelimitMixin`` is gone, migrate to ``@method_decorator``. 88 | - Moved ``is_ratelimted`` method from ``ratelimit.utils`` to 89 | ``ratelimit.core``. 90 | 91 | ``@ratelimit`` decorator on class methods 92 | ----------------------------------------- 93 | 94 | In 3.0, the decorator has been simplified and must now be used with 95 | Django's excellent ``@method_decorator`` utility. Migrating should be 96 | relatively straight-forward: 97 | 98 | .. code-block:: python 99 | 100 | from django.views.generic import View 101 | from ratelimit.decorators import ratelimit 102 | 103 | class MyView(View): 104 | @ratelimit(key='ip', rate='1/m', method='GET') 105 | def get(self, request): 106 | pass 107 | 108 | changes to 109 | 110 | .. code-block:: python 111 | 112 | from django.utils.decorators import method_decorator 113 | from django.views.generic import View 114 | from ratelimit.decorators import ratelimit 115 | 116 | class MyView(View): 117 | @method_decorator(ratelimit(key='ip', rate='1/m', method='GET')) 118 | def get(self, request): 119 | pass 120 | 121 | ``RatelimitMixin`` 122 | ------------------ 123 | 124 | ``RatelimitMixin`` is a vestige of an older version of Ratelimit that 125 | did not support multiple rates per method. As such, it is significantly 126 | less powerful than the current ``@ratelimit`` decorator. To migrate to 127 | the decorator, use the ``@method_decorator`` from Django: 128 | 129 | .. code-block:: python 130 | 131 | class MyView(RatelimitMixin, View): 132 | ratelimit_key = 'ip' 133 | ratelimit_rate = '10/m' 134 | ratelimit_method = 'GET' 135 | 136 | def get(self, request): 137 | pass 138 | 139 | becomes 140 | 141 | .. code-block:: python 142 | 143 | class MyView(View): 144 | @method_decorator(ratelimit(key='ip', rate='10/m', method='GET')) 145 | def get(self, request): 146 | pass 147 | 148 | The major benefit is that it is now possible to apply multiple limits to 149 | the same method, as with :ref:`the decorator `_. 150 | 151 | 152 | 153 | .. _upgrading-0.5: 154 | 155 | From <=0.4 to 0.5 156 | ================= 157 | 158 | Quickly: 159 | 160 | - Rate limits are now counted against fixed, instead of sliding, 161 | windows. 162 | - Rate limits are no longer shared between methods by default. 163 | - Change ``ip=True`` to ``key='ip'``. 164 | - Drop ``ip=False``. 165 | - A key must always be specified. If using without an explicit key, add 166 | ``key='ip'``. 167 | - Change ``fields='foo'`` to ``post:foo`` or ``get:foo``. 168 | - Change ``keys=callable`` to ``key=callable``. 169 | - Change ``skip_if`` to a callable ``rate=`` method (see 170 | :ref:`Rates `. 171 | - Change ``RateLimitMixin`` to ``RatelimitMixin`` (note the lowercase 172 | ``l``). 173 | - Change ``ratelimit_ip=True`` to ``ratelimit_key='ip'``. 174 | - Change ``ratelimit_fields='foo'`` to ``post:foo`` or ``get:foo``. 175 | - Change ``ratelimit_keys=callable`` to ``ratelimit_key=callable``. 176 | 177 | 178 | Fixed windows 179 | ------------- 180 | 181 | Before 0.5, rates were counted against a *sliding* window, so if the 182 | rate limit was ``1/m``, and three requests came in:: 183 | 184 | 1.2.3.4 [09/Sep/2014:12:25:03] ... 185 | 1.2.3.4 [09/Sep/2014:12:25:53] ... 186 | 1.2.3.4 [09/Sep/2014:12:25:59] ... 187 | 188 | Even though the third request came nearly two minutes after the first 189 | request, the second request moved the window. Good actors could easily 190 | get caught in this, even trying to implement reasonable back-offs. 191 | 192 | Starting in 0.5, windows are *fixed*, and staggered throughout a given 193 | period based on the key value, so the third request, above would not be 194 | rate limited (it's possible neither would the second one). 195 | 196 | .. warning:: 197 | That means that given a rate of ``X/u``, you may see up to ``2 * X`` 198 | requests in a short period of time. Make sure to set ``X`` 199 | accordingly if this is an issue. 200 | 201 | This change still limits bad actors while being far kinder to good 202 | actors. 203 | 204 | 205 | Staggering windows 206 | ^^^^^^^^^^^^^^^^^^ 207 | 208 | To avoid a situation where all limits expire at the top of the hour, 209 | windows are automatically staggered throughout their period based on the 210 | key value. So if, for example, two IP addresses are hitting hourly 211 | limits, instead of both of those limits expiring at 06:00:00, one might 212 | expire at 06:13:41 (and subsequently at 07:13:41, etc) and the other 213 | might expire at 06:48:13 (and 07:48:13, etc). 214 | 215 | 216 | Sharing rate limits 217 | ------------------- 218 | 219 | Before 0.5, rate limits were shared between methods based only on their 220 | keys. This was very confusing and unintuitive, and is far from the 221 | least-surprising_ thing. For example, given these three views:: 222 | 223 | @ratelimit(ip=True, field='username') 224 | def both(request): 225 | pass 226 | 227 | @ratelimit(ip=False, field='username') 228 | def field_only(request): 229 | pass 230 | 231 | @ratelimit(ip=True) 232 | def ip_only(request): 233 | pass 234 | 235 | 236 | The pair ``both`` and ``field_only`` shares one rate limit key based on 237 | all requests to either (and any other views) containing the same 238 | ``username`` key (in ``GET`` or ``POST``), regardless of IP address. 239 | 240 | The pair ``both`` and ``ip_only`` shares one rate limit key based on the 241 | client IP address, along with all other views. 242 | 243 | Thus, it's extremely difficult to determine exactly why a request is 244 | getting rate limited. 245 | 246 | In 0.5, methods never share rate limits by default. Instead, limits are 247 | based on a combination of the :ref:`group `, rate, key 248 | value, and HTTP methods *to which the decorator applies* (i.e. **not** 249 | the method of the request). This better supports common use cases and 250 | stacking decorators, and still allows decorators to be shared. 251 | 252 | For example, this implements an hourly rate limit with a per-minute 253 | burst rate limit:: 254 | 255 | @ratelimit(key='ip', rate='100/m') 256 | @ratelimit(key='ip', rate='1000/h') 257 | def myview(request): 258 | pass 259 | 260 | However, this view is limited *separately* from another view with the 261 | same keys and rates:: 262 | 263 | @ratelimit(key='ip', rate='100/m') 264 | @ratelimit(key='ip', rate='1000/h') 265 | def anotherview(request): 266 | pass 267 | 268 | To cause the views to share a limit, explicitly set the ``group`` 269 | argument:: 270 | 271 | @ratelimit(group='lists', key='user', rate='100/h') 272 | def user_list(request): 273 | pass 274 | 275 | @ratelimit(group='lists', key='user', rate='100/h') 276 | def group_list(request): 277 | pass 278 | 279 | You can also stack multiple decorators with different sets of applicable 280 | methods:: 281 | 282 | @ratelimit(key='ip', method='GET', rate='1000/h') 283 | @ratelimit(key='ip', method='POST', rate='100/h') 284 | def maybe_expensive(request): 285 | pass 286 | 287 | This allows a total of 1,100 requests to this view in one hour, while 288 | this would only allow 1000, but still only 100 POSTs:: 289 | 290 | @ratelimit(key='ip', method=['GET', 'POST'], rate='1000/h') 291 | @ratelimit(key='ip', method='POST', rate='100/h') 292 | def maybe_expensive(request): 293 | pass 294 | 295 | And these two decorators would not share a rate limit:: 296 | 297 | @ratelimit(key='ip', method=['GET', 'POST'], rate='100/h') 298 | def foo(request): 299 | pass 300 | 301 | @ratelimit(key='ip', method='GET', rate='100/h') 302 | def bar(request): 303 | pass 304 | 305 | But these two do share a rate limit:: 306 | 307 | @ratelimit(group='a', key='ip', method=['GET', 'POST'], rate='1/s') 308 | def foo(request): 309 | pass 310 | 311 | @ratelimit(group='a', key='ip', method=['POST', 'GET'], rate='1/s') 312 | def bar(request): 313 | pass 314 | 315 | 316 | Using multiple decorators 317 | ------------------------- 318 | 319 | A single ``@ratelimit`` decorator used to be able to ratelimit against 320 | multiple keys, e.g., before 0.5:: 321 | 322 | @ratelimit(ip=True, field='username', keys=mykeysfunc) 323 | def someview(request): 324 | # ... 325 | 326 | To simplify both the internals and the question of what limits apply, 327 | each decorator now tracks exactly one rate, but decorators can be more 328 | reliably stacked (c.f. some examples in the section above). 329 | 330 | The pre-0.5 example above would need to become four decorators:: 331 | 332 | @ratelimit(key='ip') 333 | @ratelimit(key='post:username') 334 | @ratelimit(key='get:username') 335 | @ratelimit(key=mykeysfunc) 336 | def someview(request): 337 | # ... 338 | 339 | As documented above, however, this allows powerful new uses, like burst 340 | limits and distinct GET/POST limits. 341 | 342 | 343 | .. _least-surprising: http://en.wikipedia.org/wiki/Principle_of_least_astonishment 344 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. _usage-chapter: 2 | 3 | ====================== 4 | Using Django Ratelimit 5 | ====================== 6 | 7 | 8 | .. _usage-decorator: 9 | 10 | Use as a decorator 11 | ================== 12 | 13 | .. versionchanged:: 4.0 14 | 15 | Import: 16 | 17 | .. code-block:: python 18 | 19 | from django_ratelimit.decorators import ratelimit 20 | 21 | 22 | .. py:decorator:: ratelimit(group=None, key=, rate=None, method=ALL, block=True) 23 | 24 | :arg group: 25 | *None* A group of rate limits to count together. Defaults to the 26 | dotted name of the view. 27 | 28 | :arg key: 29 | What key to use, see :ref:`Keys `. 30 | 31 | :arg rate: 32 | *'5/m'* The number of requests per unit time allowed. Valid 33 | units are: 34 | 35 | * ``s`` - seconds 36 | * ``m`` - minutes 37 | * ``h`` - hours 38 | * ``d`` - days 39 | 40 | Also accepts callables. See :ref:`Rates `. A rate 41 | of ``0/s`` disallows all requests. A rate of ``None`` means "no 42 | limit" and will allow all requests. 43 | 44 | :arg method: 45 | *ALL* Which HTTP method(s) to rate-limit. May be a string, a 46 | list/tuple of strings, or the special values for ``ALL`` or 47 | ``UNSAFE`` (which includes ``POST``, ``PUT``, ``DELETE`` and 48 | ``PATCH``). 49 | 50 | :arg block: 51 | *True* Whether to block the request instead of annotating. 52 | 53 | 54 | HTTP Methods 55 | ------------ 56 | 57 | Each decorator can be limited to one or more HTTP methods. The 58 | ``method=`` argument accepts a method name (e.g. ``'GET'``) or a list or 59 | tuple of strings (e.g. ``('GET', 'OPTIONS')``). 60 | 61 | There are two special shortcuts values, both accessible from the 62 | ``ratelimit`` decorator or the ``is_ratelimited`` helper, as well as on 63 | the root ``ratelimit`` module: 64 | 65 | .. code-block:: python 66 | 67 | from django_ratelimit.decorators import ratelimit 68 | 69 | @ratelimit(key='ip', method=ratelimit.ALL) 70 | @ratelimit(key='ip', method=ratelimit.UNSAFE) 71 | def myview(request): 72 | pass 73 | 74 | ``ratelimit.ALL`` applies to all HTTP methods. ``ratelimit.UNSAFE`` 75 | is a shortcut for ``('POST', 'PUT', 'PATCH', 'DELETE')``. 76 | 77 | 78 | Examples 79 | -------- 80 | 81 | 82 | .. code-block:: python 83 | 84 | @ratelimit(key='ip', rate='5/m', block=False) 85 | def myview(request): 86 | # Will be true if the same IP makes more than 5 POST 87 | # requests/minute. 88 | was_limited = getattr(request, 'limited', False) 89 | return HttpResponse() 90 | 91 | @ratelimit(key='ip', rate='5/m', block=True) 92 | def myview(request): 93 | # If the same IP makes >5 reqs/min, will raise Ratelimited 94 | return HttpResponse() 95 | 96 | @ratelimit(key='post:username', rate='5/m', 97 | method=['GET', 'POST'], block=False) 98 | def login(request): 99 | # If the same username is used >5 times/min, this will be True. 100 | # The `username` value will come from GET or POST, determined by the 101 | # request method. 102 | was_limited = getattr(request, 'limited', False) 103 | return HttpResponse() 104 | 105 | @ratelimit(key='post:username', rate='5/m') 106 | @ratelimit(key='post:tenant', rate='5/m') 107 | def login(request): 108 | # Use multiple keys by stacking decorators. 109 | return HttpResponse() 110 | 111 | @ratelimit(key='get:q', rate='5/m') 112 | @ratelimit(key='post:q', rate='5/m') 113 | def search(request): 114 | # These two decorators combine to form one rate limit: the same search 115 | # query can only be tried 5 times a minute, regardless of the request 116 | # method (GET or POST) 117 | return HttpResponse() 118 | 119 | @ratelimit(key='ip', rate='4/h') 120 | def slow(request): 121 | # Allow 4 reqs/hour. 122 | return HttpResponse() 123 | 124 | get_rate = lambda g, r: None if r.user.is_authenticated else '100/h' 125 | @ratelimit(key='ip', rate=get_rate) 126 | def skipif1(request): 127 | # Only rate limit anonymous requests 128 | return HttpResponse() 129 | 130 | @ratelimit(key='user_or_ip', rate='10/s') 131 | @ratelimit(key='user_or_ip', rate='100/m') 132 | def burst_limit(request): 133 | # Implement a separate burst limit. 134 | return HttpResponse() 135 | 136 | @ratelimit(group='expensive', key='user_or_ip', rate='10/h') 137 | def expensive_view_a(request): 138 | return something_expensive() 139 | 140 | @ratelimit(group='expensive', key='user_or_ip', rate='10/h') 141 | def expensive_view_b(request): 142 | # Shares a counter with expensive_view_a 143 | return something_else_expensive() 144 | 145 | @ratelimit(key='header:x-cluster-client-ip') 146 | def post(request): 147 | # Uses the X-Cluster-Client-IP header value. 148 | return HttpResponse() 149 | 150 | @ratelimit(key=lambda g, r: r.META.get('HTTP_X_CLUSTER_CLIENT_IP', 151 | r.META['REMOTE_ADDR']) 152 | def myview(request): 153 | # Use `X-Cluster-Client-IP` but fall back to REMOTE_ADDR. 154 | return HttpResponse() 155 | 156 | 157 | Class-Based Views 158 | ----------------- 159 | 160 | .. versionadded:: 0.5 161 | .. versionchanged:: 3.0 162 | 163 | To use the ``@ratelimit`` decorator with class-based views, use the 164 | Django ``@method_decorator``: 165 | 166 | .. code-block:: python 167 | 168 | from django.utils.decorators import method_decorator 169 | from django.views.generic import View 170 | 171 | class MyView(View): 172 | @method_decorator(ratelimit(key='ip', rate='1/m', method='GET')) 173 | def get(self, request): 174 | pass 175 | 176 | @method_decorator(ratelimit(key='ip', rate='1/m', method='GET'), name='get') 177 | class MyOtherView(View): 178 | def get(self, request): 179 | pass 180 | 181 | It is also possible to wrap a whole view later, e.g.: 182 | 183 | .. code-block:: python 184 | 185 | from django.urls import path 186 | 187 | from myapp.views import MyView 188 | 189 | from django_ratelimit.decorators import ratelimit 190 | 191 | urlpatterns = [ 192 | path('/', ratelimit(key='ip', method='GET', rate='1/m')(MyView.as_view())), 193 | ] 194 | 195 | .. warning:: 196 | 197 | Make sure the ``method`` argument matches the method decorated. 198 | 199 | .. note:: 200 | 201 | Unless given an explicit ``group`` argument, different methods of a 202 | class-based view will be limited separately. 203 | 204 | 205 | .. _usage-helper: 206 | 207 | Core Methods 208 | ============ 209 | 210 | .. versionadded:: 3.0 211 | 212 | In some cases the decorator is not flexible enough to, e.g., 213 | conditionally apply rate limits. In these cases, you can access the core 214 | functionality in ``ratelimit.core``. The two major methods are 215 | ``get_usage`` and ``is_ratelimited``. 216 | 217 | 218 | .. code-block:: python 219 | 220 | from django_ratelimit.core import get_usage, is_ratelimited 221 | 222 | .. py:function:: get_usage(request, group=None, fn=None, key=None, \ 223 | rate=None, method=ALL, increment=False) 224 | 225 | :arg request: 226 | *None* The HTTPRequest object. 227 | 228 | :arg group: 229 | *None* A group of rate limits to count together. Defaults to the 230 | dotted name of the view. 231 | 232 | :arg fn: 233 | *None* A view function which can be used to calculate the group 234 | as if it was decorated by :ref:`@ratelimit `. 235 | 236 | :arg key: 237 | What key to use, see :ref:`Keys `. 238 | 239 | :arg rate: 240 | *'5/m'* The number of requests per unit time allowed. Valid 241 | units are: 242 | 243 | * ``s`` - seconds 244 | * ``m`` - minutes 245 | * ``h`` - hours 246 | * ``d`` - days 247 | 248 | Also accepts callables. See :ref:`Rates `. 249 | 250 | :arg method: 251 | *ALL* Which HTTP method(s) to rate-limit. May be a string, a 252 | list/tuple, or ``None`` for all methods. 253 | 254 | :arg increment: 255 | *False* Whether to increment the count or just check. 256 | 257 | :returns dict or None: 258 | Either returns None, indicating that ratelimiting was not active 259 | for this request (for some reason) or returns a dict including 260 | the current count, limit, time left in the window, and whether 261 | this request should be limited. 262 | 263 | .. py:function:: is_ratelimited(request, group=None, fn=None, \ 264 | key=None, rate=None, method=ALL, \ 265 | increment=False) 266 | 267 | :arg request: 268 | *None* The HTTPRequest object. 269 | 270 | :arg group: 271 | *None* A group of rate limits to count together. Defaults to the 272 | dotted name of the view. 273 | 274 | :arg fn: 275 | *None* A view function which can be used to calculate the group 276 | as if it was decorated by :ref:`@ratelimit `. 277 | 278 | :arg key: 279 | What key to use, see :ref:`Keys `. 280 | 281 | :arg rate: 282 | *'5/m'* The number of requests per unit time allowed. Valid 283 | units are: 284 | 285 | * ``s`` - seconds 286 | * ``m`` - minutes 287 | * ``h`` - hours 288 | * ``d`` - days 289 | 290 | Also accepts callables. See :ref:`Rates `. 291 | 292 | :arg method: 293 | *ALL* Which HTTP method(s) to rate-limit. May be a string, a 294 | list/tuple, or ``None`` for all methods. 295 | 296 | :arg increment: 297 | *False* Whether to increment the count or just check. 298 | 299 | :returns bool: 300 | Whether this request should be limited or not. 301 | 302 | 303 | ``is_ratelimited`` is a thin wrapper around ``get_usage`` that is 304 | maintained for compatibility. It provides strictly less information. 305 | 306 | .. warning:: 307 | 308 | ``get_usage`` and ``is_ratelimited`` require either ``group=`` or 309 | ``fn=`` to be passed, or they cannot determine the rate limiting 310 | state and will throw. 311 | 312 | 313 | .. _usage-exception: 314 | 315 | Exceptions 316 | ========== 317 | 318 | .. py:class:: ratelimit.exceptions.Ratelimited 319 | 320 | If a request is ratelimited and ``block`` is set to ``True``, 321 | Ratelimit will raise ``ratelimit.exceptions.Ratelimited``. 322 | 323 | This is a subclass of Django's ``PermissionDenied`` exception, so 324 | if you don't need any special handling beyond the built-in 403 325 | processing, you don't have to do anything. 326 | 327 | If you are setting |handler403|_ in your root URLconf, you can catch this 328 | exception in your custom view to return a different response, for example: 329 | 330 | .. code-block:: python 331 | 332 | def handler403(request, exception=None): 333 | if isinstance(exception, Ratelimited): 334 | return HttpResponse('Sorry you are blocked', status=429) 335 | return HttpResponseForbidden('Forbidden') 336 | 337 | .. |handler403| replace:: ``handler403`` 338 | .. _handler403: https://docs.djangoproject.com/en/2.1/topics/http/urls/#error-handling 339 | 340 | .. _usage-middleware: 341 | 342 | Middleware 343 | ========== 344 | 345 | There is optional middleware to use a custom view to handle ``Ratelimited`` 346 | exceptions. 347 | 348 | To use it, add ``django_ratelimit.middleware.RatelimitMiddleware`` to your 349 | ``MIDDLEWARE`` (toward the bottom of the list) and set 350 | ``RATELIMIT_VIEW`` to the full path of a view you want to use. 351 | 352 | The view specified in ``RATELIMIT_VIEW`` will get two arguments, the 353 | ``request`` object (after ratelimit processing) and the exception. 354 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-ratelimit" 7 | version = "4.1.0" 8 | authors = [{name = "James Socol", email = "me@jamessocol.com"}] 9 | requires-python = ">= 3.7" 10 | license = {file = "LICENSE"} 11 | description = "Cache-based rate-limiting for Django." 12 | readme = "README.rst" 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Environment :: Web Environment", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: Apache Software License", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Framework :: Django", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | ] 29 | urls = {Homepage = "https://github.com/jsocol/django-ratelimit"} 30 | 31 | [tool.distutils.bdist_wheel] 32 | universal = 1 33 | 34 | [tool.setuptools] 35 | include-package-data = true 36 | 37 | [tool.setuptools.packages] 38 | find = {namespaces = false} 39 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export PYTHONPATH=".:$PYTHONPATH" 4 | export DJANGO_SETTINGS_MODULE="test_settings" 5 | 6 | PROG="$0" 7 | CMD="$1" 8 | shift 9 | 10 | usage() { 11 | echo "USAGE: $PROG [command]" 12 | echo " test - run the ratelimit tests" 13 | echo " lint - run flake8 (alias: flake8)" 14 | echo " shell - open the Django shell" 15 | echo " build - build a package for release" 16 | echo " check - run twine check on build artifacts" 17 | exit 1 18 | } 19 | 20 | case "$CMD" in 21 | "test" ) 22 | echo "Django version: $(python -m django --version)" 23 | python \ 24 | -W error::ResourceWarning \ 25 | -W error::DeprecationWarning \ 26 | -W error::PendingDeprecationWarning \ 27 | -m django \ 28 | test \ 29 | django_ratelimit \ 30 | "$@" 31 | ;; 32 | "lint"|"flake8" ) 33 | echo "Flake8 version: $(flake8 --version)" 34 | flake8 "$@" django_ratelimit/ 35 | ;; 36 | "shell" ) 37 | python -m django shell 38 | ;; 39 | "build" ) 40 | rm -rf dist/* 41 | python -m build 42 | ;; 43 | "check" ) 44 | twine check dist/* 45 | ;; 46 | * ) 47 | usage ;; 48 | esac 49 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | ignore=E731 6 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'ratelimit' 2 | 3 | SILENCED_SYSTEM_CHECKS = ['django_ratelimit.E003', 'django_ratelimit.W001'] 4 | 5 | INSTALLED_APPS = ( 6 | 'django_ratelimit', 7 | ) 8 | 9 | RATELIMIT_USE_CACHE = 'default' 10 | 11 | CACHES = { 12 | 'default': { 13 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 14 | 'LOCATION': 'ratelimit-tests', 15 | }, 16 | 'connection-errors': { 17 | 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', 18 | 'LOCATION': 'test-connection-errors', 19 | }, 20 | 'connection-errors-redis': { 21 | 'BACKEND': 'django_redis.cache.RedisCache', 22 | 'LOCATION': 'redis://test-connection-errors', 23 | 'OPTIONS': { 24 | 'IGNORE_EXCEPTIONS': True, 25 | } 26 | }, 27 | 'instant-expiration': { 28 | 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', 29 | 'LOCATION': 'test-instant-expiration', 30 | }, 31 | } 32 | 33 | DATABASES = { 34 | 'default': { 35 | 'ENGINE': 'django.db.backends.sqlite3', 36 | 'NAME': 'test.db', 37 | }, 38 | } 39 | 40 | USE_TZ = True 41 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py37-django32, 4 | py38-django{32,40,41,42,main}, 5 | py39-django{32,40,41,42,main}, 6 | py310-django{32,40,41,42,50,main}, 7 | py311-django{41,42,50,main}, 8 | py312-django{50,main}, 9 | pypy39-django{32,40,41,main}, 10 | 11 | [testenv] 12 | allowlist_externals = ./run.sh 13 | deps = 14 | django32: Django>=3.2,<3.3 15 | django40: Django>=4.0,<4.1 16 | django41: Django>=4.1,<4.2 17 | django42: Django>=4.2,<4.3 18 | django50: Django>=5.0a1,<5.1 19 | djangomain: https://github.com/django/django/archive/main.tar.gz 20 | pymemcache>=4.0,<5.0 21 | django-redis>=5.2,<6.0 22 | flake8 23 | 24 | allowlist_externals = 25 | */run.sh 26 | 27 | commands = 28 | ./run.sh test {posargs} 29 | ./run.sh flake8 30 | --------------------------------------------------------------------------------