xtr-rate-limiter
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.
Why?
A limit is not a web concern. A worker calling a paid API, a job retrying a flaky upstream, a login form, a message consumer draining a queue — each needs to know whether it may go ahead now, or when it may. This library answers that for any key, from any code, without a web framework in sight.
- 🪣 Three policies. A fixed window, a sliding window that smooths bursts at window edges, and a token bucket that allows bursts then a steady rate — plus no limit at all.
- ⏳ Decide now, or book ahead.
consume()grants tokens or refuses;reserve()books them for the moment they come free and says when that is. - 🧱 Anywhere the state fits. In this process, in a cache pool shared by every process, or on a Redis server that counts each hit in one atomic script — no lock needed.
- 🧩 Combined limits. 10 a minute and 1 000 a day, as one limiter.
Install
uv add xtr-rate-limiter # policies, in-memory storage
uv add "xtr-rate-limiter[redis]" # + atomic counting on a Redis server
uv add "xtr-rate-limiter[cache]" # + state in a cache pool
uv add "xtr-rate-limiter[lock]" # + locks shared between processes
uv add "xtr-rate-limiter[di]" # + the bundle
Quick start
A factory is one configured limit; it hands out a limiter per key:
from xtr_rate_limiter import InMemoryStorage, LimiterConfig, RateLimiterFactory
factory = RateLimiterFactory(
"api",
LimiterConfig("sliding_window", limit=100, interval="1 minute"),
InMemoryStorage(),
)
limit = await factory.create(client_ip).consume()
if not limit.is_accepted():
raise TooBusy(retry_after=limit.retry_after)
consume() answers with a RateLimit:
| Member | Meaning |
|---|---|
is_accepted() |
the tokens were granted |
remaining_tokens |
tokens left |
retry_after |
when the tokens asked for become available |
reset_at |
when the limiter holds every token again (None for no limit) |
limit |
tokens held when full |
ensure_accepted() |
return the limit, or raise RateLimitExceededError |
await wait() |
wait until retry_after |
Consuming 0 tokens reports the limit as it stands and spends nothing.
A peek acts now: reserve(0) books nothing and its time_to_act is the present moment, with
the wait to the next free token in the limit's retry_after.
Waiting instead of refusing
reserve() books the tokens for when they come free. From then on they count as spent, so
callers are served in the order they reserved:
reservation = await limiter.reserve(tokens=1, max_time=5)
await reservation.wait()
await call_the_api()
A wait longer than max_time raises MaxWaitDurationExceededError and books nothing.
Policies
LimiterConfig's policy picks which of its other fields apply:
LimiterConfig("fixed_window", limit=5, interval="15 minutes")
LimiterConfig("fixed_window", limit=1000, interval="1 month", anchor_at="2026-01-01")
LimiterConfig("sliding_window", limit=100, interval="1 minute")
LimiterConfig("token_bucket", limit=10, rate=Rate("1 minute", amount=2))
LimiterConfig("no_limit")
LimiterConfig("compound", limiters=["per_minute", "per_day"])
fixed_window— at mostlimithits per window ofinterval. A window opens on the first hit and covers[start, start + interval), so a hit at exactly one interval past the start opens the next window; withanchor_at(an interval of a month or more) windows follow a calendar instead — the first of each month, whenever hits arrive. Cheap, but a burst at the end of one window and another at the start of the next go through together.sliding_window— at mostlimithits in any span ofinterval, estimated from the current window and a fading share of the last one. Smooths the edge burst out.token_bucket— a bucket oflimittokens refilled byrate: an idle caller can burst, a busy one settles to the rate.no_limit— accepts everything; for a limit turned off in one environment.compound— every one oflimitersapplies to each hit (configured by the bundle; in code,CompoundRateLimiterFactory). The answer is the first refusal, or the limit closest to running out. Limits consulted before a refusal keep their spend, and a compound limiter cannotreserve().
Intervals are written "30 seconds", "1 hour 30 minutes", "2 weeks", "1 month" — or as a
timedelta. A reservation beyond a window's size is a debt the following windows pay, so no
token is ever handed out twice.
Where the state lives
| Storage | Shared by | Guarded by |
|---|---|---|
InMemoryStorage() |
this process | a lock within the process |
CacheStorage(pool) |
every process reaching the pool | a lock from a LockFactory |
RedisRateLimiterFactory(...) |
every process reaching the server | one atomic script per hit |
A storage limiter reads its state, decides, and writes it back under a lock on the key — held
within the process when no lock factory is given, which already matters in one process: a task
can be switched away from between the read and the write. Pass an xtr-lock LockFactory to
share the lock between processes:
factory = RateLimiterFactory("api", config, CacheStorage(pool), LockFactory(RedisStore(redis)))
A cache pool never fails loudly: a pool whose backend is down reads as empty, so a limiter on it lets every hit through until the backend is back. Use Redis for limits that must hold then.
CacheStorage keys every state "rl." and a hash of the limiter's id, so a pool it shares with
the rest of the application cannot collide with a count. The pool still encodes the state with
pickle by default, and reading one runs code the stored bytes name — on a backend anything
outside the application can write to, give that pool a SodiumMarshaller, which refuses bytes
it cannot authenticate before pickle ever sees them.
Redis
RedisRateLimiterFactory keeps each limiter's state in one hash and changes it in one script
the server runs whole — no lock, one round trip, and the server's own clock, so the processes'
clocks need not agree:
from redis.asyncio import Redis
from xtr_rate_limiter.redis import RedisRateLimiterFactory
factory = RedisRateLimiterFactory(
"login",
LimiterConfig("fixed_window", limit=5, interval="15 minutes"),
Redis.from_url("redis://localhost:6379"),
)
Keys are "<prefix><length>:<id>:<key>" (prefix="rate_limiter:", length the id's, so the
limit "x" keyed "a-b" and the limit "x-a" keyed "b" cannot share a count), each expiring
once it no longer matters. A server that cannot be reached raises RateLimiterStorageError
rather than guessing: whether to let a hit through then is the caller's call. A server refusing
to read its clock in a script is sent this process's instead; a calendar-anchored window always
counts by this process's clock, since the calendar is worked out here.
Building limits in code
For limits decided at runtime — per tenant, per plan:
builder = RateLimiterBuilder(InMemoryStorage())
factory = builder.token_bucket(f"tenant-{tenant.id}", limit=tenant.burst, interval="1 second")
sliding_window, fixed_window, token_bucket, compound and noop build a factory each.
Reporting a refusal
A denied hit is not a rejection by itself — a caller may wait, retry or carry on — so limiters
dispatch nothing. Code that turns a hit away can dispatch RateLimitExceededEvent(rate_limit, limiter_name, key) through its event dispatcher.
Use in an application
Everything adding this package to an application on xtr-dependency-injection takes — and, read backwards, what removing it undoes.
- Install —
uv add "xtr-rate-limiter[di]"; addcacheand xtr-cache for state shared through a cache pool,lockand xtr-lock for a lock shared between processes,redisto count on a Redis server. - Recipe —
uv run xtr-recipes recipes:syncdoes the Activate step below: it listsRateLimiterBundle. There is no config file, environment or ignore line to write; it prints the step to name your limiters inconfig/rate_limiter.py, which a recipe cannot make for you. - Activate —
RateLimiterBundle: {"all": True}inBUNDLESin<app>/bundles.py, imported fromxtr_rate_limiter.bundle. - Brings along — the cache bundle when xtr-cache is installed, and the lock bundle when xtr-lock is.
- Configure — optional: with no configuration there are no limiters. Limiters go in
<app>/config/rate_limiter.py, a@configurefunction returningRateLimiterConfig— see Kernel / bundle. - Environment — nothing required. A storage given as
env(...)must be set when the application boots: booting checks every limiter. - Ignore — nothing beyond what the cache and lock bundles write under
var/. - Remove — drop the
BUNDLESentry, delete<app>/config/rate_limiter.py, thenuv remove xtr-rate-limiter. - Check —
debug:bundlesshowsrate_limiteraslistedandactive.
Kernel / bundle
# app/bundles.py
from xtr_rate_limiter.bundle import RateLimiterBundle
BUNDLES = {RateLimiterBundle: {"all": True}}
# app/config/rate_limiter.py
from xtr_dependency_injection import configure, env
from xtr_rate_limiter import LimiterConfig, Rate
from xtr_rate_limiter.bundle import RateLimiterConfig
@configure
def rate_limiter() -> RateLimiterConfig:
return RateLimiterConfig(
limiters={
"api": LimiterConfig("sliding_window", limit=100, interval="1 minute"),
"login": LimiterConfig(
"fixed_window", limit=5, interval="15 minutes", storage=env("REDIS_DSN")
),
"uploads": LimiterConfig("token_bucket", limit=10, rate=Rate("1 minute", amount=2)),
"strict": LimiterConfig("compound", limiters=["api", "uploads"]),
},
)
Every limiter is a RateLimiterFactoryInterface qualified by its name:
from typing import Annotated
from xtr_dependency_injection import Target, as_service
from xtr_rate_limiter import RateLimiterFactoryInterface
@as_service
class LoginThrottle:
def __init__(self, limiter: Annotated[RateLimiterFactoryInterface, Target("login")]) -> None:
self._limiter = limiter
RateLimiterConfig:
| Field | Default | What it sets |
|---|---|---|
limiters |
{} |
every limiter, by name |
builder |
BuilderConfig() |
where the RateLimiterBuilder service keeps its state |
redis_prefix |
"rate_limiter:" |
what every key counted in Redis starts with |
Each LimiterConfig also says where its state lives:
| Field | Default | What it sets |
|---|---|---|
storage |
"cache" |
"cache", "in-memory", a Redis DSN, or a Reference to a StorageInterface or a Redis client — also env(...) |
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 |
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 |
The bundle registers a factory per limiter and the RateLimiterBuilder, and its zero-config
path registers no limiter and touches no I/O. Boot checks every storage — a cache pool that
exists, a Redis DSN, a registered reference — so a mistake fails at startup rather than on the
first hit. A connection the bundle opened from a DSN is closed with the container.
Errors
Everything this library raises derives from RateLimiterError, and carries what went wrong as
typed attributes rather than only a message.
| Error | Raised when |
|---|---|
InvalidArgumentError |
a configuration or call cannot be used — also a ValueError |
InvalidIntervalError |
an interval cannot be read |
MaxWaitDurationExceededError |
a reservation would wait longer than max_time |
RateLimitExceededError |
ensure_accepted() on a refused limit |
ReserveNotSupportedError |
reserve() on a compound limiter |
RateLimiterStorageError |
the Redis server failed or could not be reached |
Development
Developed in the python-xtr monorepo, under
packages/xtr-rate-limiter; run the commands below from there. The python-xtr-rate-limiter
repository is a read-only copy, so send issues and pull requests to the monorepo.
uv sync --all-extras
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
The Redis scripts run in process, on a fake server; no Redis is needed.
License
MIT — see LICENSE.
Metadata
Release files for xtr-rate-limiter 4.0.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xtr_rate_limiter-4.0.3.tar.gz | 57.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xtr_rate_limiter-4.0.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 136.8 kB
Release files / xtr_rate_limiter-4.0.3.tar.gz
| Download URL | xtr_rate_limiter-4.0.3.tar.gz |
|---|---|
| Size | 57.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b4ea7ae799250f5e681914350e2db9ceeae24159f815d5059b186e8b24eb0e08
|
|
BLAKE2b-256 checksum How to use checksums |
6d683b23116d951eca04163ea1ec8b61bf18b9f51980cf7aee1482bc8770de06
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / xtr_rate_limiter-4.0.3-py3-none-any.whl
| Download URL | xtr_rate_limiter-4.0.3-py3-none-any.whl |
|---|---|
| Size | 79.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0c11fd32c89d0be3ebbb1ed786b4964e317d2d47884ec1a88be711d74950d0c0
|
|
BLAKE2b-256 checksum How to use checksums |
d9239710ff035d929c9a6aec1b37a11ff28edf8af17742108d853b11cbfaffda
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency log