├── .coveragerc ├── .devcontainer └── devcontainer.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile └── source │ ├── _static │ └── .gitkeep │ ├── _templates │ └── .gitkeep │ ├── conf.py │ ├── configuration.rst │ ├── index.rst │ ├── installation.rst │ └── intro.rst ├── runtests.py ├── setup.cfg ├── setup.py └── throttle ├── __init__.py ├── backends ├── __init__.py ├── base.py ├── cache.py └── redispy.py ├── decorators.py ├── exceptions.py ├── middleware.py ├── models.py ├── tests ├── __init__.py ├── backends │ ├── __init__.py │ └── test_get_backend.py ├── test_decorators.py ├── test_gcra.py ├── test_utils.py └── test_zones.py ├── utils.py ├── views.py └── zones ├── __init__.py └── remoteip.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # Test coverage configuration. 2 | # Usage: 3 | # python runtests --coverage 4 | [run] 5 | source = throttle 6 | omit = 7 | throttle/tests/* 8 | 9 | [report] 10 | # Ignore missing source files, i.e. fake template-generated "files" 11 | ignore_errors = true 12 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", 4 | 5 | // Configure tool-specific properties. 6 | "customizations": { 7 | // Configure properties specific to VS Code. 8 | "vscode": { 9 | // Set *default* container specific settings.json values on container create. 10 | "settings": { 11 | "python.defaultInterpreterPath": "/usr/local/bin/python", 12 | "python.linting.enabled": true, 13 | "python.linting.pylintEnabled": true, 14 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 15 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 16 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 17 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 18 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 19 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 20 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 21 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 22 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" 23 | }, 24 | 25 | // Add the IDs of extensions you want installed when the container is created. 26 | "extensions": [ 27 | "ms-python.python", 28 | "ms-python.vscode-pylance" 29 | ] 30 | } 31 | }, 32 | 33 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 34 | // "forwardPorts": [], 35 | 36 | // Use 'postCreateCommand' to run commands after the container is created. 37 | "postCreateCommand": "pip install django", 38 | 39 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 40 | "remoteUser": "vscode" 41 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8", "pypy-3.9", "pypy-3.10"] 12 | experimental: [false] 13 | django: ["3.0", "3.1.5", "3.2.9", "4.0", "4.1", "4.2", "5.0.2"] 14 | exclude: 15 | - python-version: "3.12" # Django uses the distutils module under the hood, which is removed in Python 3.12 16 | django: "3.0" 17 | - python-version: "3.12" # Django uses the distutils module under the hood, which is removed in Python 3.12 18 | django: "3.1.5" 19 | - python-version: "3.7" # Django 4.0 supports Python 3.8+ 20 | django: "4.0" 21 | - python-version: "3.7" 22 | django: "4.1" 23 | - python-version: "3.7" 24 | django: "4.2" 25 | - python-version: "3.7" # Django 5.0 support Python 3.10+ 26 | django: "5.0.2" 27 | - python-version: "3.8" 28 | django: "5.0.2" 29 | - python-version: "3.9" 30 | django: "5.0.2" 31 | - python-version: "pypy-3.8" 32 | django: "5.0.2" 33 | - python-version: "pypy-3.9" 34 | django: "5.0.2" 35 | 36 | env: 37 | DJANGO: ${{ matrix.django }} 38 | 39 | continue-on-error: ${{ matrix.experimental }} 40 | 41 | # Use Redis service on localhost to run tests 42 | services: 43 | redis: 44 | image: redis 45 | # Set health checks to wait until redis has started 46 | options: >- 47 | --health-cmd "redis-cli ping" 48 | --health-interval 10s 49 | --health-timeout 5s 50 | --health-retries 5 51 | ports: 52 | # Maps port 6379 on service container to the host 53 | - 6379:6379 54 | 55 | steps: 56 | - name: Checkout repo 57 | uses: actions/checkout@v4 58 | 59 | - name: Set up Python ${{ matrix.python-version }} 60 | uses: actions/setup-python@v5 61 | with: 62 | python-version: ${{ matrix.python-version }} 63 | 64 | - name: Install Dependencies 65 | run: | 66 | python -m pip install --upgrade pip 67 | python -m pip install --upgrade redis 68 | pip install -q flake8 69 | 70 | - name: Install Django ${{ matrix.django }} 71 | run: | 72 | pip install -q Django==$DJANGO 73 | 74 | - name: Run tests 75 | run: | 76 | python runtests.py --use-redis 77 | 78 | - name: Flake8 static code analysis 79 | run: | 80 | flake8 throttle 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .DS_Store 3 | .idea 4 | *.egg* 5 | build 6 | dist 7 | MANIFEST 8 | htmlcov 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2013-2020 Lewis Sobotkiewicz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst setup.py LICENSE 2 | recursive-include throttle *.py 3 | recursive-exclude throttle/tests *.py 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | django-throttle-requests 3 | ======================== 4 | 5 | *a framework for implementing rate-limiting middleware for Django projects* 6 | 7 | |Build| |PyVersion| |PyPiVersion| |License| 8 | 9 | Overview 10 | ======== 11 | 12 | This package allows Django developers to define application-level rate-limiting rules. Often, these rules would be expressed as "max # requests within a defined time period". E.g.: 13 | 14 | - an IP address may make at most 1500 requests/day 15 | 16 | - users with an OAuth access token may make 500 reads/hour and 200 writes/hour 17 | 18 | You can also define leaky bucket-style rules: 19 | 20 | - Allow 10 requests per minute, then every 6 seconds thereafter. 21 | 22 | 23 | Features 24 | ======== 25 | 26 | - Attach rules to specific views using a decorator 27 | - Supports multiple throttle configurations 28 | - Use Django's cache layer as the storage backend, or use Redis scripting for production-ready atomic operations 29 | - Define request attributes to rate limit (e.g. remote IP address, username, HTTP header value, device fingerprint, etc.) 30 | - Application-level rate limiting rules using fixed-bucket or generic cell rate algorithm (leaky bucket) 31 | 32 | 33 | 34 | Installation 35 | ============ 36 | 37 | #. Install the library with pip:: 38 | 39 | sudo pip install django-throttle-requests 40 | 41 | #. Add the directory ``throttle`` to your project's ``PYTHONPATH``. 42 | 43 | Usage 44 | ===== 45 | 46 | #. Insert the following configuration into your project's settings:: 47 | 48 | THROTTLE_ZONES = { 49 | 'default': { 50 | 'VARY':'throttle.zones.RemoteIP', 51 | 'ALGORITHM': 'fixed-bucket', # Default if not defined. 52 | 'BUCKET_INTERVAL':15 * 60, # Number of seconds to enforce limit. 53 | 'BUCKET_CAPACITY':50, # Maximum number of requests allowed within BUCKET_INTERVAL 54 | }, 55 | } 56 | 57 | # Where to store request counts. 58 | THROTTLE_BACKEND = 'throttle.backends.cache.CacheBackend' 59 | 60 | # Optional if Redis backend is chosen ('throttle.backends.redispy.RedisBackend') 61 | THROTTLE_REDIS_HOST = 'localhost' 62 | THROTTLE_REDIS_PORT = 6379 63 | THROTTLE_REDIS_DB = 0 64 | THROTTLE_REDIS_AUTH = 'pass' 65 | 66 | # Normally, throttling is disabled when DEBUG=True. Use this to force it to enabled. 67 | THROTTLE_ENABLED = True 68 | 69 | #. Use the ``@throttle`` decorator to enforce throttling rules on a view:: 70 | 71 | from throttle.decorators import throttle 72 | 73 | @throttle(zone='default') 74 | def myview(request): 75 | ... 76 | 77 | #. Also works with class-based views:: 78 | 79 | from django.views.generic import View 80 | from django.utils.decorators import method_decorator 81 | 82 | from throttle.decorators import throttle 83 | 84 | class TestView(View): 85 | 86 | @method_decorator(throttle(zone='default')) 87 | def dispatch(self, *args, **kwargs): 88 | return super(TestView, self).dispatch(*args, **kwargs) 89 | 90 | def head(self, request): 91 | ... 92 | 93 | def get(self, request): 94 | ... 95 | 96 | :Code: https://github.com/sobotklp/django-throttle-requests 97 | :Documentation: https://readthedocs.org/projects/django-throttle-requests/ 98 | 99 | .. |PyPiVersion| image:: https://img.shields.io/pypi/v/django-throttle-requests.svg 100 | :alt: PyPi 101 | :target: https://pypi.python.org/pypi/django-throttle-requests 102 | 103 | .. |License| image:: https://img.shields.io/badge/license-MIT-yellow.svg 104 | :alt: 105 | 106 | .. |PyVersion| image:: https://img.shields.io/badge/python-3.7+-blue.svg 107 | :alt: 108 | 109 | .. |Build| image:: https://github.com/sobotklp/django-throttle-requests/workflows/CI/badge.svg?branch=master 110 | :target: https://github.com/sobotklp/django-throttle-requests/actions?workflow=CI 111 | :alt: CI Status 112 | -------------------------------------------------------------------------------- /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) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 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/django-throttle-requests.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-throttle-requests.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/django-throttle-requests" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-throttle-requests" 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/source/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sobotklp/django-throttle-requests/56129ecf200de829988cee2499219004bf0cb8fd/docs/source/_static/.gitkeep -------------------------------------------------------------------------------- /docs/source/_templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sobotklp/django-throttle-requests/56129ecf200de829988cee2499219004bf0cb8fd/docs/source/_templates/.gitkeep -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-throttle-requests documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Feb 24 09:43:44 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 = ['sphinx.ext.autodoc'] 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-throttle-requests' 44 | copyright = u'2013, Lewis Sobotkiewicz' 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 = '0.5.0' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '0.5.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 = [] 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 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'django-throttle-requestsdoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | latex_elements = { 173 | # The paper size ('letterpaper' or 'a4paper'). 174 | #'papersize': 'letterpaper', 175 | 176 | # The font size ('10pt', '11pt' or '12pt'). 177 | #'pointsize': '10pt', 178 | 179 | # Additional stuff for the LaTeX preamble. 180 | #'preamble': '', 181 | } 182 | 183 | # Grouping the document tree into LaTeX files. List of tuples 184 | # (source start file, target name, title, author, documentclass [howto/manual]). 185 | latex_documents = [ 186 | ('index', 'django-throttle-requests.tex', u'django-throttle-requests Documentation', 187 | u'Lewis Sobotkiewicz', 'manual'), 188 | ] 189 | 190 | # The name of an image file (relative to this directory) to place at the top of 191 | # the title page. 192 | #latex_logo = None 193 | 194 | # For "manual" documents, if this is true, then toplevel headings are parts, 195 | # not chapters. 196 | #latex_use_parts = False 197 | 198 | # If true, show page references after internal links. 199 | #latex_show_pagerefs = False 200 | 201 | # If true, show URL addresses after external links. 202 | #latex_show_urls = False 203 | 204 | # Documents to append as an appendix to all manuals. 205 | #latex_appendices = [] 206 | 207 | # If false, no module index is generated. 208 | #latex_domain_indices = True 209 | 210 | 211 | # -- Options for manual page output -------------------------------------------- 212 | 213 | # One entry per manual page. List of tuples 214 | # (source start file, name, description, authors, manual section). 215 | man_pages = [ 216 | ('index', 'django-throttle-requests', u'django-throttle-requests Documentation', 217 | [u'Lewis Sobotkiewicz'], 1) 218 | ] 219 | 220 | # If true, show URL addresses after external links. 221 | #man_show_urls = False 222 | 223 | 224 | # -- Options for Texinfo output ------------------------------------------------ 225 | 226 | # Grouping the document tree into Texinfo files. List of tuples 227 | # (source start file, target name, title, author, 228 | # dir menu entry, description, category) 229 | texinfo_documents = [ 230 | ('index', 'django-throttle-requests', u'django-throttle-requests Documentation', 231 | u'Lewis Sobotkiewicz', 'django-throttle-requests', 'One line description of project.', 232 | 'Miscellaneous'), 233 | ] 234 | 235 | # Documents to append as an appendix to all manuals. 236 | #texinfo_appendices = [] 237 | 238 | # If false, no module index is generated. 239 | #texinfo_domain_indices = True 240 | 241 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 242 | #texinfo_show_urls = 'footnote' 243 | -------------------------------------------------------------------------------- /docs/source/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | ============= 4 | Configuration 5 | ============= 6 | 7 | .. currentmodule:: django.conf.settings 8 | 9 | .. attribute:: THROTTLE_ENABLED 10 | 11 | :default: not ``settings.DEBUG`` 12 | 13 | Optional boolean value that is used to control whether or not throttling is enforced. To test throttling 14 | when ``DEBUG`` is ``True``, you must also explicitly set ``THROTTLE_ENABLED = True``. 15 | 16 | .. attribute:: THROTTLE_BACKEND 17 | 18 | The path to the class that implements the backend storage mechanism for per-user request counts. 19 | Currently, only two values are supported: 20 | 21 | ``throttle.backends.cache.CacheBackend`` uses Django's cache backend for storage. If you're going to use this in production, ensure that your cache backend supports atomic increment operations. 22 | ``throttle.backends.redispy.RedisBackend`` uses ``redispy`` and Lua scripting to perform operations atomically within Redis. This is the recommended backend for high-volume production use. 23 | 24 | .. attribute:: THROTTLE_ZONES 25 | 26 | A dictionary that contains definitions of the rate limiting rules for your application. 27 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. django-throttle-requests documentation master file, created by 2 | sphinx-quickstart on Sun Feb 24 09:43:44 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-throttle-requests's documentation! 7 | ==================================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | intro 15 | installation 16 | configuration 17 | 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | 26 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | #. Install the library with pip:: 8 | 9 | sudo pip install django-throttle-requests 10 | 11 | #. Add the directory ``throttle`` to your project's ``PYTHONPATH``. 12 | 13 | #. Insert the following configuration into your project's settings:: 14 | 15 | THROTTLE_ZONES = { 16 | 'default': { 17 | 'VARY':'throttle.zones.RemoteIP', 18 | 'ALGORITHM': 'fixed-bucket' # This is the default if not defined 19 | 'BUCKET_INTERVAL':15 * 60 # Period of time to enforce limits. 20 | 'BUCKET_CAPACITY':50, # Maximum number of requests allowed within BUCKET_INTERVAL 21 | }, 22 | } 23 | 24 | # Where to store request counts. 25 | THROTTLE_BACKEND = 'throttle.backends.cache.CacheBackend' 26 | 27 | # Force throttling when DEBUG=True 28 | THROTTLE_ENABLED = True 29 | 30 | #. Use the ``@throttle`` decorator to enforce throttling rules on a view:: 31 | 32 | from throttle.decorators import throttle 33 | 34 | @throttle(zone='default') 35 | def myview(request): 36 | ... 37 | -------------------------------------------------------------------------------- /docs/source/intro.rst: -------------------------------------------------------------------------------- 1 | .. _intro: 2 | 3 | ============ 4 | Introduction 5 | ============ 6 | 7 | In the context of web applications, limiting the number of requests a host or user makes solves two problems: 8 | 9 | * withstanding Denial-of-service attacks (`rate-limiting `_) 10 | * ensuring that a user doesn't consume too many resources (throttling) 11 | * protecting sensitive resources such as authentication endpoints from abuse 12 | 13 | Rate-limiting is often accomplished with firewall rules on a device, ``iptables``, or web server. They are enforced at the network or transport layer before the request is delivered to the application. For example, 14 | a rule such as "An IP address may make no more than 20 reqs/sec" would queue, or simply drop any requests that exceeded the maximum rate, and the application will not receive the request. 15 | 16 | Throttling can be thought of as application middleware that maintains a count of users' requests during a specific time period. If an incoming request exceeds the maximum for the time period, the user receives a response (e.g. `HTTP 403 `_) containing a helpful error message. 17 | 18 | A good example of throttling is `Twitter's API rate-limiting `_. Twitter enforces several types of limits depending on the type of access token used and the API function used. An example of a rule is "a user may make no more than 150 requests per 15-minute window". 19 | 20 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import argparse 5 | from contextlib import contextmanager 6 | from django.conf import settings 7 | 8 | 9 | def _apply_settings(use_redis): 10 | settings.configure( 11 | ROOT_URLCONF='', 12 | DEBUG=False, 13 | 14 | DATABASES={ 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.sqlite3', 17 | 'NAME': ':memory:', 18 | } 19 | }, 20 | 21 | # Need to use LocMemCache for testing 'throttle.backends.cache.CacheBackend' 22 | # Can't use DummyCache because our functionality depends on the cache backend actually saving values. 23 | CACHES={ 24 | 'default': { 25 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # Bad for production! 26 | } 27 | }, 28 | 29 | INSTALLED_APPS=[ 30 | 'throttle', 31 | ], 32 | 33 | MIDDLEWARE_CLASSES=[], 34 | 35 | SECRET_KEY="asdfnasdf;asdfasdfas", 36 | 37 | THROTTLE_ZONES={ 38 | 'default': { 39 | 'VARY': 'throttle.zones.RemoteIP', 40 | 'ALGORITHM': 'fixed-bucket', # default 41 | 'BUCKET_CAPACITY': 5, # Maximum number of requests allowed within BUCKET_INTERVAL 42 | 'BUCKET_INTERVAL': 1, # Number of seconds to use each bucket. 43 | }, 44 | 'test2': { 45 | 'VARY': 'throttle.zones.RemoteIP', 46 | 'ALGORITHM': 'fixed-bucket', # default 47 | 'BUCKET_CAPACITY': 5, # Maximum number of requests allowed within BUCKET_INTERVAL 48 | 'BUCKET_INTERVAL': 60, # Number of seconds to use each bucket. 49 | }, 50 | 'gcra_test': { 51 | 'VARY': 'throttle.zones.RemoteIP', 52 | 'ALGORITHM': 'gcra', 53 | 'BUCKET_CAPACITY': 10, # Maximum number of requests allowed within window BUCKET_INTERVAL 54 | 'BUCKET_INTERVAL': 10, # Length of window 55 | }, 56 | }, 57 | 58 | THROTTLE_BACKEND=('throttle.backends.redispy.RedisBackend' if use_redis else 'throttle.backends.cache.CacheBackend') 59 | ) 60 | 61 | 62 | @contextmanager 63 | def record_coverage(enable_coverage=False): 64 | """ 65 | Start the coverage module if we chose to run with test coverage enabled. 66 | """ 67 | 68 | # If coverage is not enabled, we don't have to bother with the rest of this function. 69 | if not enable_coverage: 70 | yield 71 | return 72 | 73 | try: 74 | from coverage import coverage 75 | except ImportError: 76 | print("You are attempting to run with coverage turned on, but the coverage module is not installed!", file=sys.stderr) 77 | print("Try sudo pip install coverage", file=sys.stderr) 78 | sys.exit(1) 79 | 80 | cov = coverage(include="throttle/*") 81 | cov.erase() 82 | cov.start() 83 | 84 | yield 85 | 86 | cov.stop() 87 | cov.html_report(directory='htmlcov') 88 | 89 | 90 | def runtests(*test_args, **kwargs): 91 | """ 92 | Invoke Django's test runner and collect output. 93 | Returns 0 if there were no failures 94 | """ 95 | coverage_enabled = kwargs.get("coverage", False) or os.environ.get("WITH_COVERAGE", "False") == "True" 96 | use_redis = kwargs.get('use_redis', False) 97 | _apply_settings(use_redis) 98 | 99 | import django 100 | from django.test.utils import get_runner 101 | 102 | with record_coverage(coverage_enabled): 103 | django.setup() 104 | 105 | if not test_args: 106 | test_args = ['throttle'] 107 | TestRunner = get_runner(settings) 108 | test_runner = TestRunner(verbosity=kwargs.get('verbosity', 1), interactive=kwargs.get('interactive', False), failfast=kwargs.get('failfast')) 109 | failures = test_runner.run_tests(test_args) 110 | 111 | sys.exit(failures) 112 | 113 | 114 | if __name__ == '__main__': 115 | parser = argparse.ArgumentParser(description="Run unit tests for django-throttle-requests") 116 | parser.add_argument('--failfast', action='store_true', default=False, dest='failfast') 117 | parser.add_argument('--coverage', action='store_true', default=False, dest='coverage', help="generate an HTML coverage report") 118 | parser.add_argument('--use-redis', action='store_true', default=False, dest='use_redis', 119 | help="Use local Redis server as backing store") 120 | parser.add_argument('--verbosity', type=int, default=1, dest='verbosity') 121 | args = parser.parse_args() 122 | 123 | runtests(failfast=args.failfast, verbosity=args.verbosity, coverage=args.coverage, use_redis=args.use_redis) 124 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | 4 | [bdist_wheel] 5 | universal = 1 6 | 7 | [flake8] 8 | ignore = E501 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import find_packages, setup 4 | try: 5 | import multiprocessing # Suppresses a confusing but harmless warning when running ./setup.py test 6 | import logging 7 | except ImportError: 8 | # multiprocessing introduced in Python 2.6 9 | pass 10 | 11 | from throttle import __version__ 12 | 13 | long_description = open('README.rst').read() 14 | 15 | setup( 16 | name="django-throttle-requests", 17 | description="A Django framework for application-layer rate limiting", 18 | long_description=long_description, 19 | long_description_content_type="text/x-rst", 20 | packages=find_packages(), 21 | url="https://github.com/sobotklp/django-throttle-requests", 22 | version=__version__, 23 | author='Lewis Sobotkiewicz', 24 | author_email='lewis.sobot@gmail.com', 25 | install_requires=[ 26 | 'Django>=2.2', 27 | ], 28 | license='MIT', 29 | test_suite='runtests.runtests', 30 | classifiers=[ 31 | 'License :: OSI Approved :: MIT License', 32 | 'Development Status :: 4 - Beta', 33 | 'Environment :: Web Environment', 34 | 'Framework :: Django', 35 | 'Intended Audience :: Developers', 36 | 'Operating System :: OS Independent', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: Implementation :: PyPy', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.8', 41 | 'Programming Language :: Python :: 3.9', 42 | 'Programming Language :: Python :: 3.10', 43 | 'Programming Language :: Python :: 3.11', 44 | 'Programming Language :: Python :: 3.12', 45 | 'Topic :: Software Development :: Libraries', 46 | 'Topic :: Software Development :: Libraries :: Python Modules', 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /throttle/__init__.py: -------------------------------------------------------------------------------- 1 | __version_info__ = ('0', '7', '0') 2 | __version__ = ".".join(__version_info__) 3 | -------------------------------------------------------------------------------- /throttle/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.conf import settings 3 | 4 | from throttle.utils import load_class_from_path 5 | 6 | THROTTLE_BACKEND = getattr(settings, 'THROTTLE_BACKEND', {}) 7 | _backend = None 8 | 9 | 10 | def load_backend_from_path(classpath): 11 | klass = load_class_from_path(classpath) 12 | try: 13 | return klass() 14 | except TypeError: 15 | raise ImproperlyConfigured("%s is not callable" % (klass.__name__)) 16 | 17 | 18 | def get_backend(): 19 | global _backend 20 | if _backend: 21 | return _backend 22 | 23 | if THROTTLE_BACKEND: 24 | _backend = load_backend_from_path(settings.THROTTLE_BACKEND) 25 | return _backend 26 | else: 27 | raise ImproperlyConfigured('@throttle was used, but settings.THROTTLE_BACKEND is not set') 28 | -------------------------------------------------------------------------------- /throttle/backends/base.py: -------------------------------------------------------------------------------- 1 | class ThrottleBackendBase: 2 | def incr_bucket(self, zone_name, bucket_key, bucket_interval, limit, cost=1): 3 | """ 4 | Increments the limit for the given bucket. 5 | 6 | @returns: the new value of the bucket, post-increment 7 | """ 8 | raise NotImplementedError 9 | 10 | def gcra(self, zone_name, bucket_key, bucket_interval, limit): 11 | """ 12 | Increments the limit for the given bucket. 13 | 14 | @returns: the new value of the bucket, post-increment 15 | """ 16 | raise NotImplementedError 17 | 18 | def get_algorithm(self, algorithm_name): 19 | """ 20 | Returns a reference to the method implementing `algorithm_name` 21 | """ 22 | raise NotImplementedError 23 | -------------------------------------------------------------------------------- /throttle/backends/cache.py: -------------------------------------------------------------------------------- 1 | import time 2 | from django.core.cache import cache 3 | from throttle.backends.base import ThrottleBackendBase 4 | 5 | 6 | class CacheBackend(ThrottleBackendBase): 7 | def __init__(self): 8 | self.cache = cache 9 | self.time_function = lambda: time.time() 10 | 11 | def incr_bucket(self, zone_name, bucket_key, bucket_span, limit, cost=1): 12 | bucket_cache_key = "%s:%s" % (zone_name, bucket_key) 13 | 14 | # Increment the value of the current bucket. 15 | # Note that the Django documentation states that this operation is not guaranteed 16 | # to be atomic. 17 | try: 18 | new_val = self.cache.incr(bucket_cache_key, cost) 19 | except ValueError: 20 | # bucket_cache_key not in the cache 21 | self.cache.add(bucket_cache_key, cost, bucket_span) 22 | new_val = cost 23 | 24 | return new_val 25 | 26 | def gcra(self, zone_name, bucket_key, bucket_span, limit): 27 | """ 28 | Implement Generic Cell Rate Algorithm using Django's cache backend 29 | 30 | Don't use this in production, since this function is not atomic and there's 31 | currently no locking facility. This produces a race condition that lowers the 32 | reliability of the request limiter if used on multiple application processes. 33 | 34 | For production, please use the redispy implementation. 35 | """ 36 | bucket_cache_key = "%s:%s" % (zone_name, bucket_key) 37 | 38 | now = self.time_function() 39 | between_reqs = bucket_span / limit 40 | try: 41 | prior_value = float(self.cache.get_or_set(bucket_cache_key, 0, bucket_span)) 42 | except ValueError: 43 | prior_value = 0 44 | 45 | tat = max(prior_value, now) 46 | if tat - now <= bucket_span - between_reqs: 47 | new_tat = max(now, tat) + between_reqs 48 | self.cache.set(bucket_cache_key, new_tat, bucket_span) 49 | return round(((new_tat - now) / between_reqs)) # How many more requests are allowed 50 | 51 | return limit + 1 # Limit exceeded 52 | 53 | ALGORITHMS = { 54 | 'fixed-bucket': incr_bucket, 55 | 'gcra': gcra, 56 | } 57 | 58 | def get_algorithm(self, algorithm_name): 59 | return self.ALGORITHMS[algorithm_name] 60 | -------------------------------------------------------------------------------- /throttle/backends/redispy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import hashlib 3 | from django.conf import settings 4 | 5 | from throttle.backends.base import ThrottleBackendBase 6 | try: 7 | import redis 8 | from redis.exceptions import NoScriptError 9 | except ImportError: 10 | from throttle.exceptions import ThrottleImproperlyConfigured 11 | raise ThrottleImproperlyConfigured("django-throttle-requests is configured to use redis, but redis-py is not installed!") 12 | 13 | # Lua script to update bucket data atomically. 14 | # In general, lua scripts should be used instead of Redis transactions to ensure atomicity. Transactions may be 15 | # deprecated at some point. 16 | # 17 | # Script takes 1 key and 2 arguments: , 18 | INCR_BUCKET_SCRIPT = """ 19 | local newval = redis.call('INCRBY', KEYS[1], ARGV[2]) 20 | redis.call('EXPIRE', KEYS[1], ARGV[1]) 21 | return newval 22 | """ 23 | 24 | INCR_BUCKET_SCRIPT_SHA1 = hashlib.sha1(INCR_BUCKET_SCRIPT.encode('utf-8')).hexdigest() 25 | 26 | 27 | # Since this script is non-deterministic (it uses the Redis TIME command), 28 | # ensure that your Redis server is configured with "script effects replication." 29 | # This is the default setting for Redis 5.0.0+ 30 | # 31 | # Script takes 1 key and 2 arguments: , 32 | GCRA_SCRIPT = """ 33 | local bucket_interval = ARGV[1] 34 | local limit = ARGV[2] 35 | local time = redis.call('TIME') 36 | local now = time[1] + (time[2] / 1000000) 37 | local between_reqs = bucket_interval / limit 38 | redis.call('SET', KEYS[1], 0, 'NX', 'EX', bucket_interval) 39 | local tat = math.max(redis.call('GET', KEYS[1]), now) 40 | if tat - now <= bucket_interval - between_reqs then 41 | local new_tat = math.max(now, tat) + between_reqs 42 | redis.call('SET', KEYS[1], tostring(new_tat), 'EX', bucket_interval) 43 | return ((new_tat - now) / between_reqs) -- request is allowed 44 | end 45 | 46 | return limit + 1 -- request is blocked 47 | """ 48 | 49 | GCRA_SCRIPT_SHA1 = hashlib.sha1(GCRA_SCRIPT.encode('utf-8')).hexdigest() 50 | 51 | 52 | class RedisBackend(ThrottleBackendBase): 53 | 54 | def __init__(self): 55 | 56 | THROTTLE_REDIS_HOST = getattr(settings, 'THROTTLE_REDIS_HOST', 'localhost') 57 | THROTTLE_REDIS_PORT = getattr(settings, 'THROTTLE_REDIS_PORT', 6379) 58 | THROTTLE_REDIS_DB = getattr(settings, 'THROTTLE_REDIS_DB', 0) 59 | THROTTLE_REDIS_AUTH = getattr(settings, 'THROTTLE_REDIS_AUTH', None) 60 | 61 | self.pool = redis.ConnectionPool(host=THROTTLE_REDIS_HOST, port=THROTTLE_REDIS_PORT, db=THROTTLE_REDIS_DB, password=THROTTLE_REDIS_AUTH) 62 | 63 | def incr_bucket(self, zone_name, bucket_key, bucket_interval, limit, cost=1): 64 | """ 65 | Fixed window bucket algorithm using Redis scripting 66 | """ 67 | conn = redis.Redis(connection_pool=self.pool) 68 | 69 | bucket_cache_key = "%s:%s" % (zone_name, bucket_key) 70 | 71 | try: 72 | try: 73 | return conn.evalsha(INCR_BUCKET_SCRIPT_SHA1, 1, bucket_cache_key, bucket_interval, cost) 74 | except NoScriptError: 75 | return conn.eval(INCR_BUCKET_SCRIPT, 1, bucket_cache_key, bucket_interval, cost) 76 | 77 | except redis.ConnectionError: 78 | return cost 79 | 80 | def gcra(self, zone_name, bucket_key, bucket_interval, limit): 81 | """ 82 | Generic Cell Rate Algorithm using Redis scripting 83 | """ 84 | conn = redis.Redis(connection_pool=self.pool) 85 | 86 | bucket_cache_key = "%s:%s" % (zone_name, bucket_key) 87 | 88 | try: 89 | try: 90 | return conn.evalsha(GCRA_SCRIPT_SHA1, 1, bucket_cache_key, bucket_interval, limit) 91 | except NoScriptError: 92 | return conn.eval(GCRA_SCRIPT, 1, bucket_cache_key, bucket_interval, limit) 93 | 94 | except redis.ConnectionError: 95 | return 1 96 | 97 | ALGORITHMS = { 98 | 'fixed-bucket': incr_bucket, 99 | 'gcra': gcra, 100 | } 101 | 102 | def get_algorithm(self, algorithm_name): 103 | return self.ALGORITHMS[algorithm_name] 104 | -------------------------------------------------------------------------------- /throttle/decorators.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from throttle.zones import get_zone 4 | 5 | 6 | def throttle(view_func=None, zone='default'): 7 | def decorator(func): 8 | 9 | @functools.wraps(func, assigned=functools.WRAPPER_ASSIGNMENTS) 10 | def _wrapped_view(request, *args, **kwargs): 11 | # Get zone from cache 12 | _throttle_zone = get_zone(zone) 13 | 14 | # raises an exception if the rate limit is exceeded 15 | response = _throttle_zone.process_view(request, func, args, kwargs) 16 | return response 17 | 18 | # Validate the rate limiter bucket 19 | _zone = get_zone(zone) 20 | 21 | if func: 22 | setattr(_wrapped_view, 'throttle_zone', _zone) 23 | return _wrapped_view 24 | return _wrapped_view 25 | 26 | # Validate the rate limiter bucket 27 | _zone = get_zone(zone) 28 | if view_func: 29 | setattr(view_func, 'throttle_zone', _zone) 30 | return decorator(view_func) 31 | return decorator 32 | -------------------------------------------------------------------------------- /throttle/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured, PermissionDenied 2 | 3 | 4 | class RateLimitExceeded(PermissionDenied): 5 | pass 6 | 7 | 8 | class ThrottleZoneNotDefined(ImproperlyConfigured): 9 | pass 10 | 11 | 12 | class ThrottleImproperlyConfigured(ImproperlyConfigured): 13 | pass 14 | -------------------------------------------------------------------------------- /throttle/middleware.py: -------------------------------------------------------------------------------- 1 | from exceptions import RateLimitExceeded 2 | 3 | 4 | class RateLimitMiddleware(object): 5 | def process_exception(self, request, exception): 6 | if not isinstance(exception, RateLimitExceeded): 7 | return 8 | -------------------------------------------------------------------------------- /throttle/models.py: -------------------------------------------------------------------------------- 1 | # No models. 2 | -------------------------------------------------------------------------------- /throttle/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from throttle.tests.test_utils import test_load_module_from_path # noqa: F401 2 | from throttle.tests.test_zones import TestRemoteIP, Test_ThrottleZone # noqa: F401 3 | from throttle.tests.test_decorators import test_throttle # noqa: F401 4 | from throttle.tests.test_gcra import Test_GCRAAlgorithm # noqa: F401 5 | from throttle.tests.backends import * # noqa: F401, F403 6 | -------------------------------------------------------------------------------- /throttle/tests/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from throttle.backends.base import ThrottleBackendBase 2 | from .test_get_backend import test_get_backend 3 | 4 | 5 | class TestThrottleBackend(ThrottleBackendBase): 6 | pass 7 | 8 | 9 | __all__ = ['test_get_backend'] 10 | -------------------------------------------------------------------------------- /throttle/tests/backends/test_get_backend.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | try: 3 | from django.utils import unittest 4 | except ImportError: # Not available in Django 1.9 5 | import unittest 6 | 7 | from django.core.exceptions import ImproperlyConfigured 8 | 9 | from throttle.backends import load_backend_from_path 10 | 11 | 12 | class test_get_backend(unittest.TestCase): 13 | def test_load_backend_from_path_invalid_modulename(self): 14 | with self.assertRaises(ImproperlyConfigured): 15 | load_backend_from_path("throttle.tests.backends.BADThrottleBackend") 16 | 17 | def test_load_backend_from_path(self): 18 | backend = load_backend_from_path("throttle.tests.backends.TestThrottleBackend") 19 | self.assertEqual(backend.__class__.__name__, 'TestThrottleBackend') 20 | -------------------------------------------------------------------------------- /throttle/tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.test import TestCase 3 | from django.http import HttpResponse 4 | from django.views.generic import View 5 | from django.utils.decorators import method_decorator 6 | from django.urls import re_path 7 | from django.test.utils import override_settings 8 | 9 | from throttle.decorators import throttle 10 | from throttle.exceptions import ThrottleZoneNotDefined 11 | 12 | 13 | @throttle 14 | def _test_view(request): 15 | return HttpResponse('OK') 16 | 17 | 18 | @throttle 19 | @throttle(zone='test2') 20 | def _test_multiple_throttles(request): 21 | return HttpResponse("Photos") 22 | 23 | 24 | @throttle(zone='test2') 25 | def _test_view_with_parameters(request, id): 26 | return HttpResponse(str(id)) 27 | 28 | 29 | def _test_view_not_throttled(request): 30 | return HttpResponse("Go ahead and DoS me!") 31 | 32 | 33 | class TestView(View): 34 | 35 | @method_decorator(throttle(zone='default')) 36 | def dispatch(self, *args, **kwargs): 37 | return super(TestView, self).dispatch(*args, **kwargs) 38 | 39 | def head(self, request, id): 40 | return HttpResponse("Metadata") 41 | 42 | def get(self, request, id): 43 | return HttpResponse(str(id)) 44 | 45 | 46 | # Explicitly create the view. This is only done for testing as we need to inspect the view code 47 | test_generic_view = TestView.as_view() 48 | 49 | urlpatterns = [ 50 | re_path(r'^test/$', _test_view), 51 | re_path(r'^test/(\d+)/$', _test_view_with_parameters), 52 | re_path(r'^test-generic-view/(\d+)/?$', test_generic_view) 53 | ] 54 | 55 | 56 | @override_settings(ROOT_URLCONF=__name__) 57 | class test_throttle(TestCase): 58 | 59 | def test_view_marked(self): 60 | ''' 61 | @throttle adds an attribute 'throttle_zone' to views it decorates. 62 | ''' 63 | self.assertFalse(hasattr(_test_view_not_throttled, 'throttle_zone')) 64 | self.assertTrue(hasattr(_test_view, 'throttle_zone')) 65 | self.assertEqual(_test_view.throttle_zone.vary.__class__.__name__, 'RemoteIP') 66 | 67 | def test_with_invalid_zone(self): 68 | ''' 69 | @throttle throws an exception if an invalid zone is specified 70 | ''' 71 | with self.assertRaises(ThrottleZoneNotDefined): 72 | _throttled_view = throttle(_test_view_not_throttled, zone='oœuf') 73 | _throttled_view(object) 74 | 75 | def test_marked_view_returns(self): 76 | response = self.client.get('/test/') 77 | 78 | self.assertEqual(response.status_code, 200) 79 | self.assertContains(response, "OK") 80 | 81 | def test_marked_view_with_params(self): 82 | response = self.client.get('/test/99/') 83 | 84 | self.assertEqual(response.status_code, 200) 85 | self.assertContains(response, "99") 86 | 87 | def test_returns_403_if_exceeded(self): 88 | 89 | # THROTTLE_ZONE 'default' allows 5 requests/second 90 | for i in range(5): 91 | response = self.client.get('/test/', REMOTE_ADDR='test_returns_403_if_exceeded') 92 | self.assertEqual(response.status_code, 200, '%ith iteration' % i) 93 | 94 | # Now the next request should fail 95 | response = self.client.get('/test/', REMOTE_ADDR='test_returns_403_if_exceeded') 96 | self.assertEqual(response.status_code, 403) 97 | 98 | def test_marked_class_view_returns(self): 99 | response = self.client.get('/test-generic-view/100') 100 | 101 | self.assertEqual(response.status_code, 200) 102 | self.assertContains(response, "100") 103 | 104 | def test_marked_class_view_returns_403_if_exceeded(self): 105 | # THROTTLE_ZONE 'default' allows 5 requests/second 106 | for i in range(5): 107 | response = self.client.get('/test-generic-view/%i' % i, REMOTE_ADDR='test_marked_class_view_returns_403_if_exceeded') 108 | self.assertEqual(response.status_code, 200) 109 | 110 | # Now the next request should fail 111 | response = self.client.get('/test-generic-view/%i' % i, REMOTE_ADDR='test_marked_class_view_returns_403_if_exceeded') 112 | self.assertEqual(response.status_code, 403) 113 | -------------------------------------------------------------------------------- /throttle/tests/test_gcra.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.http import HttpResponse 3 | from django.urls import re_path 4 | from django.test.utils import override_settings 5 | 6 | from throttle.zones import RemoteIP, ThrottleZone 7 | from throttle.exceptions import RateLimitExceeded 8 | 9 | 10 | def _test_remote_ip(request): 11 | return HttpResponse(RemoteIP().get_bucket_key(request, _test_remote_ip, None, None)) 12 | 13 | 14 | urlpatterns = [ 15 | re_path(r'^test/$', _test_remote_ip), 16 | ] 17 | 18 | 19 | @override_settings(ROOT_URLCONF=__name__) 20 | class Test_GCRAAlgorithm(TestCase): 21 | 22 | def setUp(self): 23 | self.zone = ThrottleZone('gcra_test', RemoteIP, BUCKET_INTERVAL=10, BUCKET_CAPACITY=10, ALGORITHM='gcra') 24 | 25 | class FakeRequest: 26 | META = { 27 | 'REMOTE_ADDR': '127.0.0.1', 28 | } 29 | 30 | self.fake_request = FakeRequest() 31 | 32 | def test_gcra(self): 33 | # First request should succeed 34 | response = self.zone.process_view(self.fake_request, _test_remote_ip, (), {}) 35 | self.assertEqual(response.throttle_remaining, 9) 36 | 37 | # Next nine requests should succeed 38 | for i in range(0, 9): 39 | response = self.zone.process_view(self.fake_request, _test_remote_ip, (), {}) 40 | 41 | # Eleventh one should fail 42 | with self.assertRaises(RateLimitExceeded): 43 | response = self.zone.process_view(self.fake_request, _test_remote_ip, (), {}) 44 | -------------------------------------------------------------------------------- /throttle/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | try: 3 | from django.utils import unittest 4 | except ImportError: # Not available in Django 1.9 5 | import unittest 6 | from django.core.exceptions import ImproperlyConfigured 7 | 8 | from throttle.utils import load_class_from_path 9 | 10 | 11 | class test_load_module_from_path(unittest.TestCase): 12 | def test_invalid_modulename(self): 13 | with self.assertRaises(ImproperlyConfigured): 14 | load_class_from_path("allmodulenoklass") 15 | with self.assertRaises(ImproperlyConfigured): 16 | load_class_from_path("badmodule.badclass") 17 | with self.assertRaises(ImproperlyConfigured): 18 | load_class_from_path("tests.blah") 19 | with self.assertRaises(ImproperlyConfigured): 20 | load_class_from_path("tests.BADClass") 21 | 22 | def test_get_module(self): 23 | module = load_class_from_path("throttle.tests.backends.TestThrottleBackend") 24 | self.assertEqual(module.__name__, 'TestThrottleBackend') 25 | -------------------------------------------------------------------------------- /throttle/tests/test_zones.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.http import HttpResponse 3 | from django.urls import re_path 4 | from django.test.utils import override_settings 5 | 6 | from throttle.zones import RemoteIP, ThrottleZone 7 | from throttle.exceptions import ThrottleImproperlyConfigured 8 | 9 | 10 | def _test_remote_ip(request): 11 | return HttpResponse(RemoteIP().get_bucket_key(request, _test_remote_ip, None, None)) 12 | 13 | 14 | urlpatterns = [ 15 | re_path(r'^test/$', _test_remote_ip), 16 | ] 17 | 18 | 19 | @override_settings(ROOT_URLCONF=__name__) 20 | class TestRemoteIP(TestCase): 21 | 22 | def test_remoteIP(self): 23 | response = self.client.get('/test/') 24 | self.assertContains(response, '127.0.0.1') 25 | 26 | def test_with_alternate_remote_addr(self): 27 | response = self.client.get('/test/', REMOTE_ADDR='10.5.2.1') 28 | self.assertContains(response, '10.5.2.1') 29 | 30 | def test_with_proxied_ip(self): 31 | response = self.client.get('/test/', HTTP_X_FORWARDED_FOR='10.60.70.255', REMOTE_ADDR='10.5.2.1') 32 | self.assertContains(response, '10.60.70.255') 33 | 34 | 35 | @override_settings(ROOT_URLCONF=__name__) 36 | class Test_ThrottleZone(TestCase): 37 | # TODO: Add more tests for the constructor 38 | def setUp(self): 39 | self.zone = ThrottleZone('testZone', RemoteIP, BUCKET_INTERVAL=60, BUCKET_CAPACITY=15) 40 | 41 | class FakeRequest: 42 | META = { 43 | 'REMOTE_ADDR': '127.0.0.1', 44 | } 45 | 46 | self.fake_request = FakeRequest() 47 | 48 | def test_unsupported_algorithm(self): 49 | with self.assertRaises(ThrottleImproperlyConfigured): 50 | ThrottleZone('testZone', RemoteIP, BUCKET_INTERVAL=60, BUCKET_CAPACITY=15, ALGORITHM="FAKE") 51 | 52 | def test_process_view(self): 53 | response = self.zone.process_view(self.fake_request, _test_remote_ip, (), {}) 54 | self.assertEqual(response.throttle_remaining, 14) 55 | 56 | response = self.zone.process_view(self.fake_request, _test_remote_ip, (), {}) 57 | self.assertEqual(response.throttle_remaining, 13) 58 | 59 | def test_obeys_THROTTLE_ENABLED_setting(self): 60 | import throttle.zones 61 | old_THROTTLE_ENABLED = throttle.zones.THROTTLE_ENABLED 62 | throttle.zones.THROTTLE_ENABLED = False 63 | 64 | # Should be able to make more than 15 calls now 65 | for i in range(20): 66 | response = self.zone.process_view(self.fake_request, _test_remote_ip, (), {}) 67 | self.assertEqual(response.status_code, 200) 68 | self.assertFalse(hasattr(response, 'throttle_remaining')) 69 | 70 | throttle.zones.THROTTLE_ENABLED = old_THROTTLE_ENABLED 71 | -------------------------------------------------------------------------------- /throttle/utils.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | try: 5 | from importlib import import_module 6 | except ImportError: 7 | from django.utils.importlib import import_module 8 | 9 | 10 | def load_class_from_path(class_path): 11 | # Split the class into a pair 12 | try: 13 | modulename, classname = class_path.rsplit('.', 1) 14 | except ValueError: 15 | raise ImproperlyConfigured("'%s' isn't a valid module name" % (class_path)) 16 | 17 | # Attempt to load the module 18 | try: 19 | module = import_module(modulename) 20 | except (ImportError,): 21 | raise ImproperlyConfigured("Error importing module '%s'" % (modulename)) 22 | 23 | # Attempt to reference the class 24 | try: 25 | return getattr(module, classname) 26 | except AttributeError: 27 | raise ImproperlyConfigured("Module %s has no class '%s'" % (modulename, classname)) 28 | 29 | 30 | # Memcached can't take keys with spaces. This could happen when 31 | # you use more than one reverse proxy. 32 | def serialize_bucket_key(bucket_key): 33 | return sha256(bucket_key.encode('utf-8')).hexdigest() 34 | -------------------------------------------------------------------------------- /throttle/views.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sobotklp/django-throttle-requests/56129ecf200de829988cee2499219004bf0cb8fd/throttle/views.py -------------------------------------------------------------------------------- /throttle/zones/__init__.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.conf import settings 3 | 4 | from throttle.zones.remoteip import RemoteIP # noqa: F401 5 | from throttle.exceptions import ThrottleZoneNotDefined, ThrottleImproperlyConfigured, RateLimitExceeded 6 | from throttle.utils import load_class_from_path, serialize_bucket_key 7 | from throttle.backends import get_backend 8 | 9 | THROTTLE_ENABLED = getattr(settings, 'THROTTLE_ENABLED', not settings.DEBUG) 10 | 11 | _THROTTLE_ZONES = {} 12 | 13 | 14 | class ThrottleZone(object): 15 | def __init__(self, zone_name, vary_with, **config): 16 | self._zone_name = zone_name 17 | self.vary = vary_with(**config) 18 | self.config = config 19 | self.backend = get_backend() 20 | 21 | self.algorithm = config.get('ALGORITHM', 'fixed-bucket') 22 | try: 23 | self.algorithm_impl = self.backend.get_algorithm(self.algorithm) 24 | except KeyError: 25 | raise ThrottleImproperlyConfigured(f'ALGORITHM \'{self.algorithm}\' not supported by backend') 26 | 27 | try: 28 | self.bucket_interval = int(config['BUCKET_INTERVAL']) 29 | if self.bucket_interval <= 0: 30 | raise ValueError 31 | except KeyError: 32 | raise ThrottleImproperlyConfigured(f'THROTTLE_ZONE[\'{zone_name}\'] missing BUCKET_INTERVAL parameter)') 33 | except ValueError: 34 | raise ThrottleImproperlyConfigured(f'THROTTLE_ZONE[\'{zone_name}\'][\'BUCKET_INTERVAL\'] must be > 0') 35 | 36 | try: 37 | self.bucket_capacity = int(config['BUCKET_CAPACITY']) 38 | except KeyError: 39 | raise ThrottleImproperlyConfigured(f'THROTTLE_ZONE[\'{zone_name}\'] missing BUCKET_CAPACITY parameter)') 40 | except ValueError: 41 | raise ThrottleImproperlyConfigured(f'THROTTLE_ZONE[\'{zone_name}\'][\'BUCKET_CAPACITY\'] must be an int') 42 | 43 | self.bucket_span = self.bucket_interval 44 | 45 | def process_view(self, request, view_func, view_args, view_kwargs): 46 | # If THROTTLE_ENABLED is False, just return the response from the view. 47 | if not THROTTLE_ENABLED: 48 | return view_func(request, *view_args, **view_kwargs) 49 | 50 | # if func is a class view the request is the instance of the class 51 | view_class = request 52 | request = getattr(request, 'request', request) 53 | 54 | bucket_key = serialize_bucket_key( 55 | self.vary.get_bucket_key(request, view_func, view_args, view_kwargs) 56 | ) 57 | 58 | # Tell the backing store to increment the count 59 | new_value = self.algorithm_impl(self.backend, self.name, bucket_key, self.bucket_interval, self.bucket_capacity) 60 | 61 | # Has the bucket capacity been exceeded? 62 | if new_value > self.bucket_capacity: 63 | raise RateLimitExceeded(self.name) 64 | 65 | num_remaining = self.bucket_capacity - new_value 66 | 67 | # Execute the Django view. Add a few attributes to the response object. 68 | response = view_func(view_class, *view_args, **view_kwargs) 69 | response.throttle_limit = self.bucket_capacity 70 | response.throttle_remaining = num_remaining 71 | 72 | # Perform additional processing on the response object 73 | # TODO: Make this better 74 | response = self.process_response(request, response, remaining=num_remaining) 75 | 76 | return response 77 | 78 | def process_response(self, request, response, remaining=0): 79 | # TODO: Make this configurable 80 | response['X-Request-Limit-Limit'] = self.bucket_capacity 81 | response['X-Request-Limit-Remaining'] = remaining 82 | return response 83 | 84 | @property 85 | def name(self): 86 | return self._zone_name 87 | 88 | 89 | def _load_zone(zone_name, **config): 90 | vary_klass = load_class_from_path(config['VARY']) 91 | return ThrottleZone(zone_name, vary_klass, **config) 92 | 93 | 94 | def get_zone(zone_name): 95 | try: 96 | return _THROTTLE_ZONES[zone_name] 97 | except KeyError: 98 | try: 99 | throttle_zone = settings.THROTTLE_ZONES[zone_name] 100 | zone = _load_zone(zone_name, **throttle_zone) 101 | _THROTTLE_ZONES[zone_name] = zone 102 | return zone 103 | except AttributeError: 104 | raise ImproperlyConfigured('@throttle is used, but settings.THROTTLE_ZONES is undefined') 105 | except KeyError: 106 | raise ThrottleZoneNotDefined(zone_name) 107 | -------------------------------------------------------------------------------- /throttle/zones/remoteip.py: -------------------------------------------------------------------------------- 1 | class RemoteIP: 2 | def __init__(self, **config): 3 | pass 4 | 5 | def get_bucket_key(self, request, view_func, view_args, view_kwargs): 6 | """ 7 | Return our best crack at the remote IP address 8 | """ 9 | # Handle Load Balancer case 10 | # Load balancer returns usually in format of 'xx.xx.xx.xx, yy.yy.yy.yy, zz.zz.zz.zz' 11 | # where xx.xx.xx.xx is the actual ip while others vary on the request 12 | ip_string = request.META.get('HTTP_X_FORWARDED_FOR', "") 13 | ip_string = ip_string.split(',')[0] 14 | return ip_string or request.META.get('REMOTE_ADDR') 15 | --------------------------------------------------------------------------------