Skip to main content

Module ratelimit

Module ratelimit 

Source
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 carriesKeyScopeCapFallback
VerifiedPrincipal extensionprinc:<sha256>none
Authorization + ConnectInfoauth:<sha256>peer:<addr>128ip:<addr>
Authorization, no ConnectInfoauth:<sha256>global8192overflow
ConnectInfo onlyip:<addr>none
neitherrefused, 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§

InMemoryRateLimitStore
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.
RateLimitBucketBudget
Caps on how many distinct rate-limit buckets one scope may create, applied by super::RateLimitLayer’s default key derivation.
RateLimitConfig
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.
RateLimitLayer
RateLimitService
VerifiedPrincipal
A caller identity an upstream layer has actually verified.

Enums§

RateLimitDecision
Result of attempting to consume a token. Allowed carries the number of tokens left after consumption; Throttled carries seconds the caller should wait before retrying.
StoreErrorPolicy
How super::RateLimitLayer treats a failure of the backing super::RateLimitStore, as distinct from a caller who is genuinely over budget.
UnverifiedAuthPolicy
What to do with an Authorization header that nothing has verified — which is every Authorization header 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.consume call — first attempt and any backend-internal retry, as a single budget.

Traits§

RateLimitStore
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.