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.
Files changed (55) hide show
  1. xtr_rate_limiter-2.0.0/LICENSE +21 -0
  2. xtr_rate_limiter-2.0.0/PKG-INFO +328 -0
  3. xtr_rate_limiter-2.0.0/README.md +296 -0
  4. xtr_rate_limiter-2.0.0/pyproject.toml +182 -0
  5. xtr_rate_limiter-2.0.0/pyproject.toml.orig +187 -0
  6. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/__init__.py +98 -0
  7. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/_interval.py +138 -0
  8. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/_lock.py +70 -0
  9. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/_time.py +14 -0
  10. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/bundle/__init__.py +9 -0
  11. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/bundle/builder_config.py +58 -0
  12. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/bundle/rate_limiter_bundle.py +347 -0
  13. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/bundle/rate_limiter_config.py +91 -0
  14. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/compound_limiter.py +70 -0
  15. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/compound_rate_limiter_factory.py +68 -0
  16. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/event/__init__.py +7 -0
  17. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/event/rate_limit_exceeded_event.py +34 -0
  18. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/__init__.py +27 -0
  19. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/invalid_argument_error.py +29 -0
  20. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/invalid_interval_error.py +22 -0
  21. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/max_wait_duration_exceeded_error.py +38 -0
  22. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/rate_limit_exceeded_error.py +51 -0
  23. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/rate_limiter_error.py +14 -0
  24. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/rate_limiter_storage_error.py +25 -0
  25. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/exception/reserve_not_supported_error.py +25 -0
  26. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/limiter_config.py +223 -0
  27. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/limiter_interface.py +58 -0
  28. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/limiter_state_interface.py +27 -0
  29. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/__init__.py +17 -0
  30. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/_stored_limiter.py +100 -0
  31. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/calendar_aligned_window.py +72 -0
  32. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/fixed_window_limiter.py +155 -0
  33. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/no_limiter.py +54 -0
  34. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/rate.py +76 -0
  35. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/sliding_window.py +101 -0
  36. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/sliding_window_limiter.py +119 -0
  37. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/token_bucket.py +84 -0
  38. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/token_bucket_limiter.py +123 -0
  39. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/policy/window.py +76 -0
  40. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/py.typed +0 -0
  41. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/rate_limit.py +114 -0
  42. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/rate_limiter_builder.py +88 -0
  43. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/rate_limiter_factory.py +109 -0
  44. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/rate_limiter_factory_interface.py +30 -0
  45. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/__init__.py +8 -0
  46. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/_script_runner.py +116 -0
  47. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/_scripts.py +340 -0
  48. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/redis_connection.py +87 -0
  49. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/redis_limiter.py +123 -0
  50. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/redis/redis_rate_limiter_factory.py +157 -0
  51. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/reservation.py +75 -0
  52. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/storage/__init__.py +9 -0
  53. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/storage/cache_storage.py +63 -0
  54. xtr_rate_limiter-2.0.0/src/xtr_rate_limiter/storage/in_memory_storage.py +86 -0
  55. 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).