wred-ratelimit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wred_ratelimit-0.1.0/.github/workflows/tests.yml +47 -0
- wred_ratelimit-0.1.0/.gitignore +8 -0
- wred_ratelimit-0.1.0/LICENSE +29 -0
- wred_ratelimit-0.1.0/PKG-INFO +139 -0
- wred_ratelimit-0.1.0/README.md +121 -0
- wred_ratelimit-0.1.0/benchmarks/README.md +54 -0
- wred_ratelimit-0.1.0/benchmarks/redis_load.py +139 -0
- wred_ratelimit-0.1.0/benchmarks/results-2026-11-01.json +104 -0
- wred_ratelimit-0.1.0/pyproject.toml +20 -0
- wred_ratelimit-0.1.0/tests/test_hardening.py +141 -0
- wred_ratelimit-0.1.0/tests/test_limits.py +73 -0
- wred_ratelimit-0.1.0/tests/test_redis.py +97 -0
- wred_ratelimit-0.1.0/wred_ratelimit/__init__.py +168 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
timeout-minutes: 10
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
include:
|
|
16
|
+
- python: "3.11"
|
|
17
|
+
dependencies: minimum
|
|
18
|
+
- python: "3.11"
|
|
19
|
+
dependencies: latest
|
|
20
|
+
- python: "3.14"
|
|
21
|
+
dependencies: latest
|
|
22
|
+
services:
|
|
23
|
+
redis:
|
|
24
|
+
image: redis:7-alpine
|
|
25
|
+
ports:
|
|
26
|
+
- 6379:6379
|
|
27
|
+
options: >-
|
|
28
|
+
--health-cmd "redis-cli ping"
|
|
29
|
+
--health-interval 5s
|
|
30
|
+
--health-timeout 3s
|
|
31
|
+
--health-retries 10
|
|
32
|
+
env:
|
|
33
|
+
WRED_TEST_REDIS: redis://localhost:6379/15
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@v4
|
|
36
|
+
- uses: actions/setup-python@v5
|
|
37
|
+
with:
|
|
38
|
+
python-version: ${{ matrix.python }}
|
|
39
|
+
- name: Install Redis for isolated restart test
|
|
40
|
+
run: sudo apt-get update && sudo apt-get install -y redis-server
|
|
41
|
+
- name: Install supported minimum dependencies
|
|
42
|
+
if: matrix.dependencies == 'minimum'
|
|
43
|
+
run: python -m pip install -e '.[test,redis]' 'limits==4.2.0' 'redis==5.2.0'
|
|
44
|
+
- name: Install latest compatible dependencies
|
|
45
|
+
if: matrix.dependencies == 'latest'
|
|
46
|
+
run: python -m pip install -e '.[test,redis]'
|
|
47
|
+
- run: python -m pytest -q
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Harrison Erd
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
17
|
+
may be used to endorse or promote products derived from this software
|
|
18
|
+
without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wred-ratelimit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Optional ratelimit integration for Wred
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: wred>=0.13,<0.14
|
|
10
|
+
Requires-Dist: limits>=4.2,<6
|
|
11
|
+
Requires-Dist: redis>=5.2 ; extra == "redis"
|
|
12
|
+
Requires-Dist: pytest>=8 ; extra == "test"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.24 ; extra == "test"
|
|
14
|
+
Requires-Dist: httpx>=0.27 ; extra == "test"
|
|
15
|
+
Provides-Extra: redis
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
|
|
18
|
+
# wred-ratelimit
|
|
19
|
+
|
|
20
|
+
Async per-handler quotas using `limits`. Python 3.11+, Wred 0.13.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install '.[redis]'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from wred import App
|
|
28
|
+
from wred_ratelimit import Limiter
|
|
29
|
+
|
|
30
|
+
app = App()
|
|
31
|
+
limiter = Limiter.redis("redis://localhost:6379/0", namespace="orders-api")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.route("/orders/{order_id}")
|
|
35
|
+
@limiter.limit("30/minute")
|
|
36
|
+
async def order(request, order_id):
|
|
37
|
+
return {"id": order_id}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`Limiter(storage=None, namespace='wred', strategy='moving', key=client_ip,
|
|
41
|
+
fail_open=False, timeout=0.5)` uses an in-process store unless provided a limits
|
|
42
|
+
async store. Choose `moving` or `fixed` windows. The memory store belongs to each
|
|
43
|
+
limiter instance; use shared Redis for quotas across workers. All workers must
|
|
44
|
+
share a namespace, Redis database, bucket, and rule for shared quotas.
|
|
45
|
+
|
|
46
|
+
`Limiter.redis(url, storage_options=None, **options)` configures redis-py.
|
|
47
|
+
`options` configure the limiter; `storage_options` configure the limits Redis
|
|
48
|
+
storage and its client. Requires `limits>=4.2` and `redis>=5.2`.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
limiter = Limiter.redis(
|
|
52
|
+
"redis://localhost:6379/0",
|
|
53
|
+
namespace="orders-api",
|
|
54
|
+
timeout=0.15, # total storage budget per admission/check, in seconds
|
|
55
|
+
storage_options={
|
|
56
|
+
"socket_connect_timeout": 0.1,
|
|
57
|
+
"socket_timeout": 0.1,
|
|
58
|
+
"max_connections": 50, # per limiter instance, normally per worker
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Defaults are a 0.5-second total storage deadline, 0.2-second connect/read timeouts,
|
|
64
|
+
100 connections per pool, and no automatic command retries. Tune these to your
|
|
65
|
+
application's latency and concurrency budget. Redis URL query parameters take
|
|
66
|
+
precedence over equivalent client options. A supplied `connection_pool` owns its
|
|
67
|
+
own client settings. The total deadline still applies. A timeout can occur after
|
|
68
|
+
Redis consumed quota but before the response arrived; automatically retrying a
|
|
69
|
+
mutation can charge twice. Override the retry policy only with that tradeoff in mind.
|
|
70
|
+
|
|
71
|
+
Use Python 3.12+ for latency-sensitive deployments. Python 3.11 remains compatible,
|
|
72
|
+
but a connection-startup cancellation test with redis-py 5.2 showed cancellation
|
|
73
|
+
delayed until the next timeout. This is consistent with the upstream
|
|
74
|
+
[asyncio cancellation race](https://github.com/python/cpython/issues/86296).
|
|
75
|
+
Deadlines rely on cooperative cancellation; keep socket timeouts short as a second
|
|
76
|
+
bound, especially on 3.11 or with custom storage implementations.
|
|
77
|
+
|
|
78
|
+
`limit(rule, key=None, bucket=None)` supports stacked rules. The default bucket is
|
|
79
|
+
handler module + qualified name; set `bucket` explicitly when reusing a handler for
|
|
80
|
+
distinct routes. Rejected requests consume outer quotas in a decorator stack.
|
|
81
|
+
Keys may be sync or async request callbacks and are hashed before storage.
|
|
82
|
+
|
|
83
|
+
The default identity uses the ASGI peer address, ignoring forwarded headers. Configure
|
|
84
|
+
trusted-proxy handling in the server before using client IPs. A user/API-key callback
|
|
85
|
+
must return a stable identity. Authentication must run before a user-based quota;
|
|
86
|
+
place a separate IP quota outside authentication to limit failed attempts.
|
|
87
|
+
|
|
88
|
+
Exhaustion returns JSON 429 and `Retry-After`. Storage failures and deadlines return
|
|
89
|
+
503 unless `fail_open=True`. Once Redis denies a request, a failure fetching reset
|
|
90
|
+
statistics preserves the 429, using a full window as a conservative `Retry-After`.
|
|
91
|
+
Allowed HTTP requests do not fetch statistics. Rules are parsed when decorated.
|
|
92
|
+
|
|
93
|
+
`await limiter.check(rule, identity, bucket='...')` consumes quota and returns
|
|
94
|
+
`(allowed, retry_after)`, including reset information for allowed calls. It propagates
|
|
95
|
+
storage failures and `TimeoutError` for service-level callers. The deadline covers
|
|
96
|
+
storage operations, not identity callbacks or the handler. Unexpected errors outside
|
|
97
|
+
the storage backend's declared exception types propagate; cancellation also propagates.
|
|
98
|
+
|
|
99
|
+
The decorator logs handled failures at WARNING to `wred_ratelimit`, with record fields
|
|
100
|
+
`namespace`, `bucket`, `fail_open`, `error_type`, and `quota_denied`. Configure your log
|
|
101
|
+
formatter/exporter to include these fields. Identity values, Redis URLs, and exception
|
|
102
|
+
messages are omitted. Monitor these events alongside HTTP 429/503 rates, request tail
|
|
103
|
+
latency, Redis memory, and pool/connection usage. Direct `check()` callers own their
|
|
104
|
+
error reporting. Avoid identities as metric labels.
|
|
105
|
+
|
|
106
|
+
Use `async with limiter` or `await limiter.aclose()` in the hosting application's
|
|
107
|
+
resource lifecycle. Wred itself does not implement lifespan.
|
|
108
|
+
|
|
109
|
+
Tests: `pip install -e '.[test,redis]'`, then `pytest`.
|
|
110
|
+
Set `WRED_TEST_REDIS=redis://localhost:6379/15` to include real Redis tests.
|
|
111
|
+
Use a dedicated test instance/database. Cross-process tests verify shared quotas
|
|
112
|
+
for both strategies. The restart test starts its own disposable Redis and requires
|
|
113
|
+
the `redis-server` executable. CI runs real Redis with minimum and current dependencies.
|
|
114
|
+
|
|
115
|
+
For a repeatable local load check, install the project and run:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
REDIS_URL=redis://localhost:6379/15 python benchmarks/redis_load.py \
|
|
119
|
+
--workers 4 --concurrency 25 --requests 20000 --identities 1000 --quota 5
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Use a dedicated benchmark Redis. The script exits unsuccessfully on an incorrect
|
|
123
|
+
quota result or storage-error responses and prints throughput and p50/p95/p99
|
|
124
|
+
latency. It exercises decorated handlers across processes, without HTTP network or
|
|
125
|
+
application work. Each run creates a unique namespace whose keys expire in one hour.
|
|
126
|
+
Repeat with `--strategy fixed` and with many distinct identities to assess memory
|
|
127
|
+
and latency for your workload. This is a closed-loop load check, not an overload or
|
|
128
|
+
production-capacity certification; validate your full application under sustained
|
|
129
|
+
offered traffic and Redis failure/recovery before rollout.
|
|
130
|
+
See [the recorded local baseline](benchmarks/README.md) for measured results and limits.
|
|
131
|
+
|
|
132
|
+
Redis restarts without persistence, eviction, and failover that loses recent writes
|
|
133
|
+
can reset quotas. The restart test explicitly verifies recovery after state loss;
|
|
134
|
+
it does not promise quota continuity through failover. Choose persistence, memory
|
|
135
|
+
limits/eviction policy, and availability settings for your enforcement requirements.
|
|
136
|
+
Moving windows retain request timestamps and need more memory than fixed counters.
|
|
137
|
+
Keep worker clocks synchronized. Cluster/Sentinel deployments can supply an appropriate
|
|
138
|
+
limits async storage via `Limiter(storage=...)`; validate that topology separately.
|
|
139
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# wred-ratelimit
|
|
2
|
+
|
|
3
|
+
Async per-handler quotas using `limits`. Python 3.11+, Wred 0.13.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install '.[redis]'
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from wred import App
|
|
11
|
+
from wred_ratelimit import Limiter
|
|
12
|
+
|
|
13
|
+
app = App()
|
|
14
|
+
limiter = Limiter.redis("redis://localhost:6379/0", namespace="orders-api")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.route("/orders/{order_id}")
|
|
18
|
+
@limiter.limit("30/minute")
|
|
19
|
+
async def order(request, order_id):
|
|
20
|
+
return {"id": order_id}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`Limiter(storage=None, namespace='wred', strategy='moving', key=client_ip,
|
|
24
|
+
fail_open=False, timeout=0.5)` uses an in-process store unless provided a limits
|
|
25
|
+
async store. Choose `moving` or `fixed` windows. The memory store belongs to each
|
|
26
|
+
limiter instance; use shared Redis for quotas across workers. All workers must
|
|
27
|
+
share a namespace, Redis database, bucket, and rule for shared quotas.
|
|
28
|
+
|
|
29
|
+
`Limiter.redis(url, storage_options=None, **options)` configures redis-py.
|
|
30
|
+
`options` configure the limiter; `storage_options` configure the limits Redis
|
|
31
|
+
storage and its client. Requires `limits>=4.2` and `redis>=5.2`.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
limiter = Limiter.redis(
|
|
35
|
+
"redis://localhost:6379/0",
|
|
36
|
+
namespace="orders-api",
|
|
37
|
+
timeout=0.15, # total storage budget per admission/check, in seconds
|
|
38
|
+
storage_options={
|
|
39
|
+
"socket_connect_timeout": 0.1,
|
|
40
|
+
"socket_timeout": 0.1,
|
|
41
|
+
"max_connections": 50, # per limiter instance, normally per worker
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Defaults are a 0.5-second total storage deadline, 0.2-second connect/read timeouts,
|
|
47
|
+
100 connections per pool, and no automatic command retries. Tune these to your
|
|
48
|
+
application's latency and concurrency budget. Redis URL query parameters take
|
|
49
|
+
precedence over equivalent client options. A supplied `connection_pool` owns its
|
|
50
|
+
own client settings. The total deadline still applies. A timeout can occur after
|
|
51
|
+
Redis consumed quota but before the response arrived; automatically retrying a
|
|
52
|
+
mutation can charge twice. Override the retry policy only with that tradeoff in mind.
|
|
53
|
+
|
|
54
|
+
Use Python 3.12+ for latency-sensitive deployments. Python 3.11 remains compatible,
|
|
55
|
+
but a connection-startup cancellation test with redis-py 5.2 showed cancellation
|
|
56
|
+
delayed until the next timeout. This is consistent with the upstream
|
|
57
|
+
[asyncio cancellation race](https://github.com/python/cpython/issues/86296).
|
|
58
|
+
Deadlines rely on cooperative cancellation; keep socket timeouts short as a second
|
|
59
|
+
bound, especially on 3.11 or with custom storage implementations.
|
|
60
|
+
|
|
61
|
+
`limit(rule, key=None, bucket=None)` supports stacked rules. The default bucket is
|
|
62
|
+
handler module + qualified name; set `bucket` explicitly when reusing a handler for
|
|
63
|
+
distinct routes. Rejected requests consume outer quotas in a decorator stack.
|
|
64
|
+
Keys may be sync or async request callbacks and are hashed before storage.
|
|
65
|
+
|
|
66
|
+
The default identity uses the ASGI peer address, ignoring forwarded headers. Configure
|
|
67
|
+
trusted-proxy handling in the server before using client IPs. A user/API-key callback
|
|
68
|
+
must return a stable identity. Authentication must run before a user-based quota;
|
|
69
|
+
place a separate IP quota outside authentication to limit failed attempts.
|
|
70
|
+
|
|
71
|
+
Exhaustion returns JSON 429 and `Retry-After`. Storage failures and deadlines return
|
|
72
|
+
503 unless `fail_open=True`. Once Redis denies a request, a failure fetching reset
|
|
73
|
+
statistics preserves the 429, using a full window as a conservative `Retry-After`.
|
|
74
|
+
Allowed HTTP requests do not fetch statistics. Rules are parsed when decorated.
|
|
75
|
+
|
|
76
|
+
`await limiter.check(rule, identity, bucket='...')` consumes quota and returns
|
|
77
|
+
`(allowed, retry_after)`, including reset information for allowed calls. It propagates
|
|
78
|
+
storage failures and `TimeoutError` for service-level callers. The deadline covers
|
|
79
|
+
storage operations, not identity callbacks or the handler. Unexpected errors outside
|
|
80
|
+
the storage backend's declared exception types propagate; cancellation also propagates.
|
|
81
|
+
|
|
82
|
+
The decorator logs handled failures at WARNING to `wred_ratelimit`, with record fields
|
|
83
|
+
`namespace`, `bucket`, `fail_open`, `error_type`, and `quota_denied`. Configure your log
|
|
84
|
+
formatter/exporter to include these fields. Identity values, Redis URLs, and exception
|
|
85
|
+
messages are omitted. Monitor these events alongside HTTP 429/503 rates, request tail
|
|
86
|
+
latency, Redis memory, and pool/connection usage. Direct `check()` callers own their
|
|
87
|
+
error reporting. Avoid identities as metric labels.
|
|
88
|
+
|
|
89
|
+
Use `async with limiter` or `await limiter.aclose()` in the hosting application's
|
|
90
|
+
resource lifecycle. Wred itself does not implement lifespan.
|
|
91
|
+
|
|
92
|
+
Tests: `pip install -e '.[test,redis]'`, then `pytest`.
|
|
93
|
+
Set `WRED_TEST_REDIS=redis://localhost:6379/15` to include real Redis tests.
|
|
94
|
+
Use a dedicated test instance/database. Cross-process tests verify shared quotas
|
|
95
|
+
for both strategies. The restart test starts its own disposable Redis and requires
|
|
96
|
+
the `redis-server` executable. CI runs real Redis with minimum and current dependencies.
|
|
97
|
+
|
|
98
|
+
For a repeatable local load check, install the project and run:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
REDIS_URL=redis://localhost:6379/15 python benchmarks/redis_load.py \
|
|
102
|
+
--workers 4 --concurrency 25 --requests 20000 --identities 1000 --quota 5
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use a dedicated benchmark Redis. The script exits unsuccessfully on an incorrect
|
|
106
|
+
quota result or storage-error responses and prints throughput and p50/p95/p99
|
|
107
|
+
latency. It exercises decorated handlers across processes, without HTTP network or
|
|
108
|
+
application work. Each run creates a unique namespace whose keys expire in one hour.
|
|
109
|
+
Repeat with `--strategy fixed` and with many distinct identities to assess memory
|
|
110
|
+
and latency for your workload. This is a closed-loop load check, not an overload or
|
|
111
|
+
production-capacity certification; validate your full application under sustained
|
|
112
|
+
offered traffic and Redis failure/recovery before rollout.
|
|
113
|
+
See [the recorded local baseline](benchmarks/README.md) for measured results and limits.
|
|
114
|
+
|
|
115
|
+
Redis restarts without persistence, eviction, and failover that loses recent writes
|
|
116
|
+
can reset quotas. The restart test explicitly verifies recovery after state loss;
|
|
117
|
+
it does not promise quota continuity through failover. Choose persistence, memory
|
|
118
|
+
limits/eviction policy, and availability settings for your enforcement requirements.
|
|
119
|
+
Moving windows retain request timestamps and need more memory than fixed counters.
|
|
120
|
+
Keep worker clocks synchronized. Cluster/Sentinel deployments can supply an appropriate
|
|
121
|
+
limits async storage via `Limiter(storage=...)`; validate that topology separately.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Local Redis baseline
|
|
2
|
+
|
|
3
|
+
Measured on 2026-11-01 using Python 3.14.4, limits 5.8.0, redis-py 8.1.0,
|
|
4
|
+
Redis Server 8.0.5, and an Intel Core Ultra 7 256V host. Each scenario used a fresh
|
|
5
|
+
Redis process over a Unix socket with persistence disabled. Four worker processes
|
|
6
|
+
ran 25 concurrent decorated-handler calls each. No HTTP transport, TLS, proxy,
|
|
7
|
+
authentication, or application workload was included.
|
|
8
|
+
|
|
9
|
+
Each scenario ran 1,000,000 calls. The mixed workload used 10,000 identities and
|
|
10
|
+
a quota of 25/hour: exactly 250,000 allowed and 750,000 denied. The all-allowed
|
|
11
|
+
workload used 100,000 identities and a quota of 10/hour. All four runs matched
|
|
12
|
+
the expected quota counts, with no 503 responses. Distinct workers shared identities.
|
|
13
|
+
|
|
14
|
+
| Strategy | Workload | Duration | Calls/sec | p95 ms | p99 ms | Redis memory increase |
|
|
15
|
+
| --- | --- | ---: | ---: | ---: | ---: | ---: |
|
|
16
|
+
| Moving | Mixed | 23.479 s | 42,591 | 3.710 | 3.770 | 8.80 MiB |
|
|
17
|
+
| Moving | All allowed | 18.179 s | 55,008 | 2.012 | 2.563 | 48.65 MiB |
|
|
18
|
+
| Fixed | Mixed | 38.131 s | 26,225 | 4.551 | 4.677 | 2.54 MiB |
|
|
19
|
+
| Fixed | All allowed | 16.447 s | 60,800 | 1.705 | 1.727 | 23.47 MiB |
|
|
20
|
+
|
|
21
|
+
Memory increase is Redis `used_memory` after completion minus the idle-server
|
|
22
|
+
baseline, not total process RSS or peak memory. The raw measurements include Redis
|
|
23
|
+
peak memory in [results-2026-11-01.json](results-2026-11-01.json). Each run leaves
|
|
24
|
+
its uniquely namespaced quota keys for one hour; use a dedicated benchmark server.
|
|
25
|
+
|
|
26
|
+
Reproduce the two workloads for each strategy, with `REDIS_URL` pointing at a fresh
|
|
27
|
+
disposable Redis instance:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
python benchmarks/redis_load.py --strategy moving --requests 1000000 \
|
|
31
|
+
--workers 4 --concurrency 25 --identities 10000 --quota 25
|
|
32
|
+
python benchmarks/redis_load.py --strategy moving --requests 1000000 \
|
|
33
|
+
--workers 4 --concurrency 25 --identities 100000 --quota 10
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Repeat with `--strategy fixed`. The script reports handler latency and quota results;
|
|
37
|
+
memory measurements above were collected separately through Redis INFO before and
|
|
38
|
+
after each run.
|
|
39
|
+
|
|
40
|
+
These are single, short, closed-loop local measurements. They are not steady-state
|
|
41
|
+
capacity limits, an overload test, or a production SLO. Requests wait for earlier
|
|
42
|
+
requests in each concurrency lane, so the latency distribution excludes queueing
|
|
43
|
+
that would occur under sustained offered traffic above capacity. Mixed workloads
|
|
44
|
+
transition from admission to rejection as quotas fill. Validate the complete service
|
|
45
|
+
with realistic network latency, key distributions, bursts, expiry cycles, Redis
|
|
46
|
+
failover, and application work before using these numbers for sizing.
|
|
47
|
+
|
|
48
|
+
The correctness suite separately exercises cross-process quotas, stalled connections,
|
|
49
|
+
pool exhaustion, and restart recovery. It passes on Python 3.11.11 with minimum
|
|
50
|
+
dependencies (limits 4.2, redis-py 5.2), Python 3.13.2 with minimum and intermediate
|
|
51
|
+
dependencies, and Python 3.14.4 with current dependencies. On 3.11, cancellation
|
|
52
|
+
during Redis connection startup was delayed until the configured timeout; see the
|
|
53
|
+
runtime caveat in the main README. Restart recovery does not preserve quota state
|
|
54
|
+
when Redis has no persistence, and is not a replicated-failover continuity test.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Exercise decorated handlers in independent processes against a disposable Redis.
|
|
2
|
+
|
|
3
|
+
No HTTP server/network is included. Each run uses a unique namespace; its keys
|
|
4
|
+
expire after one hour. REDIS_URL must point at a dedicated benchmark instance.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import multiprocessing
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
17
|
+
|
|
18
|
+
from wred import Request
|
|
19
|
+
|
|
20
|
+
from wred_ratelimit import Limiter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def worker(index, options, namespace):
|
|
24
|
+
async def run():
|
|
25
|
+
latencies = []
|
|
26
|
+
statuses = Counter()
|
|
27
|
+
per_worker, remainder = divmod(options["requests"], options["workers"])
|
|
28
|
+
first = index * per_worker + min(index, remainder)
|
|
29
|
+
indices = iter(range(first, first + per_worker + (index < remainder)))
|
|
30
|
+
async with Limiter.redis(
|
|
31
|
+
os.environ["REDIS_URL"],
|
|
32
|
+
namespace=namespace,
|
|
33
|
+
strategy=options["strategy"],
|
|
34
|
+
timeout=options["timeout"],
|
|
35
|
+
storage_options={"max_connections": options["concurrency"]},
|
|
36
|
+
) as limiter:
|
|
37
|
+
|
|
38
|
+
@limiter.limit(f"{options['quota']}/hour", bucket="load")
|
|
39
|
+
async def handler(request):
|
|
40
|
+
return {}, [], 200
|
|
41
|
+
|
|
42
|
+
async def lane():
|
|
43
|
+
for number in indices:
|
|
44
|
+
request = Request(
|
|
45
|
+
{
|
|
46
|
+
"path": "/",
|
|
47
|
+
"method": "GET",
|
|
48
|
+
"client": (str(number % options["identities"]), 1),
|
|
49
|
+
},
|
|
50
|
+
None,
|
|
51
|
+
None,
|
|
52
|
+
)
|
|
53
|
+
start = time.perf_counter()
|
|
54
|
+
result = await handler(request)
|
|
55
|
+
latencies.append((time.perf_counter() - start) * 1000)
|
|
56
|
+
statuses[result[2]] += 1
|
|
57
|
+
|
|
58
|
+
start = time.perf_counter()
|
|
59
|
+
await asyncio.gather(*(lane() for _ in range(options["concurrency"])))
|
|
60
|
+
end = time.perf_counter()
|
|
61
|
+
return dict(statuses), latencies, start, end
|
|
62
|
+
|
|
63
|
+
return asyncio.run(run())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
68
|
+
parser.add_argument("--workers", type=int, default=4)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--concurrency", type=int, default=25, help="Concurrent calls per worker"
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument("--requests", type=int, default=20000)
|
|
73
|
+
parser.add_argument("--identities", type=int, default=1000)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--quota", type=int, default=5, help="Per-identity hourly quota"
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument("--strategy", choices=["moving", "fixed"], default="moving")
|
|
78
|
+
parser.add_argument("--timeout", type=float, default=0.5)
|
|
79
|
+
options = vars(parser.parse_args())
|
|
80
|
+
if "REDIS_URL" not in os.environ:
|
|
81
|
+
parser.error("Set REDIS_URL to a dedicated benchmark Redis instance")
|
|
82
|
+
if any(
|
|
83
|
+
options[key] <= 0
|
|
84
|
+
for key in ("workers", "concurrency", "requests", "identities", "quota")
|
|
85
|
+
):
|
|
86
|
+
parser.error("Counts must be positive")
|
|
87
|
+
if not math.isfinite(options["timeout"]) or options["timeout"] <= 0:
|
|
88
|
+
parser.error("Timeout must be finite and positive")
|
|
89
|
+
|
|
90
|
+
namespace = "wred-benchmark-" + uuid.uuid4().hex
|
|
91
|
+
with ProcessPoolExecutor(
|
|
92
|
+
max_workers=options["workers"], mp_context=multiprocessing.get_context("spawn")
|
|
93
|
+
) as pool:
|
|
94
|
+
futures = [
|
|
95
|
+
pool.submit(worker, index, options, namespace)
|
|
96
|
+
for index in range(options["workers"])
|
|
97
|
+
]
|
|
98
|
+
results = [future.result() for future in futures]
|
|
99
|
+
statuses = Counter()
|
|
100
|
+
latencies = []
|
|
101
|
+
for counts, timings, _, _ in results:
|
|
102
|
+
statuses.update(counts)
|
|
103
|
+
latencies.extend(timings)
|
|
104
|
+
latencies.sort()
|
|
105
|
+
elapsed = max(result[3] for result in results) - min(
|
|
106
|
+
result[2] for result in results
|
|
107
|
+
)
|
|
108
|
+
per_identity, remainder = divmod(options["requests"], options["identities"])
|
|
109
|
+
expected = remainder * min(options["quota"], per_identity + 1) + (
|
|
110
|
+
options["identities"] - remainder
|
|
111
|
+
) * min(options["quota"], per_identity)
|
|
112
|
+
correct = (
|
|
113
|
+
statuses[200] == expected and statuses[429] == options["requests"] - expected
|
|
114
|
+
)
|
|
115
|
+
print(
|
|
116
|
+
json.dumps(
|
|
117
|
+
{
|
|
118
|
+
**options,
|
|
119
|
+
"namespace": namespace,
|
|
120
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
121
|
+
"requests_per_second": round(len(latencies) / elapsed),
|
|
122
|
+
"latency_ms": {
|
|
123
|
+
f"p{percentile}": round(
|
|
124
|
+
latencies[math.ceil(len(latencies) * percentile / 100) - 1], 3
|
|
125
|
+
)
|
|
126
|
+
for percentile in (50, 95, 99)
|
|
127
|
+
},
|
|
128
|
+
"statuses": dict(statuses),
|
|
129
|
+
"expected_allowed": expected,
|
|
130
|
+
"quota_correct": correct,
|
|
131
|
+
},
|
|
132
|
+
indent=2,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
raise SystemExit(0 if correct else 1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"workers": 4,
|
|
4
|
+
"concurrency": 25,
|
|
5
|
+
"requests": 1000000,
|
|
6
|
+
"identities": 10000,
|
|
7
|
+
"quota": 25,
|
|
8
|
+
"strategy": "moving",
|
|
9
|
+
"timeout": 0.5,
|
|
10
|
+
"namespace": "wred-benchmark-ce726911dbe448ffa3ff416040f454ce",
|
|
11
|
+
"elapsed_seconds": 23.479,
|
|
12
|
+
"requests_per_second": 42591,
|
|
13
|
+
"latency_ms": {
|
|
14
|
+
"p50": 2.39,
|
|
15
|
+
"p95": 3.71,
|
|
16
|
+
"p99": 3.77
|
|
17
|
+
},
|
|
18
|
+
"statuses": {
|
|
19
|
+
"200": 250000,
|
|
20
|
+
"429": 750000
|
|
21
|
+
},
|
|
22
|
+
"expected_allowed": 250000,
|
|
23
|
+
"quota_correct": true,
|
|
24
|
+
"redis_version": "8.0.5",
|
|
25
|
+
"redis_used_memory_delta_bytes": 9231176,
|
|
26
|
+
"redis_peak_memory_bytes": 11374008
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"workers": 4,
|
|
30
|
+
"concurrency": 25,
|
|
31
|
+
"requests": 1000000,
|
|
32
|
+
"identities": 100000,
|
|
33
|
+
"quota": 10,
|
|
34
|
+
"strategy": "moving",
|
|
35
|
+
"timeout": 0.5,
|
|
36
|
+
"namespace": "wred-benchmark-7b1170ba730e4497b675dab6d6eecc47",
|
|
37
|
+
"elapsed_seconds": 18.179,
|
|
38
|
+
"requests_per_second": 55008,
|
|
39
|
+
"latency_ms": {
|
|
40
|
+
"p50": 1.776,
|
|
41
|
+
"p95": 2.012,
|
|
42
|
+
"p99": 2.563
|
|
43
|
+
},
|
|
44
|
+
"statuses": {
|
|
45
|
+
"200": 1000000
|
|
46
|
+
},
|
|
47
|
+
"expected_allowed": 1000000,
|
|
48
|
+
"quota_correct": true,
|
|
49
|
+
"redis_version": "8.0.5",
|
|
50
|
+
"redis_used_memory_delta_bytes": 51016896,
|
|
51
|
+
"redis_peak_memory_bytes": 51947200
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"workers": 4,
|
|
55
|
+
"concurrency": 25,
|
|
56
|
+
"requests": 1000000,
|
|
57
|
+
"identities": 10000,
|
|
58
|
+
"quota": 25,
|
|
59
|
+
"strategy": "fixed",
|
|
60
|
+
"timeout": 0.5,
|
|
61
|
+
"namespace": "wred-benchmark-5453cb19ab4344ebac6b6dd8997d2d47",
|
|
62
|
+
"elapsed_seconds": 38.131,
|
|
63
|
+
"requests_per_second": 26225,
|
|
64
|
+
"latency_ms": {
|
|
65
|
+
"p50": 4.412,
|
|
66
|
+
"p95": 4.551,
|
|
67
|
+
"p99": 4.677
|
|
68
|
+
},
|
|
69
|
+
"statuses": {
|
|
70
|
+
"200": 250000,
|
|
71
|
+
"429": 750000
|
|
72
|
+
},
|
|
73
|
+
"expected_allowed": 250000,
|
|
74
|
+
"quota_correct": true,
|
|
75
|
+
"redis_version": "8.0.5",
|
|
76
|
+
"redis_used_memory_delta_bytes": 2665056,
|
|
77
|
+
"redis_peak_memory_bytes": 5180400
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"workers": 4,
|
|
81
|
+
"concurrency": 25,
|
|
82
|
+
"requests": 1000000,
|
|
83
|
+
"identities": 100000,
|
|
84
|
+
"quota": 10,
|
|
85
|
+
"strategy": "fixed",
|
|
86
|
+
"timeout": 0.5,
|
|
87
|
+
"namespace": "wred-benchmark-c67ca2ff1ff44be2bac3b204b56c9bc4",
|
|
88
|
+
"elapsed_seconds": 16.447,
|
|
89
|
+
"requests_per_second": 60800,
|
|
90
|
+
"latency_ms": {
|
|
91
|
+
"p50": 1.66,
|
|
92
|
+
"p95": 1.705,
|
|
93
|
+
"p99": 1.727
|
|
94
|
+
},
|
|
95
|
+
"statuses": {
|
|
96
|
+
"200": 1000000
|
|
97
|
+
},
|
|
98
|
+
"expected_allowed": 1000000,
|
|
99
|
+
"quota_correct": true,
|
|
100
|
+
"redis_version": "8.0.5",
|
|
101
|
+
"redis_used_memory_delta_bytes": 24610688,
|
|
102
|
+
"redis_peak_memory_bytes": 25639376
|
|
103
|
+
}
|
|
104
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core>=3.12,<4"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wred-ratelimit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Optional ratelimit integration for Wred"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "BSD-3-Clause"
|
|
12
|
+
dependencies = ["wred>=0.13,<0.14", "limits>=4.2,<6"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
test = ["pytest>=8", "pytest-asyncio>=0.24", "httpx>=0.27"]
|
|
16
|
+
redis = ["redis>=5.2"]
|
|
17
|
+
|
|
18
|
+
[tool.pytest.ini_options]
|
|
19
|
+
asyncio_mode = "auto"
|
|
20
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from limits.aio.storage import MemoryStorage
|
|
6
|
+
from wred import Request
|
|
7
|
+
|
|
8
|
+
from wred_ratelimit import Limiter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def request():
|
|
12
|
+
return Request(
|
|
13
|
+
{"path": "/", "method": "GET", "client": ("127.0.0.1", 1)}, None, None
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize("strategy", ["moving", "fixed"])
|
|
18
|
+
async def test_admission_survives_stats_failure_without_bypassing_denial(
|
|
19
|
+
strategy, caplog
|
|
20
|
+
):
|
|
21
|
+
class StatsUnavailable(MemoryStorage):
|
|
22
|
+
async def get_moving_window(self, *args, **kwargs):
|
|
23
|
+
raise ValueError("stats unavailable")
|
|
24
|
+
|
|
25
|
+
async def get_expiry(self, *args, **kwargs):
|
|
26
|
+
raise ValueError("stats unavailable")
|
|
27
|
+
|
|
28
|
+
async with Limiter(
|
|
29
|
+
storage=StatsUnavailable(), strategy=strategy, fail_open=True
|
|
30
|
+
) as limiter:
|
|
31
|
+
|
|
32
|
+
@limiter.limit("1/minute")
|
|
33
|
+
async def handler(request):
|
|
34
|
+
return {"ok": True}
|
|
35
|
+
|
|
36
|
+
with caplog.at_level(logging.WARNING, logger="wred_ratelimit"):
|
|
37
|
+
assert await handler(request()) == {"ok": True}
|
|
38
|
+
assert not caplog.records
|
|
39
|
+
denied = await handler(request())
|
|
40
|
+
assert denied[2] == 429
|
|
41
|
+
assert dict(denied[1])["retry-after"] == "60"
|
|
42
|
+
assert caplog.records[-1].error_type == "ValueError"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize("fail_open", [False, True])
|
|
46
|
+
async def test_programming_errors_propagate(fail_open):
|
|
47
|
+
class BrokenStorage(MemoryStorage):
|
|
48
|
+
async def acquire_entry(self, *args, **kwargs):
|
|
49
|
+
raise TypeError("programming error")
|
|
50
|
+
|
|
51
|
+
async with Limiter(storage=BrokenStorage(), fail_open=fail_open) as limiter:
|
|
52
|
+
|
|
53
|
+
@limiter.limit("1/minute")
|
|
54
|
+
async def handler(request):
|
|
55
|
+
return {"ok": True}
|
|
56
|
+
|
|
57
|
+
with pytest.raises(TypeError, match="programming error"):
|
|
58
|
+
await handler(request())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
async def silent_redis():
|
|
63
|
+
connected = asyncio.Event()
|
|
64
|
+
writers = []
|
|
65
|
+
|
|
66
|
+
async def unresponsive(reader, writer):
|
|
67
|
+
writers.append(writer)
|
|
68
|
+
connected.set()
|
|
69
|
+
await reader.read()
|
|
70
|
+
|
|
71
|
+
server = await asyncio.start_server(unresponsive, "127.0.0.1", 0)
|
|
72
|
+
port = server.sockets[0].getsockname()[1]
|
|
73
|
+
try:
|
|
74
|
+
yield f"redis://127.0.0.1:{port}", connected
|
|
75
|
+
finally:
|
|
76
|
+
server.close()
|
|
77
|
+
for writer in writers:
|
|
78
|
+
writer.close()
|
|
79
|
+
await writer.wait_closed()
|
|
80
|
+
await server.wait_closed()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize("fail_open", [False, True])
|
|
84
|
+
async def test_slow_redis_is_bounded_and_observable(fail_open, caplog, silent_redis):
|
|
85
|
+
url, _ = silent_redis
|
|
86
|
+
async with Limiter.redis(
|
|
87
|
+
url,
|
|
88
|
+
fail_open=fail_open,
|
|
89
|
+
timeout=0.05,
|
|
90
|
+
storage_options={"socket_timeout": 5, "socket_connect_timeout": 5},
|
|
91
|
+
) as limiter:
|
|
92
|
+
|
|
93
|
+
@limiter.limit("1/minute", key=lambda request: "private-identity")
|
|
94
|
+
async def handler(request):
|
|
95
|
+
return {"ok": True}
|
|
96
|
+
|
|
97
|
+
with caplog.at_level(logging.WARNING, logger="wred_ratelimit"):
|
|
98
|
+
result = await asyncio.wait_for(handler(request()), timeout=1)
|
|
99
|
+
if fail_open:
|
|
100
|
+
assert result == {"ok": True}
|
|
101
|
+
else:
|
|
102
|
+
assert result[2] == 503
|
|
103
|
+
assert caplog.records[-1].error_type == "TimeoutError"
|
|
104
|
+
assert caplog.records[-1].fail_open is fail_open
|
|
105
|
+
assert "private-identity" not in caplog.text
|
|
106
|
+
async with asyncio.timeout(1):
|
|
107
|
+
with pytest.raises(TimeoutError):
|
|
108
|
+
await limiter.check("1/minute", "user", bucket="test")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def test_redis_pool_is_bounded_and_cancellation_propagates(caplog, silent_redis):
|
|
112
|
+
url, connected = silent_redis
|
|
113
|
+
pending = None
|
|
114
|
+
try:
|
|
115
|
+
async with Limiter.redis(
|
|
116
|
+
url,
|
|
117
|
+
timeout=10,
|
|
118
|
+
storage_options={"max_connections": 1, "socket_timeout": 10},
|
|
119
|
+
) as limiter:
|
|
120
|
+
|
|
121
|
+
@limiter.limit("1/minute")
|
|
122
|
+
async def handler(request):
|
|
123
|
+
return {"ok": True}
|
|
124
|
+
|
|
125
|
+
pending = asyncio.create_task(handler(request()))
|
|
126
|
+
await asyncio.wait_for(connected.wait(), 1)
|
|
127
|
+
with caplog.at_level(logging.WARNING, logger="wred_ratelimit"):
|
|
128
|
+
result = await asyncio.wait_for(handler(request()), 1)
|
|
129
|
+
assert result[2] == 503
|
|
130
|
+
assert not pending.done()
|
|
131
|
+
assert caplog.records[-1].error_type in (
|
|
132
|
+
"ConnectionError",
|
|
133
|
+
"MaxConnectionsError",
|
|
134
|
+
)
|
|
135
|
+
pending.cancel()
|
|
136
|
+
with pytest.raises(asyncio.CancelledError):
|
|
137
|
+
await pending
|
|
138
|
+
finally:
|
|
139
|
+
if pending is not None and not pending.done():
|
|
140
|
+
pending.cancel()
|
|
141
|
+
await asyncio.gather(pending, return_exceptions=True)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from wred import App
|
|
8
|
+
|
|
9
|
+
from wred_ratelimit import Limiter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(params=["memory", "redis"])
|
|
13
|
+
async def limiter(request):
|
|
14
|
+
kwargs = {"namespace": uuid.uuid4().hex}
|
|
15
|
+
if request.param == "redis":
|
|
16
|
+
url = os.getenv("WRED_TEST_REDIS")
|
|
17
|
+
if not url:
|
|
18
|
+
pytest.skip("Set WRED_TEST_REDIS for real Redis integration")
|
|
19
|
+
result = Limiter.redis(url, **kwargs)
|
|
20
|
+
else:
|
|
21
|
+
result = Limiter(**kwargs)
|
|
22
|
+
yield result
|
|
23
|
+
await result.aclose()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_concurrent_quota_and_identity(limiter):
|
|
27
|
+
result = await asyncio.gather(
|
|
28
|
+
*(limiter.check("5/minute", "alice", bucket="orders") for _ in range(20))
|
|
29
|
+
)
|
|
30
|
+
assert sum(allowed for allowed, _ in result) == 5
|
|
31
|
+
assert all(retry > 0 for _, retry in result)
|
|
32
|
+
assert (await limiter.check("5/minute", "bob", bucket="orders"))[0]
|
|
33
|
+
assert (await limiter.check("5/minute", "alice", bucket="elsewhere"))[0]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_http_path_forwarding(limiter):
|
|
37
|
+
app = App()
|
|
38
|
+
|
|
39
|
+
@app.route("/{name}")
|
|
40
|
+
@limiter.limit("1/minute")
|
|
41
|
+
async def handler(request, name):
|
|
42
|
+
return {"name": name}
|
|
43
|
+
|
|
44
|
+
async with httpx.AsyncClient(
|
|
45
|
+
transport=httpx.ASGITransport(app), base_url="http://test"
|
|
46
|
+
) as client:
|
|
47
|
+
assert (await client.get("/alice")).json() == {"name": "alice"}
|
|
48
|
+
result = await client.get("/bob", headers={"X-Forwarded-For": "1.2.3.4"})
|
|
49
|
+
assert result.status_code == 429 and int(result.headers["retry-after"]) > 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def test_expiry_and_fixed_window():
|
|
53
|
+
limiter = Limiter(strategy="fixed")
|
|
54
|
+
assert (await limiter.check("1/second", "user", bucket="test"))[0]
|
|
55
|
+
assert not (await limiter.check("1/second", "user", bucket="test"))[0]
|
|
56
|
+
await asyncio.sleep(1.05)
|
|
57
|
+
assert (await limiter.check("1/second", "user", bucket="test"))[0]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def test_storage_outage():
|
|
61
|
+
limiter = Limiter.redis("redis://127.0.0.1:1")
|
|
62
|
+
|
|
63
|
+
@limiter.limit("1/minute")
|
|
64
|
+
async def handler(request):
|
|
65
|
+
return {"ok": True}
|
|
66
|
+
|
|
67
|
+
from wred import Request
|
|
68
|
+
|
|
69
|
+
request = Request({"path": "/", "method": "GET"}, None, None)
|
|
70
|
+
assert (await handler(request))[2] == 503
|
|
71
|
+
limiter.fail_open = True
|
|
72
|
+
assert await handler(request) == {"ok": True}
|
|
73
|
+
await limiter.aclose()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import multiprocessing
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import uuid
|
|
6
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from wred import Request
|
|
10
|
+
|
|
11
|
+
from wred_ratelimit import Limiter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def consume_in_worker(url, namespace, strategy):
|
|
15
|
+
async def consume():
|
|
16
|
+
async with Limiter.redis(
|
|
17
|
+
url, namespace=namespace, strategy=strategy
|
|
18
|
+
) as limiter:
|
|
19
|
+
decisions = await asyncio.gather(
|
|
20
|
+
*(
|
|
21
|
+
limiter.check("17/minute", "shared", bucket="orders")
|
|
22
|
+
for _ in range(50)
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
return sum(allowed for allowed, _ in decisions)
|
|
26
|
+
|
|
27
|
+
return asyncio.run(consume())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize("strategy", ["moving", "fixed"])
|
|
31
|
+
def test_quota_is_shared_across_processes(strategy):
|
|
32
|
+
url = os.getenv("WRED_TEST_REDIS")
|
|
33
|
+
if not url:
|
|
34
|
+
pytest.skip("Set WRED_TEST_REDIS for real Redis integration")
|
|
35
|
+
namespace = uuid.uuid4().hex
|
|
36
|
+
with ProcessPoolExecutor(
|
|
37
|
+
max_workers=4, mp_context=multiprocessing.get_context("spawn")
|
|
38
|
+
) as pool:
|
|
39
|
+
futures = [
|
|
40
|
+
pool.submit(consume_in_worker, url, namespace, strategy) for _ in range(4)
|
|
41
|
+
]
|
|
42
|
+
assert sum(future.result(timeout=20) for future in futures) == 17
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def test_recovers_after_redis_restart(tmp_path):
|
|
46
|
+
executable = shutil.which("redis-server")
|
|
47
|
+
if not executable:
|
|
48
|
+
pytest.skip("redis-server is required for restart integration")
|
|
49
|
+
socket = tmp_path / "redis.sock"
|
|
50
|
+
process = None
|
|
51
|
+
|
|
52
|
+
async def start():
|
|
53
|
+
nonlocal process
|
|
54
|
+
process = await asyncio.create_subprocess_exec(
|
|
55
|
+
executable,
|
|
56
|
+
"--port",
|
|
57
|
+
"0",
|
|
58
|
+
"--unixsocket",
|
|
59
|
+
str(socket),
|
|
60
|
+
"--save",
|
|
61
|
+
"",
|
|
62
|
+
"--appendonly",
|
|
63
|
+
"no",
|
|
64
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
65
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
66
|
+
)
|
|
67
|
+
async with asyncio.timeout(5):
|
|
68
|
+
while not socket.exists():
|
|
69
|
+
if process.returncode is not None:
|
|
70
|
+
raise RuntimeError("Test Redis failed to start")
|
|
71
|
+
await asyncio.sleep(0.01)
|
|
72
|
+
|
|
73
|
+
async def stop():
|
|
74
|
+
if process is not None and process.returncode is None:
|
|
75
|
+
process.terminate()
|
|
76
|
+
await asyncio.wait_for(process.wait(), 5)
|
|
77
|
+
socket.unlink(missing_ok=True)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
await start()
|
|
81
|
+
async with Limiter.redis(f"redis+unix://{socket}") as limiter:
|
|
82
|
+
|
|
83
|
+
@limiter.limit("1/minute")
|
|
84
|
+
async def handler(request):
|
|
85
|
+
return {"ok": True}
|
|
86
|
+
|
|
87
|
+
request = Request({"path": "/", "method": "GET"}, None, None)
|
|
88
|
+
assert await handler(request) == {"ok": True}
|
|
89
|
+
assert (await handler(request))[2] == 429
|
|
90
|
+
await stop()
|
|
91
|
+
assert (await handler(request))[2] == 503
|
|
92
|
+
await start()
|
|
93
|
+
# This server has no persistence: reconnect works, but quotas reset.
|
|
94
|
+
assert await handler(request) == {"ok": True}
|
|
95
|
+
assert (await handler(request))[2] == 429
|
|
96
|
+
finally:
|
|
97
|
+
await stop()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Per-handler request quotas using the async limits engine."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
9
|
+
from functools import wraps
|
|
10
|
+
|
|
11
|
+
from limits import parse
|
|
12
|
+
from limits.aio.storage import MemoryStorage, RedisStorage
|
|
13
|
+
from limits.aio.strategies import FixedWindowRateLimiter, MovingWindowRateLimiter
|
|
14
|
+
from limits.errors import StorageError
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def client_ip(request):
|
|
21
|
+
"""Use the ASGI peer address; forwarded headers are not trusted."""
|
|
22
|
+
return (request.scope.get("client") or ("unknown",))[0]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Limiter:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
storage=None,
|
|
30
|
+
namespace="wred",
|
|
31
|
+
strategy="moving",
|
|
32
|
+
key=client_ip,
|
|
33
|
+
fail_open=False,
|
|
34
|
+
timeout=0.5,
|
|
35
|
+
):
|
|
36
|
+
if strategy not in ("fixed", "moving"):
|
|
37
|
+
raise ValueError("strategy must be fixed or moving")
|
|
38
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
39
|
+
raise ValueError("timeout must be a finite positive number of seconds")
|
|
40
|
+
self.storage = storage if storage is not None else MemoryStorage()
|
|
41
|
+
self.engine = (
|
|
42
|
+
MovingWindowRateLimiter if strategy == "moving" else FixedWindowRateLimiter
|
|
43
|
+
)(self.storage)
|
|
44
|
+
self.namespace, self.key, self.fail_open = namespace, key, fail_open
|
|
45
|
+
self.timeout = timeout
|
|
46
|
+
errors = self.storage.base_exceptions
|
|
47
|
+
if not isinstance(errors, tuple):
|
|
48
|
+
errors = (errors,)
|
|
49
|
+
self._storage_errors = (StorageError, TimeoutError, *errors)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def redis(cls, url, *, storage_options=None, **kwargs):
|
|
53
|
+
"""Create Redis storage with bounded connections and no command retries.
|
|
54
|
+
|
|
55
|
+
``storage_options`` are forwarded to the limits RedisStorage constructor.
|
|
56
|
+
Remaining keywords configure this Limiter. Redis URL options take precedence
|
|
57
|
+
over client keyword options, as in redis-py.
|
|
58
|
+
"""
|
|
59
|
+
from redis.asyncio.retry import Retry
|
|
60
|
+
from redis.backoff import NoBackoff
|
|
61
|
+
|
|
62
|
+
options = {
|
|
63
|
+
"socket_connect_timeout": 0.2,
|
|
64
|
+
"socket_timeout": 0.2,
|
|
65
|
+
"max_connections": 100,
|
|
66
|
+
# A lost response does not mean the quota mutation failed. Retrying
|
|
67
|
+
# could charge a request twice.
|
|
68
|
+
"retry": Retry(NoBackoff(), 0),
|
|
69
|
+
}
|
|
70
|
+
options.update(storage_options or {})
|
|
71
|
+
return cls(
|
|
72
|
+
storage=RedisStorage(url, implementation="redispy", **options), **kwargs
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _identifiers(self, identity, bucket):
|
|
76
|
+
return (
|
|
77
|
+
self.namespace,
|
|
78
|
+
bucket,
|
|
79
|
+
hashlib.sha256(str(identity).encode()).hexdigest(),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def _retry_after(self, item, identifiers):
|
|
83
|
+
stats = await self.engine.get_window_stats(item, *identifiers)
|
|
84
|
+
return max(1, math.ceil(stats.reset_time - time.time()))
|
|
85
|
+
|
|
86
|
+
async def check(self, rule, identity, *, bucket):
|
|
87
|
+
"""Consume quota, returning (allowed, retry_after); storage errors propagate."""
|
|
88
|
+
item = parse(rule)
|
|
89
|
+
identifiers = self._identifiers(identity, bucket)
|
|
90
|
+
async with asyncio.timeout(self.timeout):
|
|
91
|
+
allowed = await self.engine.hit(item, *identifiers)
|
|
92
|
+
return allowed, await self._retry_after(item, identifiers)
|
|
93
|
+
|
|
94
|
+
def limit(self, rule, *, key=None, bucket=None):
|
|
95
|
+
item = parse(rule)
|
|
96
|
+
|
|
97
|
+
def decorate(handler):
|
|
98
|
+
route_bucket = bucket or f"{handler.__module__}.{handler.__qualname__}"
|
|
99
|
+
|
|
100
|
+
@wraps(handler)
|
|
101
|
+
async def wrapped(request, *args, **kwargs):
|
|
102
|
+
identity = (key or self.key)(request)
|
|
103
|
+
if inspect.isawaitable(identity):
|
|
104
|
+
identity = await identity
|
|
105
|
+
identifiers = self._identifiers(identity, route_bucket)
|
|
106
|
+
allowed = None
|
|
107
|
+
# If statistics fail after a denial, keep the denial and use a
|
|
108
|
+
# conservative full window instead of allowing an exhausted quota.
|
|
109
|
+
retry = item.get_expiry()
|
|
110
|
+
try:
|
|
111
|
+
async with asyncio.timeout(self.timeout):
|
|
112
|
+
allowed = await self.engine.hit(item, *identifiers)
|
|
113
|
+
if not allowed:
|
|
114
|
+
retry = await self._retry_after(item, identifiers)
|
|
115
|
+
except self._storage_errors as exc:
|
|
116
|
+
# Do not log identities, Redis URLs, or exception messages:
|
|
117
|
+
# they can contain credentials or other sensitive data.
|
|
118
|
+
logger.warning(
|
|
119
|
+
"Rate limit storage failure",
|
|
120
|
+
extra={
|
|
121
|
+
"namespace": self.namespace,
|
|
122
|
+
"bucket": route_bucket,
|
|
123
|
+
"fail_open": self.fail_open,
|
|
124
|
+
"error_type": type(exc).__name__,
|
|
125
|
+
"quota_denied": allowed is False,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
if allowed is not False and not self.fail_open:
|
|
129
|
+
return (
|
|
130
|
+
{"detail": "Rate limit service unavailable"},
|
|
131
|
+
[("content-type", "application/json")],
|
|
132
|
+
503,
|
|
133
|
+
)
|
|
134
|
+
if allowed is False:
|
|
135
|
+
return (
|
|
136
|
+
{"detail": "Rate limit exceeded"},
|
|
137
|
+
[
|
|
138
|
+
("content-type", "application/json"),
|
|
139
|
+
("retry-after", str(retry)),
|
|
140
|
+
],
|
|
141
|
+
429,
|
|
142
|
+
)
|
|
143
|
+
return await handler(request, *args, **kwargs)
|
|
144
|
+
|
|
145
|
+
return wrapped
|
|
146
|
+
|
|
147
|
+
return decorate
|
|
148
|
+
|
|
149
|
+
async def aclose(self):
|
|
150
|
+
# limits owns a bridge over the redis-py connection.
|
|
151
|
+
bridge = getattr(self.storage, "bridge", None)
|
|
152
|
+
if bridge is not None:
|
|
153
|
+
closed = set()
|
|
154
|
+
for attr in ("storage", "storage_readonly"):
|
|
155
|
+
client = getattr(bridge, attr, None)
|
|
156
|
+
if (
|
|
157
|
+
client is not None
|
|
158
|
+
and id(client) not in closed
|
|
159
|
+
and hasattr(client, "aclose")
|
|
160
|
+
):
|
|
161
|
+
await client.aclose()
|
|
162
|
+
closed.add(id(client))
|
|
163
|
+
|
|
164
|
+
async def __aenter__(self):
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
async def __aexit__(self, *exc):
|
|
168
|
+
await self.aclose()
|