mirror of
https://github.com/laurentS/slowapi.git
synced 2026-03-13 09:10:20 +08:00
First version
This commit is contained in:
8
LICENSE
8
LICENSE
@@ -4,10 +4,10 @@ Copyright (c) 2020 Laurent Savaete
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
in the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
99
README.md
Normal file
99
README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# SlowApi
|
||||
|
||||
A rate limiting library for Starlette and FastAPI adapted from [flask-limiter](http://github.com/alisaifee/flask-limiter).
|
||||
|
||||
Note: this is alpha quality code still, the API may change, and things may fall apart while you try it.
|
||||
|
||||
# Quick start
|
||||
|
||||
## Starlette
|
||||
|
||||
```python
|
||||
from starlette.applications import Starlette
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app = Starlette()
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
@limiter.limit("5/minute")
|
||||
async def homepage(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
app.add_route("/home", homepage)
|
||||
```
|
||||
|
||||
The above app will have a route `t1` that will accept up to 5 requests per minute. Requests beyond this limit will be answered with an HTTP 429 error, and the body of the view will not run.
|
||||
|
||||
## FastAPI
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app = FastAPI()
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
@app.get("/home")
|
||||
@limiter.limit("5/minute")
|
||||
async def homepage(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
```
|
||||
|
||||
This will provide the same result, but with a FastAPI app.
|
||||
|
||||
# Features
|
||||
|
||||
Most feature are coming from (will come from) FlaskLimiter and the underlying [limits](https://limits.readthedocs.io/).
|
||||
|
||||
Supported now:
|
||||
- Single and multiple `limit` decorator on endpoint functions to apply limits
|
||||
- redis, memcached and memory backends to track your limits (memory as a fallback)
|
||||
- support for sync and async HTTP endpoints
|
||||
- Support for shared limits across a set of routes
|
||||
|
||||
|
||||
# Limitations and known issues
|
||||
|
||||
* There is no support for default limits yet (in other words, the only default limit supported is "unlimited")
|
||||
|
||||
* The `request` argument must be explicitly passed to your endpoint, or `slowapi` won't be able to hook into it. In other words, write:
|
||||
|
||||
```python
|
||||
@limiter.limit("5/minute")
|
||||
async def myendpoint(request: Request)
|
||||
pass
|
||||
```
|
||||
|
||||
and not:
|
||||
|
||||
```python
|
||||
@limiter.limit("5/minute")
|
||||
async def myendpoint()
|
||||
pass
|
||||
```
|
||||
|
||||
* `websocket` endpoints are not supported yet.
|
||||
|
||||
# Developing and contributing
|
||||
|
||||
PRs are more than welcome! Please include tests for your changes :)
|
||||
|
||||
The package uses [poetry](https://python-poetry.org) to manage dependencies. To setup your dev env:
|
||||
|
||||
```bash
|
||||
$ poetry install
|
||||
```
|
||||
|
||||
To run the tests:
|
||||
```bash
|
||||
$ pytest
|
||||
```
|
||||
|
||||
# Credits
|
||||
|
||||
Credits go to [flask-limiter](https://github.com/alisaifee/flask-limiter) of which SlowApi is a (still partial) adaptation to Starlette and FastAPI.
|
||||
It's also important to mention that the actual rate limiting work is done be [limits](https://github.com/alisaifee/limits/), `slowapi` is just a wrapper around it.
|
||||
686
poetry.lock
generated
Normal file
686
poetry.lock
generated
Normal file
@@ -0,0 +1,686 @@
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
name = "appdirs"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.4.3"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Atomic file writes."
|
||||
marker = "sys_platform == \"win32\""
|
||||
name = "atomicwrites"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "1.3.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Classes Without Boilerplate"
|
||||
name = "attrs"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "19.3.0"
|
||||
|
||||
[package.extras]
|
||||
azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
|
||||
dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
|
||||
docs = ["sphinx", "zope.interface"]
|
||||
tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "The uncompromising code formatter."
|
||||
name = "black"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "19.10b0"
|
||||
|
||||
[package.dependencies]
|
||||
appdirs = "*"
|
||||
attrs = ">=18.1.0"
|
||||
click = ">=6.5"
|
||||
pathspec = ">=0.6,<1"
|
||||
regex = "*"
|
||||
toml = ">=0.9.4"
|
||||
typed-ast = ">=1.4.0"
|
||||
|
||||
[package.extras]
|
||||
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
name = "certifi"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "2019.11.28"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Universal encoding detector for Python 2 and 3"
|
||||
name = "chardet"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "3.0.4"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Composable command line interface toolkit"
|
||||
name = "click"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "7.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Cross-platform colored terminal text."
|
||||
marker = "sys_platform == \"win32\""
|
||||
name = "colorama"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "0.4.3"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "A backport of the dataclasses module for Python 3.6"
|
||||
marker = "python_version < \"3.7\""
|
||||
name = "dataclasses"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.6"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
name = "fastapi"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "0.49.0"
|
||||
|
||||
[package.dependencies]
|
||||
pydantic = ">=0.32.2,<2.0.0"
|
||||
starlette = "0.12.9"
|
||||
|
||||
[package.extras]
|
||||
all = ["requests", "aiofiles", "jinja2", "python-multipart", "itsdangerous", "pyyaml", "graphene", "ujson", "email-validator", "uvicorn", "async-exit-stack", "async-generator"]
|
||||
dev = ["pyjwt", "passlib", "autoflake", "flake8", "uvicorn", "graphene"]
|
||||
doc = ["mkdocs", "mkdocs-material", "markdown-include"]
|
||||
test = ["pytest (>=4.0.0)", "pytest-cov", "mypy", "black", "isort", "requests", "email-validator", "sqlalchemy", "peewee", "databases", "orjson", "async-exit-stack", "async-generator"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "time manipulation utilities for python"
|
||||
name = "hiro"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.5.1"
|
||||
|
||||
[package.dependencies]
|
||||
mock = "*"
|
||||
six = ">=1.4.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
name = "idna"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "2.9"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Read metadata from Python packages"
|
||||
marker = "python_version < \"3.8\""
|
||||
name = "importlib-metadata"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
||||
version = "1.5.0"
|
||||
|
||||
[package.dependencies]
|
||||
zipp = ">=0.5"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "rst.linker"]
|
||||
testing = ["packaging", "importlib-resources"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "A Python utility / library to sort Python imports."
|
||||
name = "isort"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "4.3.21"
|
||||
|
||||
[package.extras]
|
||||
pipfile = ["pipreqs", "requirementslib"]
|
||||
pyproject = ["toml"]
|
||||
requirements = ["pipreqs", "pip-api"]
|
||||
xdg_home = ["appdirs (>=1.4.0)"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Rate limiting utilities"
|
||||
name = "limits"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.5"
|
||||
|
||||
[package.dependencies]
|
||||
six = ">=1.4.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
|
||||
name = "lxml"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
|
||||
version = "4.5.0"
|
||||
|
||||
[package.extras]
|
||||
cssselect = ["cssselect (>=0.7)"]
|
||||
html5 = ["html5lib"]
|
||||
htmlsoup = ["beautifulsoup4"]
|
||||
source = ["Cython (>=0.29.7)"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Rolling backport of unittest.mock for all Pythons"
|
||||
name = "mock"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "4.0.1"
|
||||
|
||||
[package.extras]
|
||||
build = ["twine", "wheel", "blurb"]
|
||||
docs = ["sphinx"]
|
||||
test = ["pytest", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "More routines for operating on iterables, beyond itertools"
|
||||
name = "more-itertools"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
version = "8.2.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Optional static typing for Python"
|
||||
name = "mypy"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
version = "0.761"
|
||||
|
||||
[package.dependencies]
|
||||
mypy-extensions = ">=0.4.3,<0.5.0"
|
||||
typed-ast = ">=1.4.0,<1.5.0"
|
||||
typing-extensions = ">=3.7.4"
|
||||
|
||||
[package.extras]
|
||||
dmypy = ["psutil (>=4.0)"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||
name = "mypy-extensions"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.4.3"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Core utilities for Python packages"
|
||||
name = "packaging"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "20.1"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2"
|
||||
six = "*"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
name = "pathspec"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "0.7.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
name = "pluggy"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "0.13.1"
|
||||
|
||||
[package.dependencies]
|
||||
[package.dependencies.importlib-metadata]
|
||||
python = "<3.8"
|
||||
version = ">=0.12"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
name = "py"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "1.8.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Data validation and settings management using python 3.6 type hinting"
|
||||
name = "pydantic"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "1.4"
|
||||
|
||||
[package.dependencies]
|
||||
[package.dependencies.dataclasses]
|
||||
python = "<3.7"
|
||||
version = ">=0.6"
|
||||
|
||||
[package.extras]
|
||||
dotenv = ["python-dotenv (>=0.10.4)"]
|
||||
email = ["email-validator (>=1.0.3)"]
|
||||
typing_extensions = ["typing-extensions (>=3.7.2)"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python parsing module"
|
||||
name = "pyparsing"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
version = "2.4.6"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
name = "pytest"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
version = "5.3.5"
|
||||
|
||||
[package.dependencies]
|
||||
atomicwrites = ">=1.0"
|
||||
attrs = ">=17.4.0"
|
||||
colorama = "*"
|
||||
more-itertools = ">=4.0.0"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<1.0"
|
||||
py = ">=1.5.0"
|
||||
wcwidth = "*"
|
||||
|
||||
[package.dependencies.importlib-metadata]
|
||||
python = "<3.8"
|
||||
version = ">=0.12"
|
||||
|
||||
[package.extras]
|
||||
checkqa-mypy = ["mypy (v0.761)"]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python client for Redis key-value store"
|
||||
name = "redis"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "3.4.1"
|
||||
|
||||
[package.extras]
|
||||
hiredis = ["hiredis (>=0.1.3)"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Alternative regular expression module, to replace re."
|
||||
name = "regex"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "2020.2.20"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python HTTP for Humans."
|
||||
name = "requests"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "2.23.0"
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
chardet = ">=3.0.2,<4"
|
||||
idna = ">=2.5,<3"
|
||||
urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26"
|
||||
|
||||
[package.extras]
|
||||
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
|
||||
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python 2 and 3 compatibility utilities"
|
||||
name = "six"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
version = "1.14.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "The little ASGI library that shines."
|
||||
name = "starlette"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "0.12.9"
|
||||
|
||||
[package.extras]
|
||||
full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python Library for Tom's Obvious, Minimal Language"
|
||||
name = "toml"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "a fork of Python 2 and 3 ast modules with type comment support"
|
||||
name = "typed-ast"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.4.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Backported and Experimental Type Hints for Python 3.5+"
|
||||
name = "typing-extensions"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "3.7.4.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
name = "urllib3"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
|
||||
version = "1.25.8"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotlipy (>=0.6.0)"]
|
||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
||||
socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Measures number of Terminal column cells of wide-character codes"
|
||||
name = "wcwidth"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.1.8"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
||||
marker = "python_version < \"3.8\""
|
||||
name = "zipp"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "3.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
|
||||
testing = ["jaraco.itertools", "func-timeout"]
|
||||
|
||||
[metadata]
|
||||
content-hash = "cc0599a8890bd327b78c53f145ff1e0c9ff29cd945a4d2c21423b7e0d09054fd"
|
||||
python-versions = "^3.6"
|
||||
|
||||
[metadata.files]
|
||||
appdirs = [
|
||||
{file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"},
|
||||
{file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"},
|
||||
]
|
||||
atomicwrites = [
|
||||
{file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"},
|
||||
{file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"},
|
||||
]
|
||||
attrs = [
|
||||
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
|
||||
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
|
||||
]
|
||||
black = [
|
||||
{file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
|
||||
{file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
|
||||
]
|
||||
certifi = [
|
||||
{file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"},
|
||||
{file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"},
|
||||
]
|
||||
chardet = [
|
||||
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
|
||||
{file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
|
||||
]
|
||||
click = [
|
||||
{file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"},
|
||||
{file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"},
|
||||
]
|
||||
colorama = [
|
||||
{file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
|
||||
{file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
|
||||
]
|
||||
dataclasses = [
|
||||
{file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"},
|
||||
{file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"},
|
||||
]
|
||||
fastapi = [
|
||||
{file = "fastapi-0.49.0-py3-none-any.whl", hash = "sha256:717dbd2871c270970c70406ef4e550c3504525a7941df817f3a1318de0857c13"},
|
||||
{file = "fastapi-0.49.0.tar.gz", hash = "sha256:c9296e05a011a53c5b4f0a12f06c261b95b7199685b3af986486e41a27545081"},
|
||||
]
|
||||
hiro = [
|
||||
{file = "hiro-0.5.1-py3.7.egg", hash = "sha256:8fb52fac61a360a4bd955a2e0c975483da28aba99196febb99d5dda38b5f48e0"},
|
||||
{file = "hiro-0.5.1.tar.gz", hash = "sha256:d10e3b7f27b36673b4fa1283cd38d610326ba1ff1291260d0275152f15ae4bc7"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
|
||||
{file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"},
|
||||
]
|
||||
importlib-metadata = [
|
||||
{file = "importlib_metadata-1.5.0-py2.py3-none-any.whl", hash = "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"},
|
||||
{file = "importlib_metadata-1.5.0.tar.gz", hash = "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302"},
|
||||
]
|
||||
isort = [
|
||||
{file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
|
||||
{file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
|
||||
]
|
||||
limits = [
|
||||
{file = "limits-1.5-py2-none-any.whl", hash = "sha256:d05ba12c94ea8ab7a54cfa9c55ec46882790fae7e389743c0abea8e58fa1f886"},
|
||||
{file = "limits-1.5.tar.gz", hash = "sha256:19bc9e6f6c64e8edfc7d18734a89486cdadbad816a81673c35669a1d1cac6d5d"},
|
||||
]
|
||||
lxml = [
|
||||
{file = "lxml-4.5.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c"},
|
||||
{file = "lxml-4.5.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd"},
|
||||
{file = "lxml-4.5.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261"},
|
||||
{file = "lxml-4.5.0-cp27-cp27m-win32.whl", hash = "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89"},
|
||||
{file = "lxml-4.5.0-cp27-cp27m-win_amd64.whl", hash = "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a"},
|
||||
{file = "lxml-4.5.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128"},
|
||||
{file = "lxml-4.5.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca"},
|
||||
{file = "lxml-4.5.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb"},
|
||||
{file = "lxml-4.5.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8"},
|
||||
{file = "lxml-4.5.0-cp35-cp35m-win32.whl", hash = "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77"},
|
||||
{file = "lxml-4.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081"},
|
||||
{file = "lxml-4.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9"},
|
||||
{file = "lxml-4.5.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717"},
|
||||
{file = "lxml-4.5.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15"},
|
||||
{file = "lxml-4.5.0-cp36-cp36m-win32.whl", hash = "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7"},
|
||||
{file = "lxml-4.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012"},
|
||||
{file = "lxml-4.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6"},
|
||||
{file = "lxml-4.5.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679"},
|
||||
{file = "lxml-4.5.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc"},
|
||||
{file = "lxml-4.5.0-cp37-cp37m-win32.whl", hash = "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a"},
|
||||
{file = "lxml-4.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8"},
|
||||
{file = "lxml-4.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72"},
|
||||
{file = "lxml-4.5.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1"},
|
||||
{file = "lxml-4.5.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a"},
|
||||
{file = "lxml-4.5.0-cp38-cp38-win32.whl", hash = "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f"},
|
||||
{file = "lxml-4.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3"},
|
||||
{file = "lxml-4.5.0.tar.gz", hash = "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60"},
|
||||
]
|
||||
mock = [
|
||||
{file = "mock-4.0.1-py3-none-any.whl", hash = "sha256:5e48d216809f6f393987ed56920305d8f3c647e6ed35407c1ff2ecb88a9e1151"},
|
||||
{file = "mock-4.0.1.tar.gz", hash = "sha256:2a572b715f09dd2f0a583d8aeb5bb67d7ed7a8fd31d193cf1227a99c16a67bc3"},
|
||||
]
|
||||
more-itertools = [
|
||||
{file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"},
|
||||
{file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"},
|
||||
]
|
||||
mypy = [
|
||||
{file = "mypy-0.761-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6"},
|
||||
{file = "mypy-0.761-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36"},
|
||||
{file = "mypy-0.761-cp35-cp35m-win_amd64.whl", hash = "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72"},
|
||||
{file = "mypy-0.761-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2"},
|
||||
{file = "mypy-0.761-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0"},
|
||||
{file = "mypy-0.761-cp36-cp36m-win_amd64.whl", hash = "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474"},
|
||||
{file = "mypy-0.761-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a"},
|
||||
{file = "mypy-0.761-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749"},
|
||||
{file = "mypy-0.761-cp37-cp37m-win_amd64.whl", hash = "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1"},
|
||||
{file = "mypy-0.761-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7"},
|
||||
{file = "mypy-0.761-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"},
|
||||
{file = "mypy-0.761-cp38-cp38-win_amd64.whl", hash = "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b"},
|
||||
{file = "mypy-0.761-py3-none-any.whl", hash = "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217"},
|
||||
{file = "mypy-0.761.tar.gz", hash = "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf"},
|
||||
]
|
||||
mypy-extensions = [
|
||||
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
|
||||
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-20.1-py2.py3-none-any.whl", hash = "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73"},
|
||||
{file = "packaging-20.1.tar.gz", hash = "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"},
|
||||
]
|
||||
pathspec = [
|
||||
{file = "pathspec-0.7.0-py2.py3-none-any.whl", hash = "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424"},
|
||||
{file = "pathspec-0.7.0.tar.gz", hash = "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"},
|
||||
]
|
||||
pluggy = [
|
||||
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
|
||||
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
|
||||
]
|
||||
py = [
|
||||
{file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"},
|
||||
{file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"},
|
||||
]
|
||||
pydantic = [
|
||||
{file = "pydantic-1.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04"},
|
||||
{file = "pydantic-1.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752"},
|
||||
{file = "pydantic-1.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f"},
|
||||
{file = "pydantic-1.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac"},
|
||||
{file = "pydantic-1.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11"},
|
||||
{file = "pydantic-1.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf"},
|
||||
{file = "pydantic-1.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f"},
|
||||
{file = "pydantic-1.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df"},
|
||||
{file = "pydantic-1.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab"},
|
||||
{file = "pydantic-1.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3"},
|
||||
{file = "pydantic-1.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21"},
|
||||
{file = "pydantic-1.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed"},
|
||||
{file = "pydantic-1.4-py36.py37.py38-none-any.whl", hash = "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d"},
|
||||
{file = "pydantic-1.4.tar.gz", hash = "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-2.4.6-py2.py3-none-any.whl", hash = "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"},
|
||||
{file = "pyparsing-2.4.6.tar.gz", hash = "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f"},
|
||||
]
|
||||
pytest = [
|
||||
{file = "pytest-5.3.5-py3-none-any.whl", hash = "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"},
|
||||
{file = "pytest-5.3.5.tar.gz", hash = "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d"},
|
||||
]
|
||||
redis = [
|
||||
{file = "redis-3.4.1-py2.py3-none-any.whl", hash = "sha256:b205cffd05ebfd0a468db74f0eedbff8df1a7bfc47521516ade4692991bb0833"},
|
||||
{file = "redis-3.4.1.tar.gz", hash = "sha256:0dcfb335921b88a850d461dc255ff4708294943322bd55de6cfd68972490ca1f"},
|
||||
]
|
||||
regex = [
|
||||
{file = "regex-2020.2.20-cp27-cp27m-win32.whl", hash = "sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb"},
|
||||
{file = "regex-2020.2.20-cp27-cp27m-win_amd64.whl", hash = "sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74"},
|
||||
{file = "regex-2020.2.20-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400"},
|
||||
{file = "regex-2020.2.20-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0"},
|
||||
{file = "regex-2020.2.20-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc"},
|
||||
{file = "regex-2020.2.20-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0"},
|
||||
{file = "regex-2020.2.20-cp36-cp36m-win32.whl", hash = "sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69"},
|
||||
{file = "regex-2020.2.20-cp36-cp36m-win_amd64.whl", hash = "sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b"},
|
||||
{file = "regex-2020.2.20-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e"},
|
||||
{file = "regex-2020.2.20-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242"},
|
||||
{file = "regex-2020.2.20-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce"},
|
||||
{file = "regex-2020.2.20-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab"},
|
||||
{file = "regex-2020.2.20-cp37-cp37m-win32.whl", hash = "sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431"},
|
||||
{file = "regex-2020.2.20-cp37-cp37m-win_amd64.whl", hash = "sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1"},
|
||||
{file = "regex-2020.2.20-cp38-cp38-manylinux1_i686.whl", hash = "sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045"},
|
||||
{file = "regex-2020.2.20-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26"},
|
||||
{file = "regex-2020.2.20-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2"},
|
||||
{file = "regex-2020.2.20-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70"},
|
||||
{file = "regex-2020.2.20-cp38-cp38-win32.whl", hash = "sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d"},
|
||||
{file = "regex-2020.2.20-cp38-cp38-win_amd64.whl", hash = "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa"},
|
||||
{file = "regex-2020.2.20.tar.gz", hash = "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
|
||||
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
|
||||
]
|
||||
six = [
|
||||
{file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"},
|
||||
{file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"},
|
||||
]
|
||||
starlette = [
|
||||
{file = "starlette-0.12.9.tar.gz", hash = "sha256:c2ac9a42e0e0328ad20fe444115ac5e3760c1ee2ac1ff8cdb5ec915c4a453411"},
|
||||
]
|
||||
toml = [
|
||||
{file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"},
|
||||
{file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"},
|
||||
{file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"},
|
||||
]
|
||||
typed-ast = [
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
|
||||
{file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
|
||||
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
|
||||
]
|
||||
typing-extensions = [
|
||||
{file = "typing_extensions-3.7.4.1-py2-none-any.whl", hash = "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d"},
|
||||
{file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"},
|
||||
{file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"},
|
||||
]
|
||||
urllib3 = [
|
||||
{file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"},
|
||||
{file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"},
|
||||
]
|
||||
wcwidth = [
|
||||
{file = "wcwidth-0.1.8-py2.py3-none-any.whl", hash = "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603"},
|
||||
{file = "wcwidth-0.1.8.tar.gz", hash = "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"},
|
||||
]
|
||||
zipp = [
|
||||
{file = "zipp-3.0.0-py3-none-any.whl", hash = "sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2"},
|
||||
{file = "zipp-3.0.0.tar.gz", hash = "sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a"},
|
||||
]
|
||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[tool.poetry]
|
||||
name = "slowapi"
|
||||
version = "0.1.0"
|
||||
description = "A rate limiting extension for Starlette and Fastapi"
|
||||
authors = ["Laurent Savaete <laurent@where.tf>"]
|
||||
license = "MIT"
|
||||
|
||||
readme = "README.md"
|
||||
|
||||
repository = "https://github.com/laurents/slowapi"
|
||||
homepage = "https://github.com/laurents/slowapi"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.6"
|
||||
limits = "^1.5"
|
||||
redis = "^3.4.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
isort = "^4.3.21"
|
||||
mypy = "^0.761"
|
||||
black = "^19.10b0"
|
||||
fastapi = "^0.49.0"
|
||||
lxml = "^4.5.0"
|
||||
starlette = "^0.12.9"
|
||||
mock = "^4.0.1"
|
||||
hiro = "^0.5.1"
|
||||
requests = "^2.22.0"
|
||||
pytest = "^5.3.5"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
1
slowapi/__init__.py
Normal file
1
slowapi/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .extension import Limiter, _rate_limit_exceeded_handler
|
||||
27
slowapi/errors.py
Normal file
27
slowapi/errors.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
errors and exceptions
|
||||
"""
|
||||
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from .wrappers import Limit
|
||||
|
||||
|
||||
class RateLimitExceeded(HTTPException):
|
||||
"""
|
||||
exception raised when a rate limit is hit.
|
||||
"""
|
||||
|
||||
limit = None
|
||||
|
||||
def __init__(self, limit: Limit) -> None:
|
||||
self.limit = limit
|
||||
if limit.error_message:
|
||||
description: str = (
|
||||
limit.error_message
|
||||
if not callable(limit.error_message)
|
||||
else limit.error_message()
|
||||
)
|
||||
else:
|
||||
description = str(limit.limit)
|
||||
super(RateLimitExceeded, self).__init__(status_code=429, detail=description)
|
||||
650
slowapi/extension.py
Normal file
650
slowapi/extension.py
Normal file
@@ -0,0 +1,650 @@
|
||||
"""
|
||||
The starlette extension to rate-limit requests
|
||||
"""
|
||||
import asyncio
|
||||
import datetime
|
||||
import functools
|
||||
import inspect
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from email.utils import formatdate, parsedate_to_datetime
|
||||
from functools import wraps
|
||||
from typing import (Any, Callable, Dict, List, Optional, Set, Tuple, Type,
|
||||
TypeVar, Union)
|
||||
|
||||
from limits import RateLimitItem # type: ignore
|
||||
from limits.errors import ConfigurationError # type: ignore
|
||||
from limits.storage import Storage # type: ignore
|
||||
from limits.storage import MemoryStorage, storage_from_string
|
||||
from limits.strategies import STRATEGIES, RateLimiter # type: ignore
|
||||
from starlette.applications import Starlette
|
||||
from starlette.config import Config
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from .errors import RateLimitExceeded
|
||||
from .util import get_ipaddr
|
||||
from .wrappers import Limit, LimitGroup
|
||||
|
||||
# used to annotate get_app_config method
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class C:
|
||||
ENABLED = "RATELIMIT_ENABLED"
|
||||
HEADERS_ENABLED = "RATELIMIT_HEADERS_ENABLED"
|
||||
STORAGE_URL = "RATELIMIT_STORAGE_URL"
|
||||
STORAGE_OPTIONS = "RATELIMIT_STORAGE_OPTIONS"
|
||||
STRATEGY = "RATELIMIT_STRATEGY"
|
||||
GLOBAL_LIMITS = "RATELIMIT_GLOBAL"
|
||||
DEFAULT_LIMITS = "RATELIMIT_DEFAULT"
|
||||
APPLICATION_LIMITS = "RATELIMIT_APPLICATION"
|
||||
HEADER_LIMIT = "RATELIMIT_HEADER_LIMIT"
|
||||
HEADER_REMAINING = "RATELIMIT_HEADER_REMAINING"
|
||||
HEADER_RESET = "RATELIMIT_HEADER_RESET"
|
||||
SWALLOW_ERRORS = "RATELIMIT_SWALLOW_ERRORS"
|
||||
IN_MEMORY_FALLBACK = "RATELIMIT_IN_MEMORY_FALLBACK"
|
||||
IN_MEMORY_FALLBACK_ENABLED = "RATELIMIT_IN_MEMORY_FALLBACK_ENABLED"
|
||||
HEADER_RETRY_AFTER = "RATELIMIT_HEADER_RETRY_AFTER"
|
||||
HEADER_RETRY_AFTER_VALUE = "RATELIMIT_HEADER_RETRY_AFTER_VALUE"
|
||||
KEY_PREFIX = "RATELIMIT_KEY_PREFIX"
|
||||
|
||||
|
||||
class HEADERS:
|
||||
RESET = 1
|
||||
REMAINING = 2
|
||||
LIMIT = 3
|
||||
RETRY_AFTER = 4
|
||||
|
||||
|
||||
MAX_BACKEND_CHECKS = 5
|
||||
|
||||
|
||||
def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> Response:
|
||||
"""
|
||||
Build a simple JSON response that includes the details of the rate limit
|
||||
that was hit. If no limit is hit, the countdown is added to headers.
|
||||
"""
|
||||
response = JSONResponse(
|
||||
{"error": f"Rate limit exceeded: {exc.limit}"}, status_code=429
|
||||
)
|
||||
response = request.app.state.limiter._inject_headers(
|
||||
response, request.state.view_rate_limit
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class Limiter:
|
||||
"""
|
||||
:param app: :class:`Starlette/FastAPI` instance to initialize the extension
|
||||
with.
|
||||
:param list default_limits: a variable list of strings or callables returning strings denoting global
|
||||
limits to apply to all routes. :ref:`ratelimit-string` for more details.
|
||||
:param list application_limits: a variable list of strings or callables returning strings for limits that
|
||||
are applied to the entire application (i.e a shared limit for all routes)
|
||||
:param function key_func: a callable that returns the domain to rate limit by.
|
||||
:param bool headers_enabled: whether ``X-RateLimit`` response headers are written.
|
||||
:param str strategy: the strategy to use. refer to :ref:`ratelimit-strategy`
|
||||
:param str storage_uri: the storage location. refer to :ref:`ratelimit-conf`
|
||||
:param dict storage_options: kwargs to pass to the storage implementation upon
|
||||
instantiation.
|
||||
:param bool auto_check: whether to automatically check the rate limit in the before_request
|
||||
chain of the application. default ``True``
|
||||
:param bool swallow_errors: whether to swallow errors when hitting a rate limit.
|
||||
An exception will still be logged. default ``False``
|
||||
:param list in_memory_fallback: a variable list of strings or callables returning strings denoting fallback
|
||||
limits to apply when the storage is down.
|
||||
:param bool in_memory_fallback_enabled: simply falls back to in memory storage
|
||||
when the main storage is down and inherits the original limits.
|
||||
:param str key_prefix: prefix prepended to rate limiter keys.
|
||||
:param Optional[str] config_filename: name of the config file for Starlette from which to load settings
|
||||
for the rate limiter. Defaults to ".env".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# app: Starlette = None,
|
||||
key_func=Callable[..., str],
|
||||
default_limits: List[Union[str, Callable[..., str]]] = [],
|
||||
application_limits: List[Union[str, Callable[..., str]]] = [],
|
||||
headers_enabled: bool = False,
|
||||
strategy: Optional[str] = None,
|
||||
storage_uri: Optional[str] = None,
|
||||
storage_options: Dict = {},
|
||||
auto_check: bool = True,
|
||||
swallow_errors: bool = False,
|
||||
in_memory_fallback: List = [],
|
||||
in_memory_fallback_enabled: bool = False,
|
||||
retry_after=None,
|
||||
key_prefix: str = "",
|
||||
enabled: bool = True,
|
||||
config_filename: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Configure the rate limiter at app level
|
||||
"""
|
||||
# assert app is not None, "Passing the app instance to the limiter is required"
|
||||
# self.app = app
|
||||
# app.state.limiter = self
|
||||
|
||||
self.logger = logging.getLogger("slowapi")
|
||||
|
||||
self.app_config = Config(
|
||||
config_filename if config_filename is not None else ".env"
|
||||
)
|
||||
|
||||
self.enabled = enabled
|
||||
self._default_limits = []
|
||||
self._application_limits = []
|
||||
self._in_memory_fallback = []
|
||||
self._in_memory_fallback_enabled = (
|
||||
in_memory_fallback_enabled or len(in_memory_fallback) > 0
|
||||
)
|
||||
self._exempt_routes: Set = set()
|
||||
self._request_filters: List = []
|
||||
self._headers_enabled = headers_enabled
|
||||
self._header_mapping: Dict[int, str] = {}
|
||||
self._retry_after = retry_after
|
||||
self._strategy = strategy
|
||||
self._storage_uri = storage_uri
|
||||
self._storage_options = storage_options
|
||||
self._auto_check = auto_check
|
||||
self._swallow_errors = swallow_errors
|
||||
|
||||
self._key_func = key_func
|
||||
self._key_prefix = key_prefix
|
||||
|
||||
for limit in set(default_limits):
|
||||
self._default_limits.extend(
|
||||
[LimitGroup(limit, self._key_func, None, False, None, None, None)]
|
||||
)
|
||||
for limit in application_limits:
|
||||
self._application_limits.extend(
|
||||
[LimitGroup(limit, self._key_func, "global", False, None, None, None)]
|
||||
)
|
||||
for limit in in_memory_fallback:
|
||||
self._in_memory_fallback.extend(
|
||||
[LimitGroup(limit, self._key_func, None, False, None, None, None)]
|
||||
)
|
||||
self._route_limits: Dict = {}
|
||||
self._dynamic_route_limits: Dict = {}
|
||||
# a flag to note if the storage backend is dead (not available)
|
||||
self._storage_dead: bool = False
|
||||
self._fallback_limiter = None
|
||||
self.__check_backend_count = 0
|
||||
self.__last_check_backend = time.time()
|
||||
self.__marked_for_limiting: Dict = {}
|
||||
|
||||
class BlackHoleHandler(logging.StreamHandler):
|
||||
def emit(*_):
|
||||
return
|
||||
|
||||
self.logger.addHandler(BlackHoleHandler())
|
||||
|
||||
self.enabled = self.get_app_config(C.ENABLED, self.enabled)
|
||||
self._swallow_errors = self.get_app_config(
|
||||
C.SWALLOW_ERRORS, self._swallow_errors
|
||||
)
|
||||
self._headers_enabled = self._headers_enabled or self.get_app_config(
|
||||
C.HEADERS_ENABLED, False
|
||||
)
|
||||
self._storage_options.update(self.get_app_config(C.STORAGE_OPTIONS, {}))
|
||||
self._storage: Storage = storage_from_string(
|
||||
self._storage_uri or self.get_app_config(C.STORAGE_URL, "memory://"),
|
||||
**self._storage_options,
|
||||
)
|
||||
strategy = self._strategy or self.get_app_config(C.STRATEGY, "fixed-window")
|
||||
if strategy not in STRATEGIES:
|
||||
raise ConfigurationError("Invalid rate limiting strategy %s" % strategy)
|
||||
self._limiter: RateLimiter = STRATEGIES[strategy](self._storage)
|
||||
self._header_mapping.update(
|
||||
{
|
||||
HEADERS.RESET: self._header_mapping.get(
|
||||
HEADERS.RESET,
|
||||
self.get_app_config(C.HEADER_RESET, "X-RateLimit-Reset"),
|
||||
),
|
||||
HEADERS.REMAINING: self._header_mapping.get(
|
||||
HEADERS.REMAINING,
|
||||
self.get_app_config(C.HEADER_REMAINING, "X-RateLimit-Remaining"),
|
||||
),
|
||||
HEADERS.LIMIT: self._header_mapping.get(
|
||||
HEADERS.LIMIT,
|
||||
self.get_app_config(C.HEADER_LIMIT, "X-RateLimit-Limit"),
|
||||
),
|
||||
HEADERS.RETRY_AFTER: self._header_mapping.get(
|
||||
HEADERS.RETRY_AFTER,
|
||||
self.get_app_config(C.HEADER_RETRY_AFTER, "Retry-After"),
|
||||
),
|
||||
}
|
||||
)
|
||||
self._retry_after = self._retry_after or self.get_app_config(
|
||||
C.HEADER_RETRY_AFTER_VALUE
|
||||
)
|
||||
self._key_prefix = self._key_prefix or self.get_app_config(C.KEY_PREFIX)
|
||||
app_limits = self.get_app_config(C.APPLICATION_LIMITS, None)
|
||||
if not self._application_limits and app_limits:
|
||||
self._application_limits = [
|
||||
LimitGroup(
|
||||
app_limits, self._key_func, "global", False, None, None, None
|
||||
)
|
||||
]
|
||||
|
||||
conf_limits = self.get_app_config(C.DEFAULT_LIMITS, None)
|
||||
if not self._default_limits and conf_limits:
|
||||
self._default_limits = [
|
||||
LimitGroup(conf_limits, self._key_func, None, False, None, None, None)
|
||||
]
|
||||
fallback_enabled = self.get_app_config(C.IN_MEMORY_FALLBACK_ENABLED, False)
|
||||
fallback_limits = self.get_app_config(C.IN_MEMORY_FALLBACK, None)
|
||||
if not self._in_memory_fallback and fallback_limits:
|
||||
self._in_memory_fallback = [
|
||||
LimitGroup(
|
||||
fallback_limits, self._key_func, None, False, None, None, None
|
||||
)
|
||||
]
|
||||
if not self._in_memory_fallback_enabled:
|
||||
self._in_memory_fallback_enabled = (
|
||||
fallback_enabled or len(self._in_memory_fallback) > 0
|
||||
)
|
||||
|
||||
if self._in_memory_fallback_enabled:
|
||||
self._fallback_storage = MemoryStorage()
|
||||
self._fallback_limiter = STRATEGIES[strategy](self._fallback_storage)
|
||||
|
||||
def slowapi_startup(self):
|
||||
"""
|
||||
Starlette startup event handler that links the app with the Limiter instance.
|
||||
"""
|
||||
print("STARTUP")
|
||||
app.state.limiter = self
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
def get_app_config(self, key: str, default_value: T = None) -> T:
|
||||
"""
|
||||
Place holder until we find a better way to load config from app
|
||||
"""
|
||||
return self.app_config(key, default=default_value, cast=type(default_value))
|
||||
|
||||
def __should_check_backend(self) -> bool:
|
||||
if self.__check_backend_count > MAX_BACKEND_CHECKS:
|
||||
self.__check_backend_count = 0
|
||||
if time.time() - self.__last_check_backend > pow(2, self.__check_backend_count):
|
||||
self.__last_check_backend = time.time()
|
||||
self.__check_backend_count += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
resets the storage if it supports being reset
|
||||
"""
|
||||
try:
|
||||
self._storage.reset()
|
||||
self.logger.info("Storage has been reset and all limits cleared")
|
||||
except NotImplementedError:
|
||||
self.logger.warning("This storage type does not support being reset")
|
||||
|
||||
@property
|
||||
def limiter(self) -> RateLimiter:
|
||||
"""
|
||||
The backend that keeps track of consumption of endpoints vs limits
|
||||
"""
|
||||
if self._storage_dead and self._in_memory_fallback_enabled:
|
||||
return self._fallback_limiter
|
||||
else:
|
||||
return self._limiter
|
||||
|
||||
def _inject_headers(
|
||||
self, response: Response, current_limit: Tuple[RateLimitItem, List[str]]
|
||||
) -> Response:
|
||||
if self.enabled and self._headers_enabled and current_limit is not None:
|
||||
try:
|
||||
window_stats: Tuple[int, int] = self.limiter.get_window_stats(
|
||||
current_limit[0], *current_limit[1]
|
||||
)
|
||||
reset_in = 1 + window_stats[0]
|
||||
response.headers.append(
|
||||
self._header_mapping[HEADERS.LIMIT], str(current_limit[0].amount)
|
||||
)
|
||||
response.headers.append(
|
||||
self._header_mapping[HEADERS.REMAINING], str(window_stats[1])
|
||||
)
|
||||
response.headers.append(
|
||||
self._header_mapping[HEADERS.RESET], str(reset_in)
|
||||
)
|
||||
|
||||
# response may have an existing retry after
|
||||
print(response.headers)
|
||||
existing_retry_after_header = response.headers.get("Retry-After")
|
||||
|
||||
if existing_retry_after_header is not None:
|
||||
# might be in http-date format
|
||||
retry_after = parsedate_to_datetime(existing_retry_after_header)
|
||||
|
||||
# parse_date failure returns None
|
||||
if retry_after is None:
|
||||
retry_after = time.time() + int(existing_retry_after_header)
|
||||
|
||||
if isinstance(retry_after, datetime.datetime):
|
||||
retry_after_int: int = int(time.mktime(retry_after.timetuple()))
|
||||
|
||||
reset_in = max(retry_after_int, reset_in)
|
||||
|
||||
response.headers[self._header_mapping[HEADERS.RETRY_AFTER]] = (
|
||||
formatdate(reset_in)
|
||||
if self._retry_after == "http-date"
|
||||
else str(int(reset_in - time.time()))
|
||||
)
|
||||
except:
|
||||
if self._in_memory_fallback and not self._storage_dead:
|
||||
self.logger.warn(
|
||||
"Rate limit storage unreachable - falling back to"
|
||||
" in-memory storage"
|
||||
)
|
||||
self._storage_dead = True
|
||||
response = self._inject_headers(response, current_limit)
|
||||
if self._swallow_errors:
|
||||
self.logger.exception(
|
||||
"Failed to update rate limit headers. Swallowing error"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
return response
|
||||
|
||||
def __evaluate_limits(
|
||||
self, request: Request, endpoint: str, limits: List[Limit]
|
||||
) -> None:
|
||||
failed_limit = None
|
||||
limit_for_header = None
|
||||
for lim in limits:
|
||||
limit_scope = lim.scope or endpoint
|
||||
if lim.is_exempt:
|
||||
continue
|
||||
if lim.methods is not None and request.method.lower() not in lim.methods:
|
||||
continue
|
||||
if lim.per_method:
|
||||
limit_scope += ":%s" % request.method
|
||||
|
||||
if "request" in inspect.signature(lim.key_func).parameters.keys():
|
||||
limit_key = lim.key_func(request)
|
||||
else:
|
||||
limit_key = lim.key_func()
|
||||
|
||||
args = [limit_key, limit_scope]
|
||||
if all(args):
|
||||
if self._key_prefix:
|
||||
args = [self._key_prefix] + args
|
||||
if not limit_for_header or lim.limit < limit_for_header[0]:
|
||||
limit_for_header = (lim.limit, args)
|
||||
if not self.limiter.hit(lim.limit, *args):
|
||||
self.logger.warning(
|
||||
"ratelimit %s (%s) exceeded at endpoint: %s",
|
||||
lim.limit,
|
||||
limit_key,
|
||||
limit_scope,
|
||||
)
|
||||
failed_limit = lim
|
||||
limit_for_header = (lim.limit, args)
|
||||
break
|
||||
else:
|
||||
self.logger.error(
|
||||
"Skipping limit: %s. Empty value found in parameters.", lim.limit
|
||||
)
|
||||
continue
|
||||
# keep track of which limit was hit, to be picked up for the response header
|
||||
request.state.view_rate_limit = limit_for_header
|
||||
|
||||
if failed_limit:
|
||||
raise RateLimitExceeded(failed_limit)
|
||||
|
||||
def __check_request_limit(
|
||||
self, request: Request, endpoint_func: Callable, in_middleware: bool = True
|
||||
) -> None:
|
||||
"""
|
||||
Determine if the request is within limits
|
||||
"""
|
||||
endpoint = request["path"] or ""
|
||||
# view_func = current_app.view_functions.get(endpoint, None)
|
||||
view_func = endpoint_func
|
||||
|
||||
name = "%s.%s" % (view_func.__module__, view_func.__name__) if view_func else ""
|
||||
# cases where we don't need to check the limits
|
||||
if (
|
||||
not endpoint
|
||||
or not self.enabled
|
||||
# or we are sending a static file
|
||||
# or view_func == current_app.send_static_file
|
||||
or name in self._exempt_routes
|
||||
or any(fn() for fn in self._request_filters)
|
||||
):
|
||||
return
|
||||
limits: List = []
|
||||
dynamic_limits: List = []
|
||||
|
||||
if not in_middleware:
|
||||
limits = name in self._route_limits and self._route_limits[name] or []
|
||||
dynamic_limits = []
|
||||
if name in self._dynamic_route_limits:
|
||||
for lim in self._dynamic_route_limits[name]:
|
||||
try:
|
||||
dynamic_limits.extend(list(lim))
|
||||
except ValueError as e:
|
||||
self.logger.error(
|
||||
"failed to load ratelimit for view function %s (%s)",
|
||||
name,
|
||||
e,
|
||||
)
|
||||
|
||||
try:
|
||||
all_limits: List = []
|
||||
if self._storage_dead and self._fallback_limiter:
|
||||
if in_middleware and name in self.__marked_for_limiting:
|
||||
pass
|
||||
else:
|
||||
if self.__should_check_backend() and self._storage.check():
|
||||
self.logger.info("Rate limit storage recovered")
|
||||
self._storage_dead = False
|
||||
self.__check_backend_count = 0
|
||||
else:
|
||||
all_limits = list(itertools.chain(*self._in_memory_fallback))
|
||||
if not all_limits:
|
||||
route_limits = limits + dynamic_limits
|
||||
all_limits = (
|
||||
list(itertools.chain(*self._application_limits))
|
||||
if in_middleware
|
||||
else []
|
||||
)
|
||||
all_limits += route_limits
|
||||
if not route_limits and not (
|
||||
in_middleware and name in self.__marked_for_limiting
|
||||
):
|
||||
all_limits += list(itertools.chain(*self._default_limits))
|
||||
# actually check the limits, so far we've only computed the list of limits to check
|
||||
self.__evaluate_limits(request, endpoint, all_limits)
|
||||
except Exception as e: # no qa
|
||||
if isinstance(e, RateLimitExceeded):
|
||||
raise
|
||||
if self._in_memory_fallback_enabled and not self._storage_dead:
|
||||
self.logger.warn(
|
||||
"Rate limit storage unreachable - falling back to"
|
||||
" in-memory storage"
|
||||
)
|
||||
self._storage_dead = True
|
||||
self.__check_request_limit(request, endpoint_func, in_middleware)
|
||||
else:
|
||||
if self._swallow_errors:
|
||||
self.logger.exception("Failed to rate limit. Swallowing error")
|
||||
else:
|
||||
raise
|
||||
|
||||
def __limit_decorator(
|
||||
self,
|
||||
limit_value: Union[str, Callable[..., str]],
|
||||
key_func: Optional[Callable[..., str]] = None,
|
||||
shared: bool = False,
|
||||
scope: Optional[Union[str, Callable[..., str]]] = None,
|
||||
per_method: bool = False,
|
||||
methods: Optional[List] = None,
|
||||
error_message: Optional[str] = None,
|
||||
exempt_when: Optional[Callable[..., bool]] = None,
|
||||
) -> Callable:
|
||||
|
||||
_scope = scope if shared else None
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
keyfunc = key_func or self._key_func
|
||||
name = f"{func.__module__}.{func.__name__}"
|
||||
dynamic_limit, static_limits = None, []
|
||||
if callable(limit_value):
|
||||
dynamic_limit = LimitGroup(
|
||||
limit_value,
|
||||
keyfunc,
|
||||
_scope,
|
||||
per_method,
|
||||
methods,
|
||||
error_message,
|
||||
exempt_when,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
static_limits = list(
|
||||
LimitGroup(
|
||||
limit_value,
|
||||
keyfunc,
|
||||
_scope,
|
||||
per_method,
|
||||
methods,
|
||||
error_message,
|
||||
exempt_when,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
self.logger.error(
|
||||
"Failed to configure throttling for %s (%s)", name, e,
|
||||
)
|
||||
self.__marked_for_limiting.setdefault(name, []).append(func)
|
||||
if dynamic_limit:
|
||||
self._dynamic_route_limits.setdefault(name, []).append(dynamic_limit)
|
||||
else:
|
||||
self._route_limits.setdefault(name, []).extend(static_limits)
|
||||
|
||||
connection_type: Optional[str] = None
|
||||
sig = inspect.signature(func)
|
||||
for idx, parameter in enumerate(sig.parameters.values()):
|
||||
if parameter.name == "request" or parameter.name == "websocket":
|
||||
connection_type = parameter.name
|
||||
break
|
||||
else:
|
||||
raise Exception(
|
||||
f'No "request" or "websocket" argument on function "{func}"'
|
||||
)
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
# Handle async request/response functions.
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args: Any, **kwargs: Any) -> Response:
|
||||
# get the request object from the decorated endpoint function
|
||||
request = kwargs.get("request", args[idx] if args else None)
|
||||
assert isinstance(request, Request)
|
||||
|
||||
if self._auto_check and not getattr(
|
||||
request.state, "_rate_limiting_complete", False
|
||||
):
|
||||
self.__check_request_limit(request, func, False)
|
||||
request.state._rate_limiting_complete = True
|
||||
response = await func(*args, **kwargs)
|
||||
self._inject_headers(response, request.state.view_rate_limit)
|
||||
return response
|
||||
|
||||
return async_wrapper
|
||||
|
||||
else:
|
||||
# Handle sync request/response functions.
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args: Any, **kwargs: Any) -> Response:
|
||||
# get the request object from the decorated endpoint function
|
||||
request = kwargs.get("request", args[idx] if args else None)
|
||||
assert isinstance(request, Request)
|
||||
|
||||
if self._auto_check and not getattr(
|
||||
request.state, "_rate_limiting_complete", False
|
||||
):
|
||||
self.__check_request_limit(request, func, False)
|
||||
request.state._rate_limiting_complete = True
|
||||
response = func(*args, **kwargs)
|
||||
self._inject_headers(response, request.state.view_rate_limit)
|
||||
return response
|
||||
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
def limit(
|
||||
self,
|
||||
limit_value: Union[str, Callable[[str], str]],
|
||||
key_func: Optional[Callable[..., str]] = None,
|
||||
per_method: bool = False,
|
||||
methods: Optional[List] = None,
|
||||
error_message: Optional[str] = None,
|
||||
exempt_when=None,
|
||||
):
|
||||
"""
|
||||
decorator to be used for rate limiting individual routes.
|
||||
|
||||
:param limit_value: rate limit string or a callable that returns a string.
|
||||
:ref:`ratelimit-string` for more details.
|
||||
:param function key_func: function/lambda to extract the unique identifier for
|
||||
the rate limit. defaults to remote address of the request.
|
||||
:param bool per_method: whether the limit is sub categorized into the http
|
||||
method of the request.
|
||||
:param list methods: if specified, only the methods in this list will be rate
|
||||
limited (default: None).
|
||||
:param error_message: string (or callable that returns one) to override the
|
||||
error message used in the response.
|
||||
:param exempt_when:
|
||||
:return:
|
||||
"""
|
||||
return self.__limit_decorator(
|
||||
limit_value,
|
||||
key_func,
|
||||
per_method=per_method,
|
||||
methods=methods,
|
||||
error_message=error_message,
|
||||
exempt_when=exempt_when,
|
||||
)
|
||||
|
||||
def shared_limit(
|
||||
self,
|
||||
limit_value: Union[str, Callable[[str], str]],
|
||||
scope: Union[str, Callable[..., str]],
|
||||
key_func: Optional[Callable[..., str]] = None,
|
||||
error_message: Optional[str] = None,
|
||||
exempt_when=None,
|
||||
):
|
||||
"""
|
||||
decorator to be applied to multiple routes sharing the same rate limit.
|
||||
|
||||
:param limit_value: rate limit string or a callable that returns a string.
|
||||
:ref:`ratelimit-string` for more details.
|
||||
:param scope: a string or callable that returns a string
|
||||
for defining the rate limiting scope.
|
||||
:param function key_func: function/lambda to extract the unique identifier for
|
||||
the rate limit. defaults to remote address of the request.
|
||||
:param error_message: string (or callable that returns one) to override the
|
||||
error message used in the response.
|
||||
:param exempt_when:
|
||||
"""
|
||||
return self.__limit_decorator(
|
||||
limit_value,
|
||||
key_func,
|
||||
True,
|
||||
scope,
|
||||
error_message=error_message,
|
||||
exempt_when=exempt_when,
|
||||
)
|
||||
26
slowapi/util.py
Normal file
26
slowapi/util.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime, timedelta
|
||||
from email.utils import parsedate_tz
|
||||
from typing import Optional
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def get_ipaddr(request: Request) -> str:
|
||||
"""
|
||||
:return: the ip address for the current request (or 127.0.0.1 if none found)
|
||||
based on the X-Forwarded-For headers.
|
||||
Note that a more robust method for determining IP address of the client is
|
||||
provided by uvicorn's ProxyHeadersMiddleware.
|
||||
"""
|
||||
if "X_FORWARDED_FOR" in request.headers:
|
||||
r = request.headers["X_FORWARDED_FOR"]
|
||||
return r
|
||||
else:
|
||||
return request.client.host or "127.0.0.1"
|
||||
|
||||
|
||||
def get_remote_address(request: Request) -> str:
|
||||
"""
|
||||
:return: the ip address for the current request (or 127.0.0.1 if none found)
|
||||
"""
|
||||
return request.client.host or "127.0.0.1"
|
||||
89
slowapi/wrappers.py
Normal file
89
slowapi/wrappers.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from typing import Callable, Iterator, List, Optional, Union
|
||||
|
||||
from limits import RateLimitItem, parse_many # type: ignore
|
||||
|
||||
|
||||
class Limit(object):
|
||||
"""
|
||||
simple wrapper to encapsulate limits and their context
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: RateLimitItem,
|
||||
key_func: Callable[..., str],
|
||||
scope: Optional[Union[str, Callable[..., str]]],
|
||||
per_method: bool,
|
||||
methods: Optional[List[str]],
|
||||
error_message: Optional[Union[str, Callable[..., str]]],
|
||||
exempt_when: Optional[Callable[..., bool]],
|
||||
) -> None:
|
||||
self.limit = limit
|
||||
self.key_func = key_func
|
||||
self.__scope = scope
|
||||
self.per_method = per_method
|
||||
self.methods = methods
|
||||
self.error_message = error_message
|
||||
self.exempt_when = exempt_when
|
||||
|
||||
@property
|
||||
def is_exempt(self) -> bool:
|
||||
"""
|
||||
Check if the limit is exempt.
|
||||
Return True to exempt the route from the limit.
|
||||
"""
|
||||
return self.exempt_when() if self.exempt_when is not None else False
|
||||
|
||||
@property
|
||||
def scope(self) -> str:
|
||||
# flack.request.endpoint is the name of the function for the endpoint
|
||||
# FIXME: how to get the request here?
|
||||
if self.__scope is None:
|
||||
return ""
|
||||
else:
|
||||
return (
|
||||
self.__scope(request.endpoint)
|
||||
if callable(self.__scope)
|
||||
else self.__scope
|
||||
)
|
||||
|
||||
|
||||
class LimitGroup(object):
|
||||
"""
|
||||
represents a group of related limits either from a string or a callable that returns one
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit_provider: Union[str, Callable[..., str]],
|
||||
key_function: Callable[..., str],
|
||||
scope: Optional[Union[str, Callable[..., str]]],
|
||||
per_method: bool,
|
||||
methods: Optional[List[str]],
|
||||
error_message: Optional[Union[str, Callable[..., str]]],
|
||||
exempt_when: Optional[Callable[..., bool]],
|
||||
):
|
||||
self.__limit_provider = limit_provider
|
||||
self.__scope = scope
|
||||
self.key_function = key_function
|
||||
self.per_method = per_method
|
||||
self.methods = methods and [m.lower() for m in methods] or methods
|
||||
self.error_message = error_message
|
||||
self.exempt_when = exempt_when
|
||||
|
||||
def __iter__(self) -> Iterator[Limit]:
|
||||
limit_items: List[RateLimitItem] = parse_many(
|
||||
self.__limit_provider()
|
||||
if callable(self.__limit_provider)
|
||||
else self.__limit_provider
|
||||
)
|
||||
for limit in limit_items:
|
||||
yield Limit(
|
||||
limit,
|
||||
self.key_function,
|
||||
self.__scope,
|
||||
self.per_method,
|
||||
self.methods,
|
||||
self.error_message,
|
||||
self.exempt_when,
|
||||
)
|
||||
36
tests/__init__.py
Normal file
36
tests/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import logging
|
||||
import platform
|
||||
import unittest
|
||||
from functools import wraps
|
||||
|
||||
import redis
|
||||
from fastapi import FastAPI
|
||||
from mock import mock
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.extension import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
|
||||
class TestSlowapi:
|
||||
def build_starlette_app(self, config={}, **limiter_args):
|
||||
limiter_args.setdefault("key_func", get_remote_address)
|
||||
limiter = Limiter(**limiter_args)
|
||||
app = Starlette(debug=True)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
mock_handler = mock.Mock()
|
||||
mock_handler.level = logging.INFO
|
||||
limiter.logger.addHandler(mock_handler)
|
||||
return app, limiter
|
||||
|
||||
def build_fastapi_app(self, config={}, **limiter_args):
|
||||
limiter_args.setdefault("key_func", get_remote_address)
|
||||
limiter = Limiter(**limiter_args)
|
||||
app = FastAPI()
|
||||
mock_handler = mock.Mock()
|
||||
mock_handler.level = logging.INFO
|
||||
limiter.logger.addHandler(mock_handler)
|
||||
return app, limiter
|
||||
2
tests/test_base.py
Normal file
2
tests/test_base.py
Normal file
@@ -0,0 +1,2 @@
|
||||
def test_import():
|
||||
import slowapi
|
||||
50
tests/test_fastapi_extension.py
Normal file
50
tests/test_fastapi_extension.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import hiro
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from slowapi.extension import Limiter
|
||||
from slowapi.util import get_ipaddr
|
||||
from tests import TestSlowapi
|
||||
|
||||
|
||||
class TestDecorators(TestSlowapi):
|
||||
def test_single_decorator(self):
|
||||
app, limiter = self.build_fastapi_app(key_func=get_ipaddr)
|
||||
|
||||
@app.get("/t1")
|
||||
@limiter.limit("5/minute")
|
||||
async def t1(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
client = TestClient(app)
|
||||
for i in range(0, 10):
|
||||
response = client.get("/t1")
|
||||
assert response.status_code == 200 if i < 5 else 429
|
||||
|
||||
def test_multiple_decorators(self):
|
||||
app, limiter = self.build_fastapi_app(key_func=get_ipaddr)
|
||||
|
||||
@app.get("/t1")
|
||||
@limiter.limit(
|
||||
"100 per minute", lambda: "test"
|
||||
) # effectively becomes a limit for all users
|
||||
@limiter.limit("50/minute") # per ip as per default key_func
|
||||
async def t1(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
with hiro.Timeline().freeze() as timeline:
|
||||
cli = TestClient(app)
|
||||
for i in range(0, 100):
|
||||
response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"})
|
||||
assert response.status_code == 200 if i < 50 else 429
|
||||
for i in range(50):
|
||||
assert cli.get("/t1").status_code == 200
|
||||
|
||||
assert cli.get("/t1").status_code == 429
|
||||
assert (
|
||||
cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code
|
||||
== 429
|
||||
)
|
||||
170
tests/test_starlette_extension.py
Normal file
170
tests/test_starlette_extension.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import time
|
||||
|
||||
import hiro # type: ignore
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from slowapi.util import get_ipaddr, get_remote_address
|
||||
from tests import TestSlowapi
|
||||
|
||||
|
||||
class TestDecorators(TestSlowapi):
|
||||
def test_single_decorator_async(self):
|
||||
app, limiter = self.build_starlette_app(key_func=get_ipaddr)
|
||||
|
||||
@limiter.limit("5/minute")
|
||||
async def t1(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
app.add_route("/t1", t1)
|
||||
|
||||
client = TestClient(app)
|
||||
for i in range(0, 10):
|
||||
response = client.get("/t1")
|
||||
assert response.status_code == 200 if i < 5 else 429
|
||||
if i < 5:
|
||||
assert response.text == "test"
|
||||
|
||||
def test_single_decorator_sync(self):
|
||||
app, limiter = self.build_starlette_app(key_func=get_ipaddr)
|
||||
|
||||
@limiter.limit("5/minute")
|
||||
def t1(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
app.add_route("/t1", t1)
|
||||
|
||||
client = TestClient(app)
|
||||
for i in range(0, 10):
|
||||
response = client.get("/t1")
|
||||
assert response.status_code == 200 if i < 5 else 429
|
||||
if i < 5:
|
||||
assert response.text == "test"
|
||||
|
||||
def test_shared_decorator(self):
|
||||
app, limiter = self.build_starlette_app(key_func=get_ipaddr)
|
||||
|
||||
shared_lim = limiter.shared_limit("5/minute", "somescope")
|
||||
|
||||
@shared_lim
|
||||
def t1(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
@shared_lim
|
||||
def t2(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
app.add_route("/t1", t1)
|
||||
app.add_route("/t2", t2)
|
||||
|
||||
client = TestClient(app)
|
||||
for i in range(0, 10):
|
||||
response = client.get("/t1")
|
||||
assert response.status_code == 200 if i < 5 else 429
|
||||
# the shared limit has already been hit via t1
|
||||
assert client.get("/t2").status_code == 429
|
||||
|
||||
def test_multiple_decorators(self):
|
||||
app, limiter = self.build_starlette_app(key_func=get_ipaddr)
|
||||
|
||||
@limiter.limit("100 per minute", lambda: "test")
|
||||
@limiter.limit("50/minute") # per ip as per default key_func
|
||||
async def t1(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
app.add_route("/t1", t1)
|
||||
|
||||
with hiro.Timeline().freeze() as timeline:
|
||||
cli = TestClient(app)
|
||||
for i in range(0, 100):
|
||||
response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"})
|
||||
assert response.status_code == 200 if i < 50 else 429
|
||||
for i in range(50):
|
||||
assert cli.get("/t1").status_code == 200
|
||||
|
||||
assert cli.get("/t1").status_code == 429
|
||||
assert (
|
||||
cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code
|
||||
== 429
|
||||
)
|
||||
|
||||
def test_headers_no_breach(self):
|
||||
app, limiter = self.build_starlette_app(
|
||||
headers_enabled=True, key_func=get_remote_address
|
||||
)
|
||||
|
||||
@app.route("/t1")
|
||||
@limiter.limit("10/minute")
|
||||
def t1(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
@app.route("/t2")
|
||||
@limiter.limit("2/second; 5 per minute; 10/hour")
|
||||
def t2(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
with hiro.Timeline().freeze():
|
||||
with TestClient(app) as cli:
|
||||
resp = cli.get("/t1")
|
||||
assert resp.headers.get("X-RateLimit-Limit") == "10"
|
||||
assert resp.headers.get("X-RateLimit-Remaining") == "9"
|
||||
assert resp.headers.get("X-RateLimit-Reset") == str(
|
||||
int(time.time() + 61)
|
||||
)
|
||||
assert resp.headers.get("Retry-After") == str(60)
|
||||
resp = cli.get("/t2")
|
||||
assert resp.headers.get("X-RateLimit-Limit") == "2"
|
||||
assert resp.headers.get("X-RateLimit-Remaining") == "1"
|
||||
assert resp.headers.get("X-RateLimit-Reset") == str(
|
||||
int(time.time() + 2)
|
||||
)
|
||||
|
||||
assert resp.headers.get("Retry-After") == str(1)
|
||||
|
||||
def test_headers_breach(self):
|
||||
app, limiter = self.build_starlette_app(
|
||||
headers_enabled=True, key_func=get_remote_address
|
||||
)
|
||||
|
||||
@app.route("/t1")
|
||||
@limiter.limit("2/second; 10 per minute; 20/hour")
|
||||
def t(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
with hiro.Timeline().freeze() as timeline:
|
||||
with TestClient(app) as cli:
|
||||
for i in range(11):
|
||||
resp = cli.get("/t1")
|
||||
timeline.forward(1)
|
||||
|
||||
print(resp.headers)
|
||||
assert resp.headers.get("X-RateLimit-Limit") == "10"
|
||||
assert resp.headers.get("X-RateLimit-Remaining") == "0"
|
||||
assert resp.headers.get("X-RateLimit-Reset") == str(
|
||||
int(time.time() + 50)
|
||||
)
|
||||
assert resp.headers.get("Retry-After") == str(int(50))
|
||||
|
||||
def test_retry_after(self):
|
||||
# FIXME: this test is not actually running!
|
||||
|
||||
app, limiter = self.build_starlette_app(
|
||||
headers_enabled=True, key_func=get_remote_address
|
||||
)
|
||||
|
||||
@app.route("/t1")
|
||||
@limiter.limit("1/minute")
|
||||
def t(request: Request):
|
||||
return PlainTextResponse("test")
|
||||
|
||||
with hiro.Timeline().freeze() as timeline:
|
||||
with TestClient(app) as cli:
|
||||
resp = cli.get("/t1")
|
||||
retry_after = int(resp.headers.get("Retry-After"))
|
||||
assert retry_after > 0
|
||||
timeline.forward(retry_after)
|
||||
resp = cli.get("/t1")
|
||||
assert resp.status_code == 200
|
||||
Reference in New Issue
Block a user