Rate Limiting
RateLimitLayer caps how often a single principal can hit the router. The
shipped algorithm is a per-key token bucket with configurable burst size
and refill rate. Banks use it to dampen abuse on customer-facing channels
without writing per-route guards.
Wiring
RateLimitConfig carries:
burst— maximum tokens in the bucket (the largest peak the layer accepts)refill_per_second— tokens added back per wall-clock second
(60, 1.0) lets a caller burst 60 requests, then
steady-state 1 request per second.
Request flow
For every request the layer:- derives a key from the request (default:
Authorizationheader SHA-256 fingerprint) - asks the store to consume one token
- either forwards the request and adds
X-RateLimit-Limit+X-RateLimit-Remainingheaders to the response - or returns
429 Too Many RequestswithRetry-After: <seconds>and a typed error body
X-RateLimit-* headers ride on allowed responses only — a 429
carries Retry-After and no budget headers. There is no
X-RateLimit-Reset.
Content negotiation never rewrites the status of these responses. Accept
is caller-controlled, so passing a negotiation failure through would let any
caller downgrade its own throttle — Accept: text/html turning a 429 into a
406, a malformed Accept into a 400. An unsatisfiable or malformed Accept
instead falls back to the default codec (application/cbor) and keeps the
original status, which RFC 9110 §12.5.1 explicitly permits for a
server-originated response.
The same treatment covers the idempotency layer’s own
emitted errors — key conflict, in-flight 409, fingerprint refusal, and the
buffer-limit errors.
Key function
The default key takes the first of these the request carries:
The
Authorization header is unverified here, since this layer runs
before authentication; the budgets and their fallbacks are in the
cratestack-axum README.
(since 0.14.0, cratestack#1006)
The signed transport envelope layer is what produces a
VerifiedPrincipal for signed traffic (cose:<hex thumbprint> by default),
so a signed client is charged to its own princ: bucket with no
Authorization header or ConnectInfo. That only works with the envelope
outside this layer (its last .layer(..)), and it means signature
verification, with its key and nonce lookups, runs before the rate
limit. Put an IP-level limiter outside the envelope layer to bound what an
unauthenticated flood can cost. The 429 this layer answers a signed
request with is sealed by the envelope like any other response.
Banks running tenant-scoped budgeting override the default:
Exempting operations (@no_rate_limit)
A procedure marked @no_rate_limit (in a schema that declares
extension rate_limit { }) is not exempt by default. RateLimitLayer is
installed by your application, not generated, and it limits every request until
you tell it which operation a request is. Two ways, both reading the generated
descriptor tables:
with_op_resolver arrives in the release after 0.12.0 (cratestack#877). Before
it, @no_rate_limit cannot be honoured under a nested mount: the filters compare
against the path the schema declares, so behind .nest("/api", …) every lookup
misses. Both install paths fail closed — an operation nobody could identify is
rate limited, never exempted — and the two setters replace each other.
The resolver builders return a value that is not Clone, so give each layer its
own: call the builder once for IdempotencyLayer::with_op_resolver and once for
RateLimitLayer::with_op_resolver.
POST /rpc/batch is always rate limited as a whole: the lookup runs before the
batch body is decoded and cannot see the operations inside it. Call an exempt
operation at /rpc/{op_id} if it must stay exempt.
Stores
The shipped implementation isInMemoryRateLimitStore — a HashMap of
buckets behind a Mutex. It is appropriate for:
- single-replica deployments
- development and testing
- per-pod fairness in deployments where the upstream load balancer already shards by principal
Redis (cratestack-redis)
RedisRateLimitStore backs the same trait with a Redis hash per bucket key
and a Lua script that does the read-refill-decrement-write cycle in one
round-trip, so concurrent replicas can’t race the same bucket:
consume refreshes the bucket’s EXPIRE to the time it would take to
refill from empty (clamped to 24h), so idle buckets evict themselves —
no separate reaper needed even under a high-cardinality, tenant-scoped key
space.
Retry-once on a dropped connection
RedisRateLimitStore::consume re-issues its script exactly once when the
first attempt fails with a transport-class error, keyed on
RedisError::is_unrecoverable_error — precisely the set ConnectionManager
itself reconnects on, so “the driver considers this connection finished” and
“we treat it as transport-class” cannot drift apart. That set also covers a
half-read reply from a dying socket, which a narrower connection-dropped
check misses.
ConnectionManager’s contract is that the command which observes a dropped
connection still fails while the manager reconnects in the background, so a
Redis idle-timeout used to cost exactly one user-visible request. The retry
awaits the replacement connection instead.
Deliberately bounded: exactly one retry, never a loop, and never for a
deterministic refusal such as OOM or NOSCRIPT. consume is not
idempotent, so a retry after a mid-flight drop can spend a second token —
one token out of a bucket that exists for approximate capacity protection,
weighed against a failed user request. The idempotency store gets no such
retry, for the opposite reason: there, a double-apply is the exact thing it
exists to prevent.
Connection timeouts
Both Redis stores configure explicit connection and response timeouts on theConnectionManager (2 s each), so a store used outside this layer is
bounded too — including RedisIdempotencyStore, which stays fail-closed but
now fails promptly instead of awaiting an unbounded reconnect.
Writing a custom store
TheRateLimitStore trait is async and dyn-compatible, so a store other
than the two shipped above is a small surface to implement:
RateLimitDecision is either Allowed { remaining } or
Throttled { retry_after_secs }.
When the store fails
A store can fail: the socket breaks, Redis is unreachable, the server refuses a write. What the layer does then is configurable, and the default changed in 0.11.0 (#846).StoreErrorPolicy is class-conditional, not a blanket fail-open
StoreErrorPolicy::Allow is the default. It is not “serve every request
when the store misbehaves”. It serves a request unthrottled only when the
store failure is transport-class — the socket broke, the server is
unreachable, the lookup exceeded its budget. Backends signal that class with
CratestackError::Unavailable; every other store failure refuses the request
exactly as StoreErrorPolicy::Deny would.
That distinction is the whole design. A blanket fail-open rests on the
premise that a store failure is never caller-controlled, and that premise is
false: the default key function hashes an unvalidated Authorization
header, so an unauthenticated caller mints one Redis key per request just by
rotating it. Driven to maxmemory, every write then fails with OOM — and a
blanket fail-open would serve every request through, including from buckets
already exhausted. That is a global limiter bypass reachable by anyone.
So an OOM, a NOPERM, a poisoned mutex or a malformed reply — all
reachable-and-refusing, none self-healing — stay closed. A broken pipe is
caused by nobody, fixable by nobody in the request path, and self-heals;
refusing there would turn a limiter hiccup into a simultaneous outage of
every rate-limited route, which is why it is the one class that degrades to
unlimited.
Key derivation itself stays fail-closed under both policies: a request
the layer cannot identify is refused with 412 Precondition Failed before
the store is consulted at all. The policy is never reached on that path.
with_store_timeout bounds the lookup
with_store_timeout(Duration) — default 500 ms — caps one consume,
first attempt and any backend-internal retry, as a single budget. An
elapse is reported as a transport-class failure.
Without it, “degrade to unlimited” silently meant “hang, then allow”:
redis’s ConnectionManager defaults both its connection and response
timeouts to None, so each attempt awaited an unbounded reconnect cycle —
measured at 9.46 s, doubled to 18.92 s by the retry. Nineteen seconds of
blocking is worse for the caller than the refusal it replaced, and is itself
a denial-of-service lever.
What gets logged
Both store-error paths emit a throttledWARN, so an attacker-induced outage
cannot double as a log-volume amplifier. Each carries the count it suppressed:
"rate limit store error"— one 10-second budget, naming the error, the policy in force, and whether the request was served unthrottled- the fail-open notice — one 60-second budget, emitted only when a request actually goes through without a limit applied
cratestack_core::log_throttle (LogThrottle /
ThrottleDecision), a public module usable by any crate with the same
problem. It is deliberately not a general-purpose rate limiter: no token
bucket, no configuration, no allocation. The throttles are per-layer
instances rather than process-wide statics, so one router’s outage cannot
silence another’s.
Choosing parameters
Practical starting points:- customer-facing read endpoints: burst 30, refill 2.0 — accommodates page-load bursts
- mutating endpoints: burst 10, refill 0.5 — same caller can do meaningful work but not script floods
- operator/back-office endpoints: burst 600, refill 10.0 — humans behind a workstation, not bots
Caveats
InMemoryRateLimitStoreis bounded — idle buckets are swept after their TTL and live buckets are capped atDEFAULT_MAX_BUCKETS(100 000, tunable withwith_max_buckets) — but it is still one process’s heap. A deployment tracking that many distinct callers wantsRedisRateLimitStore.- The token bucket is wall-clock-driven; a process pause longer than one bucket-fill window grants a fresh burst on resume.
InMemoryRateLimitStoredoes not persist across restarts. That is the right choice for per-pod fairness and the wrong choice for global enforcement — reach forRedisRateLimitStorewhen buckets need to survive a restart or be shared across replicas.
Read Next
- Idempotency for the duplicate-execution protection that pairs naturally with rate limiting
- Auth provider for the principal model the key function reads from