Expand description
Per-principal rate limiting.
Token-bucket algorithm with a pluggable store. The default in-memory
implementation is appropriate for single-instance deployments; banks
running multiple replicas bring a Redis-backed implementation through
the RateLimitStore trait so all replicas share the same view of
consumption.
The middleware computes a key per request (the default hashes the
Authorization header when present and otherwise falls back to the
verified TCP peer address, the same shape the idempotency layer uses)
and refuses with 429 plus a Retry-After header when the bucket is
empty. Banks running tenant-scoped budgeting can swap the key function
for tenant-id.
Usage:
use cratestack_axum::ratelimit::{InMemoryRateLimitStore, RateLimitConfig, RateLimitLayer};
use std::net::SocketAddr;
let store = std::sync::Arc::new(InMemoryRateLimitStore::default());
let router = generated_router.layer(RateLimitLayer::new(store, RateLimitConfig::new(100, 1.0)));
// The peer-address fallback below only kicks in when the server is
// served through `into_make_service_with_connect_info`:
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router.into_make_service_with_connect_info::<SocketAddr>()).await?;This wiring matters. Nothing in this crate — and, as of this
writing, no example shipped in this repository — serves through
into_make_service_with_connect_info by default; every example uses
plain into_make_service(). Without it, ConnectInfo<SocketAddr> is
never present in request extensions, so every request without an
Authorization header is refused with 412 Precondition Failed
(cratestack#416 — the default used to silently collapse such requests
onto a shared "anonymous" bucket instead, which meant no per-caller
throttling at all for that traffic; it now refuses rather than risk
that collision). Consumers who authenticate via cookies/mTLS rather
than an Authorization header — and who cannot serve through
into_make_service_with_connect_info — must supply
RateLimitLayer::with_key_fn explicitly.
§Store failures fail open ONLY when they are transport-class (cratestack#846)
Identity derivation above is fail-closed because its inputs are
caller-controlled. A transport failure of the store is not: when
Redis drops a connection, no caller caused it, no caller can fix it,
and it self-heals — so refusing would turn a limiter hiccup into a
simultaneous outage of every rate-limited route. The layer logs a
WARN and lets those through.
A store that is reachable and refusing is a different animal, and
the reason this is not a blanket fail-open: an OOM is inducible by
any unauthenticated caller, because the default key function above
hashes an unvalidated Authorization header. Those failures stay
closed under every policy. See StoreErrorPolicy for the full
argument. Deployments using the limiter as a security control opt into
refusing even transport failures with
RateLimitLayer::with_store_error_policy/StoreErrorPolicy::Deny,
and RateLimitLayer::with_store_timeout bounds how long a lookup
may block before the policy applies at all.
Every response the layer emits itself — the throttled 429, an
identity refusal, a Denyd store failure — carries the framework’s
own codec-negotiated error envelope, so a generated client decodes a
typed code rather than an opaque body.
§The bucket keyspace is bounded (cratestack#871)
The auth: key above is a hash of an unverified header, because
this layer runs before authentication. Left alone, that is an
amplification primitive: rotate the header and mint one store key per
request. It is what made the OOM case in the previous section
reachable by anyone in the first place.
So the default derivation no longer returns a bare key. It returns the
key plus a cratestack_core::BucketBudget naming the scope that
key is counted against, how many distinct buckets that scope may hold
at once, and which bucket to charge instead once it is full:
| Request carries | Key | Scope | Cap | Fallback |
|---|---|---|---|---|
VerifiedPrincipal extension | princ:<sha256> | — | none | — |
Authorization + ConnectInfo | auth:<sha256> | peer:<addr> | 128 | ip:<addr> |
Authorization, no ConnectInfo | auth:<sha256> | global | 8192 | overflow |
ConnectInfo only | ip:<addr> | — | none | — |
| neither | refused, 412 (cratestack#416) |
<addr> is the peer address for IPv4 and its /64 prefix for IPv6,
in the scope AND in every bucket key. Aggregating only the scope left
the whole mechanism evadable by rotating the source address inside one
subscriber prefix — measured at 200 buckets with a token and 200
buckets, all allowed, without one.
The store applies the budget atomically alongside the token consumption — doing it as a separate lookup would race, with N concurrent requests each reading “under budget” and each minting a bucket.
Each admitted credential holds a slot that expires
cratestack_core::scope_ttl_secs after it was last used — at least
the buckets’ own TTL, so a slot always outlives the bucket it admitted
and no fresh generation can open beneath a live one. The window slides
per credential: an actively-used caller never loses its slot, and a
peer whose tokens rotate reclaims the slots of credentials it stopped
using. window (default 60s) is the floor on that lifetime, not a
fixed period that resets the scope. Keyspace is O(peers × cap) at every
instant.
Over the cap the caller is collapsed onto its own ip: bucket, not
refused: refusing would hand an attacker a deterministic outage of
every rate-limited route, which is the failure mode cratestack#846 was
fought over. Under the cap, distinct callers still never share
(cratestack#416). Tune with
RateLimitLayer::with_bucket_budget, opt out with
RateLimitLayer::without_bucket_budget, and see
UnverifiedAuthPolicy for the stronger “ignore the header entirely”
mode. docs/design/ratelimit-bucket-cardinality.md states what is
not bounded.
Structs§
- InMemory
Rate Limit Store - In-memory
RateLimitStore. Suitable for single-replica deployments and development; banks running multi-replica clusters need a Redis-backed implementation so the limit is enforced cluster-wide. - Rate
Limit Bucket Budget - Caps on how many distinct rate-limit buckets one scope may create,
applied by
super::RateLimitLayer’s default key derivation. - Rate
Limit Config - Configuration for a single bucket: capacity (max burst) and refill rate in tokens per second. Banks running high-frequency back-office traffic pick large bursts; consumer-facing channels use small bursts to dampen abuse.
- Rate
Limit Layer - Rate
Limit Service - Verified
Principal - A caller identity an upstream layer has actually verified.
Enums§
- Rate
Limit Decision - Result of attempting to consume a token.
Allowedcarries the number of tokens left after consumption;Throttledcarries seconds the caller should wait before retrying. - Store
Error Policy - How
super::RateLimitLayertreats a failure of the backingsuper::RateLimitStore, as distinct from a caller who is genuinely over budget. - Unverified
Auth Policy - What to do with an
Authorizationheader that nothing has verified — which is everyAuthorizationheader this layer sees, since it runs before authentication.
Constants§
- DEFAULT_
MAX_ BUCKETS - Default ceiling on live buckets.
- DEFAULT_
STORE_ TIMEOUT - Default ceiling on one
store.consumecall — first attempt and any backend-internal retry, as a single budget.
Traits§
- Rate
Limit Store - Pluggable storage for token-bucket state. Implementations must be safe to share across tasks (use a Mutex internally, or rely on the backing store’s atomicity).
Functions§
- build_
rest_ ops_ filter - Build a rate-limit filter function for REST schemas.
- build_
rpc_ ops_ filter - Build a rate-limit filter function for RPC schemas.