xtr-rate-limiter 2.0.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.
- xtr_rate_limiter-2.0.0/LICENSE +21 -0
- xtr_rate_limiter-2.0.0/PKG-INFO +328 -0
- xtr_rate_limiter-2.0.0/README.md +296 -0
- xtr_rate_limiter-2.0.0/pyproject.toml +182 -0
- xtr_rate_limiter-2.0.0/pyproject.toml.orig +187 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/__init__.py +98 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/_interval.py +138 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/_lock.py +70 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/_time.py +14 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/bundle/__init__.py +9 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/bundle/builder_config.py +58 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/bundle/rate_limiter_bundle.py +347 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/bundle/rate_limiter_config.py +91 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/compound_limiter.py +70 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/compound_rate_limiter_factory.py +68 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/event/__init__.py +7 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/event/rate_limit_exceeded_event.py +34 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/__init__.py +27 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/invalid_argument_error.py +29 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/invalid_interval_error.py +22 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/max_wait_duration_exceeded_error.py +38 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/rate_limit_exceeded_error.py +51 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/rate_limiter_error.py +14 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/rate_limiter_storage_error.py +25 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/reserve_not_supported_error.py +25 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/limiter_config.py +223 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/limiter_interface.py +58 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/limiter_state_interface.py +27 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/__init__.py +17 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/_stored_limiter.py +100 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/calendar_aligned_window.py +72 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/fixed_window_limiter.py +155 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/no_limiter.py +54 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/rate.py +76 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/sliding_window.py +101 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/sliding_window_limiter.py +119 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/token_bucket.py +84 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/token_bucket_limiter.py +123 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/window.py +76 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/py.typed +0 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/rate_limit.py +114 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/rate_limiter_builder.py +88 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/rate_limiter_factory.py +109 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/rate_limiter_factory_interface.py +30 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/__init__.py +8 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/_script_runner.py +116 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/_scripts.py +340 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/redis_connection.py +87 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/redis_limiter.py +123 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/redis_rate_limiter_factory.py +157 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/reservation.py +75 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/storage/__init__.py +9 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/storage/cache_storage.py +63 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/storage/in_memory_storage.py +86 -0
- xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/storage/storage_interface.py +33 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-rate-limiter
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Limits on how often anything may happen — calls, jobs, logins — by fixed window, sliding window or token bucket, kept in memory, in a cache or atomically in Redis.
|
|
5
|
+
Keywords: rate-limit,rate-limiter,throttle,token-bucket,sliding-window,fixed-window,redis,asyncio
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: typing-extensions>=4.4
|
|
19
|
+
Requires-Dist: xtr-clock>=2.0,<3
|
|
20
|
+
Requires-Dist: xtr-event-dispatcher-contracts>=2.0,<3
|
|
21
|
+
Requires-Dist: xtr-cache-contracts>=2.0,<3 ; extra == 'cache'
|
|
22
|
+
Requires-Dist: xtr-dependency-injection>=2.0,<3 ; extra == 'di'
|
|
23
|
+
Requires-Dist: xtr-service-contracts>=2.0,<3 ; extra == 'di'
|
|
24
|
+
Requires-Dist: xtr-lock>=2.0,<3 ; extra == 'lock'
|
|
25
|
+
Requires-Dist: redis>=5.0 ; extra == 'redis'
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Provides-Extra: cache
|
|
28
|
+
Provides-Extra: di
|
|
29
|
+
Provides-Extra: lock
|
|
30
|
+
Provides-Extra: redis
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
<div align="center">
|
|
34
|
+
|
|
35
|
+
# xtr-rate-limiter
|
|
36
|
+
|
|
37
|
+
**Limits on how often anything may happen — calls, jobs, logins — by fixed window, sliding window or token bucket, kept in memory, in a cache or atomically in Redis.**
|
|
38
|
+
|
|
39
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
40
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
41
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
42
|
+
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Why?
|
|
48
|
+
|
|
49
|
+
A limit is not a web concern. A worker calling a paid API, a job retrying a flaky upstream, a
|
|
50
|
+
login form, a message consumer draining a queue — each needs to know whether it may go ahead
|
|
51
|
+
now, or when it may. This library answers that for any key, from any code, without a web
|
|
52
|
+
framework in sight.
|
|
53
|
+
|
|
54
|
+
- 🪣 **Three policies.** A fixed window, a sliding window that smooths bursts at window edges,
|
|
55
|
+
and a token bucket that allows bursts then a steady rate — plus no limit at all.
|
|
56
|
+
- ⏳ **Decide now, or book ahead.** `consume()` grants tokens or refuses; `reserve()` books
|
|
57
|
+
them for the moment they come free and says when that is.
|
|
58
|
+
- 🧱 **Anywhere the state fits.** In this process, in a cache pool shared by every process, or
|
|
59
|
+
on a Redis server that counts each hit in one atomic script — no lock needed.
|
|
60
|
+
- 🧩 **Combined limits.** 10 a minute *and* 1 000 a day, as one limiter.
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
uv add xtr-rate-limiter # policies, in-memory storage
|
|
66
|
+
uv add "xtr-rate-limiter[redis]" # + atomic counting on a Redis server
|
|
67
|
+
uv add "xtr-rate-limiter[cache]" # + state in a cache pool
|
|
68
|
+
uv add "xtr-rate-limiter[lock]" # + locks shared between processes
|
|
69
|
+
uv add "xtr-rate-limiter[di]" # + the bundle
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
A factory is one configured limit; it hands out a limiter per key:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from xtr_rate_limiter import InMemoryStorage, LimiterConfig, RateLimiterFactory
|
|
78
|
+
|
|
79
|
+
factory = RateLimiterFactory(
|
|
80
|
+
"api",
|
|
81
|
+
LimiterConfig("sliding_window", limit=100, interval="1 minute"),
|
|
82
|
+
InMemoryStorage(),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
limit = await factory.create(client_ip).consume()
|
|
86
|
+
if not limit.is_accepted():
|
|
87
|
+
raise TooBusy(retry_after=limit.retry_after)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`consume()` answers with a `RateLimit`:
|
|
91
|
+
|
|
92
|
+
| Member | Meaning |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `is_accepted()` | the tokens were granted |
|
|
95
|
+
| `remaining_tokens` | tokens left |
|
|
96
|
+
| `retry_after` | when the tokens asked for become available |
|
|
97
|
+
| `reset_at` | when the limiter holds every token again (`None` for no limit) |
|
|
98
|
+
| `limit` | tokens held when full |
|
|
99
|
+
| `ensure_accepted()` | return the limit, or raise `RateLimitExceededError` |
|
|
100
|
+
| `await wait()` | wait until `retry_after` |
|
|
101
|
+
|
|
102
|
+
Consuming `0` tokens reports the limit as it stands and spends nothing.
|
|
103
|
+
|
|
104
|
+
### Waiting instead of refusing
|
|
105
|
+
|
|
106
|
+
`reserve()` books the tokens for when they come free. From then on they count as spent, so
|
|
107
|
+
callers are served in the order they reserved:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
reservation = await limiter.reserve(tokens=1, max_time=5)
|
|
111
|
+
await reservation.wait()
|
|
112
|
+
await call_the_api()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A wait longer than `max_time` raises `MaxWaitDurationExceededError` and books nothing.
|
|
116
|
+
|
|
117
|
+
## Policies
|
|
118
|
+
|
|
119
|
+
`LimiterConfig`'s `policy` picks which of its other fields apply:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
LimiterConfig("fixed_window", limit=5, interval="15 minutes")
|
|
123
|
+
LimiterConfig("fixed_window", limit=1000, interval="1 month", anchor_at="2026-01-01")
|
|
124
|
+
LimiterConfig("sliding_window", limit=100, interval="1 minute")
|
|
125
|
+
LimiterConfig("token_bucket", limit=10, rate=Rate("1 minute", amount=2))
|
|
126
|
+
LimiterConfig("no_limit")
|
|
127
|
+
LimiterConfig("compound", limiters=["per_minute", "per_day"])
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- **`fixed_window`** — at most `limit` hits per window of `interval`. A window opens on the
|
|
131
|
+
first hit; with `anchor_at` (an interval of a month or more) windows follow a calendar
|
|
132
|
+
instead — the first of each month, whenever hits arrive. Cheap, but a burst at the end of
|
|
133
|
+
one window and another at the start of the next go through together.
|
|
134
|
+
- **`sliding_window`** — at most `limit` hits in any span of `interval`, estimated from the
|
|
135
|
+
current window and a fading share of the last one. Smooths the edge burst out.
|
|
136
|
+
- **`token_bucket`** — a bucket of `limit` tokens refilled by `rate`: an idle caller can burst,
|
|
137
|
+
a busy one settles to the rate.
|
|
138
|
+
- **`no_limit`** — accepts everything; for a limit turned off in one environment.
|
|
139
|
+
- **`compound`** — every one of `limiters` applies to each hit (configured by the bundle; in
|
|
140
|
+
code, `CompoundRateLimiterFactory`). The answer is the first refusal, or the limit closest to
|
|
141
|
+
running out. Limits consulted before a refusal keep their spend, and a compound limiter
|
|
142
|
+
cannot `reserve()`.
|
|
143
|
+
|
|
144
|
+
Intervals are written `"30 seconds"`, `"1 hour 30 minutes"`, `"2 weeks"`, `"1 month"` — or as a
|
|
145
|
+
`timedelta`. A reservation beyond a window's size is a debt the following windows pay, so no
|
|
146
|
+
token is ever handed out twice.
|
|
147
|
+
|
|
148
|
+
## Where the state lives
|
|
149
|
+
|
|
150
|
+
| Storage | Shared by | Guarded by |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `InMemoryStorage()` | this process | a lock within the process |
|
|
153
|
+
| `CacheStorage(pool)` | every process reaching the pool | a lock from a `LockFactory` |
|
|
154
|
+
| `RedisRateLimiterFactory(...)` | every process reaching the server | one atomic script per hit |
|
|
155
|
+
|
|
156
|
+
A storage limiter reads its state, decides, and writes it back under a lock on the key — held
|
|
157
|
+
within the process when no lock factory is given, which already matters in one process: a task
|
|
158
|
+
can be switched away from between the read and the write. Pass an `xtr-lock` `LockFactory` to
|
|
159
|
+
share the lock between processes:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
factory = RateLimiterFactory("api", config, CacheStorage(pool), LockFactory(RedisStore(redis)))
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
A cache pool never fails loudly: a pool whose backend is down reads as empty, so a limiter on
|
|
166
|
+
it lets every hit through until the backend is back. Use Redis for limits that must hold then.
|
|
167
|
+
|
|
168
|
+
### Redis
|
|
169
|
+
|
|
170
|
+
`RedisRateLimiterFactory` keeps each limiter's state in one hash and changes it in one script
|
|
171
|
+
the server runs whole — no lock, one round trip, and the server's own clock, so the processes'
|
|
172
|
+
clocks need not agree:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from redis.asyncio import Redis
|
|
176
|
+
from xtr_rate_limiter.redis import RedisRateLimiterFactory
|
|
177
|
+
|
|
178
|
+
factory = RedisRateLimiterFactory(
|
|
179
|
+
"login",
|
|
180
|
+
LimiterConfig("fixed_window", limit=5, interval="15 minutes"),
|
|
181
|
+
Redis.from_url("redis://localhost:6379"),
|
|
182
|
+
)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Keys are `"<prefix><id>-<key>"` (`prefix="rate_limiter:"`), each expiring once it no longer
|
|
186
|
+
matters. A server that cannot be reached raises `RateLimiterStorageError` rather than guessing:
|
|
187
|
+
whether to let a hit through then is the caller's call. A server refusing to read its clock in a
|
|
188
|
+
script is sent this process's instead; a calendar-anchored window always counts by this
|
|
189
|
+
process's clock, since the calendar is worked out here.
|
|
190
|
+
|
|
191
|
+
### Building limits in code
|
|
192
|
+
|
|
193
|
+
For limits decided at runtime — per tenant, per plan:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
builder = RateLimiterBuilder(InMemoryStorage())
|
|
197
|
+
factory = builder.token_bucket(f"tenant-{tenant.id}", limit=tenant.burst, interval="1 second")
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`sliding_window`, `fixed_window`, `token_bucket`, `compound` and `noop` build a factory each.
|
|
201
|
+
|
|
202
|
+
## Reporting a refusal
|
|
203
|
+
|
|
204
|
+
A denied hit is not a rejection by itself — a caller may wait, retry or carry on — so limiters
|
|
205
|
+
dispatch nothing. Code that turns a hit away can dispatch `RateLimitExceededEvent(rate_limit,
|
|
206
|
+
limiter_name, key)` through its event dispatcher.
|
|
207
|
+
|
|
208
|
+
## Use in an application
|
|
209
|
+
|
|
210
|
+
Everything adding this package to an application on
|
|
211
|
+
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
212
|
+
removing it undoes.
|
|
213
|
+
|
|
214
|
+
- **Install** — `uv add "xtr-rate-limiter[di]"`; add `cache` and xtr-cache for state shared
|
|
215
|
+
through a cache pool, `lock` and xtr-lock for a lock shared between processes, `redis` to
|
|
216
|
+
count on a Redis server.
|
|
217
|
+
- **Activate** — `RateLimiterBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`,
|
|
218
|
+
imported from `xtr_rate_limiter.bundle`.
|
|
219
|
+
- **Brings along** — the cache bundle when xtr-cache is installed, and the lock bundle when
|
|
220
|
+
xtr-lock is.
|
|
221
|
+
- **Configure** — optional: with no configuration there are no limiters. Limiters go in
|
|
222
|
+
`<app>/config/rate_limiter.py`, a `@configure` function returning `RateLimiterConfig` — see
|
|
223
|
+
[Kernel / bundle](#kernel--bundle).
|
|
224
|
+
- **Environment** — nothing required. A storage given as `env(...)` must be set when the
|
|
225
|
+
application boots: booting checks every limiter.
|
|
226
|
+
- **Ignore** — nothing beyond what the cache and lock bundles write under `var/`.
|
|
227
|
+
- **Remove** — drop the `BUNDLES` entry, delete `<app>/config/rate_limiter.py`, then
|
|
228
|
+
`uv remove xtr-rate-limiter`.
|
|
229
|
+
- **Check** — `debug:bundles` shows `rate_limiter` as `listed` and `active`.
|
|
230
|
+
|
|
231
|
+
## Kernel / bundle
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
# app/bundles.py
|
|
235
|
+
from xtr_rate_limiter.bundle import RateLimiterBundle
|
|
236
|
+
|
|
237
|
+
BUNDLES = {RateLimiterBundle: {"all": True}}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
# app/config/rate_limiter.py
|
|
242
|
+
from xtr_dependency_injection import configure, env
|
|
243
|
+
|
|
244
|
+
from xtr_rate_limiter import LimiterConfig, Rate
|
|
245
|
+
from xtr_rate_limiter.bundle import RateLimiterConfig
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@configure
|
|
249
|
+
def rate_limiter() -> RateLimiterConfig:
|
|
250
|
+
return RateLimiterConfig(
|
|
251
|
+
limiters={
|
|
252
|
+
"api": LimiterConfig("sliding_window", limit=100, interval="1 minute"),
|
|
253
|
+
"login": LimiterConfig(
|
|
254
|
+
"fixed_window", limit=5, interval="15 minutes", storage=env("REDIS_DSN")
|
|
255
|
+
),
|
|
256
|
+
"uploads": LimiterConfig("token_bucket", limit=10, rate=Rate("1 minute", amount=2)),
|
|
257
|
+
"strict": LimiterConfig("compound", limiters=["api", "uploads"]),
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Every limiter is a `RateLimiterFactoryInterface` qualified by its name:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from typing import Annotated
|
|
266
|
+
|
|
267
|
+
from xtr_dependency_injection import Target, as_service
|
|
268
|
+
|
|
269
|
+
from xtr_rate_limiter import RateLimiterFactoryInterface
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@as_service
|
|
273
|
+
class LoginThrottle:
|
|
274
|
+
def __init__(self, limiter: Annotated[RateLimiterFactoryInterface, Target("login")]) -> None:
|
|
275
|
+
self._limiter = limiter
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`RateLimiterConfig`:
|
|
279
|
+
|
|
280
|
+
| Field | Default | What it sets |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| `limiters` | `{}` | every limiter, by name |
|
|
283
|
+
| `builder` | `BuilderConfig()` | where the `RateLimiterBuilder` service keeps its state |
|
|
284
|
+
| `redis_prefix` | `"rate_limiter:"` | what every key counted in Redis starts with |
|
|
285
|
+
|
|
286
|
+
Each `LimiterConfig` also says where its state lives:
|
|
287
|
+
|
|
288
|
+
| Field | Default | What it sets |
|
|
289
|
+
|---|---|---|
|
|
290
|
+
| `storage` | `"cache"` | `"cache"`, `"in-memory"`, a Redis DSN, or a `Reference` to a `StorageInterface` or a Redis client — also `env(...)` |
|
|
291
|
+
| `cache_pool` | `"rate_limiter"` | the pool a `"cache"` storage uses; the bundle adds it to the cache, on the application's adapter under a namespace of its own |
|
|
292
|
+
| `lock` | `"auto"` | the lock bundle's default factory when active, else within the process; a lock resource's name; or `None` for within the process only |
|
|
293
|
+
|
|
294
|
+
The bundle registers a factory per limiter and the `RateLimiterBuilder`, and its zero-config
|
|
295
|
+
path registers no limiter and touches no I/O. Boot checks every storage — a cache pool that
|
|
296
|
+
exists, a Redis DSN, a registered reference — so a mistake fails at startup rather than on the
|
|
297
|
+
first hit. A connection the bundle opened from a DSN is closed with the container.
|
|
298
|
+
|
|
299
|
+
## Errors
|
|
300
|
+
|
|
301
|
+
Everything this library raises derives from `RateLimiterError`, and carries what went wrong as
|
|
302
|
+
typed attributes rather than only a message.
|
|
303
|
+
|
|
304
|
+
| Error | Raised when |
|
|
305
|
+
|---|---|
|
|
306
|
+
| `InvalidArgumentError` | a configuration or call cannot be used — also a `ValueError` |
|
|
307
|
+
| `InvalidIntervalError` | an interval cannot be read |
|
|
308
|
+
| `MaxWaitDurationExceededError` | a reservation would wait longer than `max_time` |
|
|
309
|
+
| `RateLimitExceededError` | `ensure_accepted()` on a refused limit |
|
|
310
|
+
| `ReserveNotSupportedError` | `reserve()` on a compound limiter |
|
|
311
|
+
| `RateLimiterStorageError` | the Redis server failed or could not be reached |
|
|
312
|
+
|
|
313
|
+
## Development
|
|
314
|
+
|
|
315
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
316
|
+
`packages/xtr-rate-limiter`; run the commands below from there. The `python-xtr-rate-limiter`
|
|
317
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
318
|
+
|
|
319
|
+
```sh
|
|
320
|
+
uv sync --all-extras
|
|
321
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The Redis scripts run in process, on a fake server; no Redis is needed.
|
|
325
|
+
|
|
326
|
+
## License
|
|
327
|
+
|
|
328
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# xtr-rate-limiter
|
|
4
|
+
|
|
5
|
+
**Limits on how often anything may happen — calls, jobs, logins — by fixed window, sliding window or token bucket, kept in memory, in a cache or atomically in Redis.**
|
|
6
|
+
|
|
7
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
9
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why?
|
|
16
|
+
|
|
17
|
+
A limit is not a web concern. A worker calling a paid API, a job retrying a flaky upstream, a
|
|
18
|
+
login form, a message consumer draining a queue — each needs to know whether it may go ahead
|
|
19
|
+
now, or when it may. This library answers that for any key, from any code, without a web
|
|
20
|
+
framework in sight.
|
|
21
|
+
|
|
22
|
+
- 🪣 **Three policies.** A fixed window, a sliding window that smooths bursts at window edges,
|
|
23
|
+
and a token bucket that allows bursts then a steady rate — plus no limit at all.
|
|
24
|
+
- ⏳ **Decide now, or book ahead.** `consume()` grants tokens or refuses; `reserve()` books
|
|
25
|
+
them for the moment they come free and says when that is.
|
|
26
|
+
- 🧱 **Anywhere the state fits.** In this process, in a cache pool shared by every process, or
|
|
27
|
+
on a Redis server that counts each hit in one atomic script — no lock needed.
|
|
28
|
+
- 🧩 **Combined limits.** 10 a minute *and* 1 000 a day, as one limiter.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
uv add xtr-rate-limiter # policies, in-memory storage
|
|
34
|
+
uv add "xtr-rate-limiter[redis]" # + atomic counting on a Redis server
|
|
35
|
+
uv add "xtr-rate-limiter[cache]" # + state in a cache pool
|
|
36
|
+
uv add "xtr-rate-limiter[lock]" # + locks shared between processes
|
|
37
|
+
uv add "xtr-rate-limiter[di]" # + the bundle
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
A factory is one configured limit; it hands out a limiter per key:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from xtr_rate_limiter import InMemoryStorage, LimiterConfig, RateLimiterFactory
|
|
46
|
+
|
|
47
|
+
factory = RateLimiterFactory(
|
|
48
|
+
"api",
|
|
49
|
+
LimiterConfig("sliding_window", limit=100, interval="1 minute"),
|
|
50
|
+
InMemoryStorage(),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
limit = await factory.create(client_ip).consume()
|
|
54
|
+
if not limit.is_accepted():
|
|
55
|
+
raise TooBusy(retry_after=limit.retry_after)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`consume()` answers with a `RateLimit`:
|
|
59
|
+
|
|
60
|
+
| Member | Meaning |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `is_accepted()` | the tokens were granted |
|
|
63
|
+
| `remaining_tokens` | tokens left |
|
|
64
|
+
| `retry_after` | when the tokens asked for become available |
|
|
65
|
+
| `reset_at` | when the limiter holds every token again (`None` for no limit) |
|
|
66
|
+
| `limit` | tokens held when full |
|
|
67
|
+
| `ensure_accepted()` | return the limit, or raise `RateLimitExceededError` |
|
|
68
|
+
| `await wait()` | wait until `retry_after` |
|
|
69
|
+
|
|
70
|
+
Consuming `0` tokens reports the limit as it stands and spends nothing.
|
|
71
|
+
|
|
72
|
+
### Waiting instead of refusing
|
|
73
|
+
|
|
74
|
+
`reserve()` books the tokens for when they come free. From then on they count as spent, so
|
|
75
|
+
callers are served in the order they reserved:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
reservation = await limiter.reserve(tokens=1, max_time=5)
|
|
79
|
+
await reservation.wait()
|
|
80
|
+
await call_the_api()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A wait longer than `max_time` raises `MaxWaitDurationExceededError` and books nothing.
|
|
84
|
+
|
|
85
|
+
## Policies
|
|
86
|
+
|
|
87
|
+
`LimiterConfig`'s `policy` picks which of its other fields apply:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
LimiterConfig("fixed_window", limit=5, interval="15 minutes")
|
|
91
|
+
LimiterConfig("fixed_window", limit=1000, interval="1 month", anchor_at="2026-01-01")
|
|
92
|
+
LimiterConfig("sliding_window", limit=100, interval="1 minute")
|
|
93
|
+
LimiterConfig("token_bucket", limit=10, rate=Rate("1 minute", amount=2))
|
|
94
|
+
LimiterConfig("no_limit")
|
|
95
|
+
LimiterConfig("compound", limiters=["per_minute", "per_day"])
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **`fixed_window`** — at most `limit` hits per window of `interval`. A window opens on the
|
|
99
|
+
first hit; with `anchor_at` (an interval of a month or more) windows follow a calendar
|
|
100
|
+
instead — the first of each month, whenever hits arrive. Cheap, but a burst at the end of
|
|
101
|
+
one window and another at the start of the next go through together.
|
|
102
|
+
- **`sliding_window`** — at most `limit` hits in any span of `interval`, estimated from the
|
|
103
|
+
current window and a fading share of the last one. Smooths the edge burst out.
|
|
104
|
+
- **`token_bucket`** — a bucket of `limit` tokens refilled by `rate`: an idle caller can burst,
|
|
105
|
+
a busy one settles to the rate.
|
|
106
|
+
- **`no_limit`** — accepts everything; for a limit turned off in one environment.
|
|
107
|
+
- **`compound`** — every one of `limiters` applies to each hit (configured by the bundle; in
|
|
108
|
+
code, `CompoundRateLimiterFactory`). The answer is the first refusal, or the limit closest to
|
|
109
|
+
running out. Limits consulted before a refusal keep their spend, and a compound limiter
|
|
110
|
+
cannot `reserve()`.
|
|
111
|
+
|
|
112
|
+
Intervals are written `"30 seconds"`, `"1 hour 30 minutes"`, `"2 weeks"`, `"1 month"` — or as a
|
|
113
|
+
`timedelta`. A reservation beyond a window's size is a debt the following windows pay, so no
|
|
114
|
+
token is ever handed out twice.
|
|
115
|
+
|
|
116
|
+
## Where the state lives
|
|
117
|
+
|
|
118
|
+
| Storage | Shared by | Guarded by |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `InMemoryStorage()` | this process | a lock within the process |
|
|
121
|
+
| `CacheStorage(pool)` | every process reaching the pool | a lock from a `LockFactory` |
|
|
122
|
+
| `RedisRateLimiterFactory(...)` | every process reaching the server | one atomic script per hit |
|
|
123
|
+
|
|
124
|
+
A storage limiter reads its state, decides, and writes it back under a lock on the key — held
|
|
125
|
+
within the process when no lock factory is given, which already matters in one process: a task
|
|
126
|
+
can be switched away from between the read and the write. Pass an `xtr-lock` `LockFactory` to
|
|
127
|
+
share the lock between processes:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
factory = RateLimiterFactory("api", config, CacheStorage(pool), LockFactory(RedisStore(redis)))
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A cache pool never fails loudly: a pool whose backend is down reads as empty, so a limiter on
|
|
134
|
+
it lets every hit through until the backend is back. Use Redis for limits that must hold then.
|
|
135
|
+
|
|
136
|
+
### Redis
|
|
137
|
+
|
|
138
|
+
`RedisRateLimiterFactory` keeps each limiter's state in one hash and changes it in one script
|
|
139
|
+
the server runs whole — no lock, one round trip, and the server's own clock, so the processes'
|
|
140
|
+
clocks need not agree:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from redis.asyncio import Redis
|
|
144
|
+
from xtr_rate_limiter.redis import RedisRateLimiterFactory
|
|
145
|
+
|
|
146
|
+
factory = RedisRateLimiterFactory(
|
|
147
|
+
"login",
|
|
148
|
+
LimiterConfig("fixed_window", limit=5, interval="15 minutes"),
|
|
149
|
+
Redis.from_url("redis://localhost:6379"),
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Keys are `"<prefix><id>-<key>"` (`prefix="rate_limiter:"`), each expiring once it no longer
|
|
154
|
+
matters. A server that cannot be reached raises `RateLimiterStorageError` rather than guessing:
|
|
155
|
+
whether to let a hit through then is the caller's call. A server refusing to read its clock in a
|
|
156
|
+
script is sent this process's instead; a calendar-anchored window always counts by this
|
|
157
|
+
process's clock, since the calendar is worked out here.
|
|
158
|
+
|
|
159
|
+
### Building limits in code
|
|
160
|
+
|
|
161
|
+
For limits decided at runtime — per tenant, per plan:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
builder = RateLimiterBuilder(InMemoryStorage())
|
|
165
|
+
factory = builder.token_bucket(f"tenant-{tenant.id}", limit=tenant.burst, interval="1 second")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`sliding_window`, `fixed_window`, `token_bucket`, `compound` and `noop` build a factory each.
|
|
169
|
+
|
|
170
|
+
## Reporting a refusal
|
|
171
|
+
|
|
172
|
+
A denied hit is not a rejection by itself — a caller may wait, retry or carry on — so limiters
|
|
173
|
+
dispatch nothing. Code that turns a hit away can dispatch `RateLimitExceededEvent(rate_limit,
|
|
174
|
+
limiter_name, key)` through its event dispatcher.
|
|
175
|
+
|
|
176
|
+
## Use in an application
|
|
177
|
+
|
|
178
|
+
Everything adding this package to an application on
|
|
179
|
+
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
180
|
+
removing it undoes.
|
|
181
|
+
|
|
182
|
+
- **Install** — `uv add "xtr-rate-limiter[di]"`; add `cache` and xtr-cache for state shared
|
|
183
|
+
through a cache pool, `lock` and xtr-lock for a lock shared between processes, `redis` to
|
|
184
|
+
count on a Redis server.
|
|
185
|
+
- **Activate** — `RateLimiterBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`,
|
|
186
|
+
imported from `xtr_rate_limiter.bundle`.
|
|
187
|
+
- **Brings along** — the cache bundle when xtr-cache is installed, and the lock bundle when
|
|
188
|
+
xtr-lock is.
|
|
189
|
+
- **Configure** — optional: with no configuration there are no limiters. Limiters go in
|
|
190
|
+
`<app>/config/rate_limiter.py`, a `@configure` function returning `RateLimiterConfig` — see
|
|
191
|
+
[Kernel / bundle](#kernel--bundle).
|
|
192
|
+
- **Environment** — nothing required. A storage given as `env(...)` must be set when the
|
|
193
|
+
application boots: booting checks every limiter.
|
|
194
|
+
- **Ignore** — nothing beyond what the cache and lock bundles write under `var/`.
|
|
195
|
+
- **Remove** — drop the `BUNDLES` entry, delete `<app>/config/rate_limiter.py`, then
|
|
196
|
+
`uv remove xtr-rate-limiter`.
|
|
197
|
+
- **Check** — `debug:bundles` shows `rate_limiter` as `listed` and `active`.
|
|
198
|
+
|
|
199
|
+
## Kernel / bundle
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
# app/bundles.py
|
|
203
|
+
from xtr_rate_limiter.bundle import RateLimiterBundle
|
|
204
|
+
|
|
205
|
+
BUNDLES = {RateLimiterBundle: {"all": True}}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# app/config/rate_limiter.py
|
|
210
|
+
from xtr_dependency_injection import configure, env
|
|
211
|
+
|
|
212
|
+
from xtr_rate_limiter import LimiterConfig, Rate
|
|
213
|
+
from xtr_rate_limiter.bundle import RateLimiterConfig
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@configure
|
|
217
|
+
def rate_limiter() -> RateLimiterConfig:
|
|
218
|
+
return RateLimiterConfig(
|
|
219
|
+
limiters={
|
|
220
|
+
"api": LimiterConfig("sliding_window", limit=100, interval="1 minute"),
|
|
221
|
+
"login": LimiterConfig(
|
|
222
|
+
"fixed_window", limit=5, interval="15 minutes", storage=env("REDIS_DSN")
|
|
223
|
+
),
|
|
224
|
+
"uploads": LimiterConfig("token_bucket", limit=10, rate=Rate("1 minute", amount=2)),
|
|
225
|
+
"strict": LimiterConfig("compound", limiters=["api", "uploads"]),
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Every limiter is a `RateLimiterFactoryInterface` qualified by its name:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from typing import Annotated
|
|
234
|
+
|
|
235
|
+
from xtr_dependency_injection import Target, as_service
|
|
236
|
+
|
|
237
|
+
from xtr_rate_limiter import RateLimiterFactoryInterface
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@as_service
|
|
241
|
+
class LoginThrottle:
|
|
242
|
+
def __init__(self, limiter: Annotated[RateLimiterFactoryInterface, Target("login")]) -> None:
|
|
243
|
+
self._limiter = limiter
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`RateLimiterConfig`:
|
|
247
|
+
|
|
248
|
+
| Field | Default | What it sets |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `limiters` | `{}` | every limiter, by name |
|
|
251
|
+
| `builder` | `BuilderConfig()` | where the `RateLimiterBuilder` service keeps its state |
|
|
252
|
+
| `redis_prefix` | `"rate_limiter:"` | what every key counted in Redis starts with |
|
|
253
|
+
|
|
254
|
+
Each `LimiterConfig` also says where its state lives:
|
|
255
|
+
|
|
256
|
+
| Field | Default | What it sets |
|
|
257
|
+
|---|---|---|
|
|
258
|
+
| `storage` | `"cache"` | `"cache"`, `"in-memory"`, a Redis DSN, or a `Reference` to a `StorageInterface` or a Redis client — also `env(...)` |
|
|
259
|
+
| `cache_pool` | `"rate_limiter"` | the pool a `"cache"` storage uses; the bundle adds it to the cache, on the application's adapter under a namespace of its own |
|
|
260
|
+
| `lock` | `"auto"` | the lock bundle's default factory when active, else within the process; a lock resource's name; or `None` for within the process only |
|
|
261
|
+
|
|
262
|
+
The bundle registers a factory per limiter and the `RateLimiterBuilder`, and its zero-config
|
|
263
|
+
path registers no limiter and touches no I/O. Boot checks every storage — a cache pool that
|
|
264
|
+
exists, a Redis DSN, a registered reference — so a mistake fails at startup rather than on the
|
|
265
|
+
first hit. A connection the bundle opened from a DSN is closed with the container.
|
|
266
|
+
|
|
267
|
+
## Errors
|
|
268
|
+
|
|
269
|
+
Everything this library raises derives from `RateLimiterError`, and carries what went wrong as
|
|
270
|
+
typed attributes rather than only a message.
|
|
271
|
+
|
|
272
|
+
| Error | Raised when |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `InvalidArgumentError` | a configuration or call cannot be used — also a `ValueError` |
|
|
275
|
+
| `InvalidIntervalError` | an interval cannot be read |
|
|
276
|
+
| `MaxWaitDurationExceededError` | a reservation would wait longer than `max_time` |
|
|
277
|
+
| `RateLimitExceededError` | `ensure_accepted()` on a refused limit |
|
|
278
|
+
| `ReserveNotSupportedError` | `reserve()` on a compound limiter |
|
|
279
|
+
| `RateLimiterStorageError` | the Redis server failed or could not be reached |
|
|
280
|
+
|
|
281
|
+
## Development
|
|
282
|
+
|
|
283
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
284
|
+
`packages/xtr-rate-limiter`; run the commands below from there. The `python-xtr-rate-limiter`
|
|
285
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
uv sync --all-extras
|
|
289
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The Redis scripts run in process, on a fake server; no Redis is needed.
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT — see [LICENSE](LICENSE).
|