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.
@@ -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,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ *.egg-info/
8
+ .coverage
@@ -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()