Suprnova ships two complementary rate-limit surfaces:
| Surface | Use when... | Backend |
|---|---|---|
RateLimiterDriver + RateLimitMiddleware |
You want strict sliding-window enforcement against arbitrary storage (Redis ZSET, in-memory deque) | dyn RateLimiterDriver |
RateLimiter + ThrottleRequestsMiddleware |
You want Laravel-shape named limiters, attempt() workflow callbacks, or X-RateLimit-* response headers |
Cache store (memory or Redis) |
The sliding-window driver is Suprnova's native shape - one slot per request, no separate timer key, atomic Lua eval on Redis. The Laravel facade is what migrated apps reach for and what the named-limiter / response-callback pattern requires. The two coexist by design, and a route can layer both.
Sliding-window driver SPI
RateLimiterDriver is the storage SPI for the sliding-window algorithm. Each key tracks a deque of hit timestamps. On every try_acquire, entries older than now - window are evicted; if the remaining count is below max_requests, now is appended and the call accepts. Otherwise it rejects.
use Arc;
use Duration;
use InMemoryRateLimiter;
use ;
let limiter: = new;
let cfg = SlidingWindowConfig ;
let ok = limiter.try_acquire.await?;
if !ok
Built-in drivers
| Driver | Storage | Selected via |
|---|---|---|
InMemoryRateLimiter |
Per-process HashMap<String, Bucket> with tokio::time::Instant so start_paused tests can drive the clock |
RATE_LIMIT_DRIVER=memory (default) |
RedisRateLimiter |
Redis ZSET + Lua atomic check-and-record | RATE_LIMIT_DRIVER=redis + RATE_LIMIT_REDIS_URL |
bootstrap_from_env() wires the matching driver into the container. Outside production an unknown driver value falls back to memory with a warn! log.
Production fails closed on the in-memory driver
In production, resolving to the in-memory limiter is a boot failure:
refusing to boot in production: RATE_LIMIT_DRIVER is unset, which defaults
to the in-memory limiter. Per-process buckets mean every configured quota
is multiplied by your replica count and reset by every deploy...
The in-memory driver keeps its buckets in one process's heap. Behind N replicas each keeps its own count, so a "5 attempts per 15 minutes" password-reset throttle is really 5N, and every deploy resets all of them to zero. The limit you configured is not the limit you get - and nothing says so, because the requests succeed, which is what a working throttle looks like from the outside. It surfaces as a credential-stuffing or account-enumeration incident, not as an error.
An unrecognised driver value fails for the same reason: it falls back
to memory. RATE_LIMIT_DRIVER=Redis - capitalised - would otherwise warn
once at boot and quietly leave a multi-replica deployment throttling
per-process. That is the case most likely to reach production, because it
looks configured.
Either point it at Redis:
RATE_LIMIT_DRIVER=redis
RATE_LIMIT_REDIS_URL=redis://cache.internal:6379
or, if you genuinely run a single process, say so:
RATE_LIMIT_ALLOW_MEMORY_IN_PRODUCTION=true
Development, testing and staging are untouched. Staging is deliberately not gated, on the same reasoning as the mail guard: hard failing it pushes teams to set the override globally, which disarms the check exactly where it matters.
RateLimitMiddleware
The HTTP wrapper around the driver. Construct with a key_fn closure to drive bucket selection per-request:
use Arc;
use Duration;
use App;
use ;
let limiter: =
.unwrap;
let mw = new
.on_backend_error;
On rejection (over quota) it returns HTTP 429 with a Retry-After header.
Limiting per recipient, not just per caller
An address-keyed limit answers is one client making too many requests. It cannot answer is one mailbox being flooded. An attacker spread across a botnet, a proxy pool, or a single IPv6 /64 stays under every per-IP budget while sending one victim thousands of password-reset emails - the inbox is the resource being exhausted, and the victim's address is the only thing those requests share. The reverse hurts too: behind carrier-grade NAT or an office gateway, per-IP limits punish a crowd for one member's behaviour.
identity_key keys a bucket on the account being acted on:
use ;
let per_recipient = new
.key_reads_body
.only_when
.on_backend_error;
Stack it alongside a per-IP limiter rather than replacing one with the other. Each catches what the other cannot: per-IP stops one host enumerating many addresses; per-recipient stops many hosts targeting one address.
Three details carry the security:
key_reads_bodybuffers the body (to the given cap) before the key is computed, so the field can be read out of a form-encoded POST as well as a query string. It is opt-in because buffering is work an unauthenticated caller gets to make you do; the cap bounds it. A body over the cap is rejected with 413 rather than passed through unkeyed - otherwise padding the body would be a way out of the limit.only_whenskips the limiter for requests that name nobody. Without it those fall intoidentity_key's address fallback and are counted against this limiter's quota - and since a per-recipient budget is normally the tighter of the pair, it would silently become the binding limit for every route that names no one.- The value is normalised and hashed.
Alice@Example.comandalice@example.comreach the same mailbox and must share a bucket, or the limit is bypassed by changing capitalisation. The result is hashed because a rate-limit backend is frequently a shared Redis with weaker access control than the primary database, and a key dump should not read as a list of who is resetting their password.
Backend-error policy
BackendErrorPolicy governs what happens when the limiter backend itself errors - e.g. Redis is unreachable - as distinct from a request legitimately exceeding its quota. The backend cannot make a decision, so the middleware must choose between availability and the limit's guarantee.
| Policy | Behaviour | When to use |
|---|---|---|
FailOpen (default) |
Pass the request through; log at warn |
Most public APIs - a limiter outage should not take down traffic |
FailClosed |
Reject with HTTP 503 + Retry-After: 1; log at error |
Sensitive routes (login, password reset, payments) where unbounded traffic during a backend outage is worse than briefly rejecting |
Choose with .on_backend_error(BackendErrorPolicy::FailClosed) on the middleware. Quota-exhausted requests are always 429 regardless of the policy - the policy only affects backend-error fallthrough.
Cache-backed Laravel-shape facade
RateLimiter (the struct) mirrors Illuminate\Cache\RateLimiter. It's a fixed-window counter built on top of the Suprnova Cache facade. Use it for named limiters, attempt() workflows, or any time you want the X-RateLimit-* headers Laravel apps expect.
Storage layout
For an attempt counter key K with decay of D seconds:
K- i64 counter incremented by everyhit. Initial seed is 0 (viaCache::add).K:timer- i64 unix-seconds-since-epoch when the window ends, set viaCache::addso only the first caller in a window pins the deadline.
Both keys carry the same TTL so the cache cleans them up automatically when the window ends. When the counter has reached max_attempts but the :timer is gone, too_many_attempts resets the counter - this is what makes the window slide forward after a quota-exhausted period.
Counter API
use RateLimiter;
// Burn one attempt; seeds the window if missing.
let n = hit.await?;
// Burn one attempt AND test the limit in a single atomic round-trip.
// Returns `true` when this hit pushed the bucket over `max` (refuse the
// request), `false` when it was admitted. Use this instead of a separate
// `too_many_attempts` + `hit` pair: checking and then hitting as two calls
// lets concurrent requests slip past the limit (a check-then-act race).
// `i64::MAX` as the max means "unlimited" - always admits, still counts.
let over_limit = hit_and_check.await?;
if over_limit
// Increment by N; useful for "cost-weighted" limits (each request burns
// more than one attempt).
let n = increment.await?;
// Read the current count (0 when never hit or expired).
let attempts = attempts.await?;
// Number of seconds until the window reopens (0 when no window open).
let secs = available_in.await?;
// Retries left before tripping.
let remaining = remaining.await?;
// retries_left is the Laravel-spelt alias of remaining.
let remaining = retries_left.await?;
// Is the bucket over its limit RIGHT NOW (with window still open)?
let over = too_many_attempts.await?;
// Drop only the counter (timer stays - the window is still pinned).
reset_attempts.await?;
// Drop both counter and timer.
clear.await?;
attempt() workflow
Run a callback only when the bucket is under quota; the hit is only burned when the callback runs:
let result = attempt.await?;
match result
This is the right shape for login forms - you don't burn an attempt unless the work actually reached the callback.
Named limiters
Register at boot, resolve at request time. The Laravel-side name for is a Rust reserved keyword, so the primary Rust-side name is define; the literal Laravel alias is exposed via r#for.
use ;
// At boot - `define` is the primary Rust-side name.
define;
// Laravel-side alias - same thing under the keyword-escape spelling.
r#for;
// Resolve.
let cb = limiter.unwrap;
let limit_result = cb;
A named-limiter callback returns a [LimitResult], constructible from:
- A single
Limit- apply this limit. - A
Vec<Limit>- apply every limit; first to trip wins. - An
HttpResponse- short-circuit immediately with this response (used for "admin gets unlimited access" viaLimit::none(), or to refuse the request outright).
Sanitising keys
RateLimiter::clean_rate_limiter_key(key) strips &abc; HTML-entity markers from a key - Laravel uses this for user-supplied strings that round-trip through htmlentities. Suprnova reproduces the strip stage exactly but does NOT prepend the htmlentities encoding (which only matters for non-UTF-8 inputs, irrelevant for Rust String). The function is deterministic and idempotent inside Suprnova; consumers who need byte-identical hashing with a PHP service should run their own htmlentities pre-step on the input.
assert_eq!;
Limit builder
The data type returned by named-limiter callbacks. Shorthand constructors mirror Laravel's Limit::per*:
use Limit;
use Duration;
per_second; // 10 per 1 second (max_attempts, decay_seconds)
per_minute; // 60 per minute
per_minutes; // 100 per 5 minutes (decay-first, Laravel signature)
per_hour; // 1000/hr
per_hours; // 5000 per 6 hours
per_day; // 10000/day
per_days; // 50000 per 7 days
new; // bare ctor
// Builder chain.
let l = per_minute
.by
.response
.after;
.by(key)- set the bucket key. Empty key is "global" (every caller shares one bucket)..response(callback)- generate a custom response when the limit trips; the default is plain 429 "Too Many Attempts."..after(callback)- only burn the attempt whencallback(response)returns true. Canonical use: only count failed logins (after(|r| r.status_code() >= 400)).
Limit::none() returns an Unlimited (a GlobalLimit with max_attempts = i64::MAX). Returning it from a named limiter is the Laravel pattern for bypass. GlobalLimit itself is a thin wrapper around Limit with an empty key, kept for parity with Illuminate\Cache\RateLimiting\GlobalLimit.
ThrottleRequestsMiddleware
HTTP wrapper around the Cache-backed facade. Mirrors Illuminate\Routing\Middleware\ThrottleRequests. Three constructors:
use ;
// Named limiter - resolves at request time via RateLimiter::limiter(name).
by_name;
// Inline max/decay/prefix - the literal Laravel `throttle:60,1` shape.
with;
// Explicit list of Limits - first-to-trip wins; most Rust-idiomatic.
with_limits;
Wire it into a route group:
use ;
define;
let router = new
.get
.post
.middleware;
Key on req.ip(), never on the header
X-Forwarded-For is caller-supplied. A limiter keyed on the raw header is
defeated by sending a different value on each request - the attacker picks
their own bucket, so the quota is per-request rather than per-client.
Request::ip() is the safe read. It returns X-Forwarded-For / X-Real-IP
only when the TCP peer is listed in APP_TRUSTED_PROXIES, and otherwise
the peer address, so a header from anyone but your own proxy is ignored.
The corollary matters as much: with that variable unset - the default -
req.ip() behind a terminating proxy returns the proxy's address on every
request, and every per-IP limit in the app collapses into a single shared
bucket. ThrottleRequestsMiddleware::with(20, 1, "login") then means 20
attempts a minute across all users combined, which any one caller can spend
to lock everybody out. Deploying behind nginx, Traefik, an ALB or Cloudflare
means setting APP_TRUSTED_PROXIES.
Response headers
Every wrapped response carries:
X-RateLimit-Limit- the configuredmax_attempts.X-RateLimit-Remaining- retries left for this bucket.
429 responses additionally carry:
Retry-After- seconds until the window reopens.X-RateLimit-Reset- unix-seconds-since-epoch when the bucket reopens.
This matches Laravel's ThrottleRequests::getHeaders shape exactly.
Missing named limiter
When a route is wired to by_name("X") but no limiter under X has been registered, the middleware returns HTTP 503 with a body that names the missing limiter. Laravel throws MissingRateLimiterException; we surface it as an HTTP response so a misconfigured boot does not panic the worker thread.
Driver-vs-facade composition
The two middlewares can coexist on a single router. Layer the sliding-window driver for low-level fairness, then the Cache-backed throttle for per-endpoint named limits:
let router = new
.get
.middleware
.middleware;
Configuration
The driver SPI is configured via environment variables; the Cache-backed facade is configured wherever your Cache store is configured (memory or Redis).
| Variable | Used by | Default |
|---|---|---|
RATE_LIMIT_DRIVER |
Driver SPI bootstrap | memory (refused in production - see above) |
RATE_LIMIT_ALLOW_MEMORY_IN_PRODUCTION |
Production fail-closed override | unset |
RATE_LIMIT_REDIS_URL |
Redis driver | redis://127.0.0.1:6379 |
RATE_LIMIT_PREFIX |
Redis key prefix | suprnova: |
CACHE_DRIVER / REDIS_URL / CACHE_DEFAULT_TTL / REDIS_PREFIX |
Cache-backed RateLimiter facade (see Cache) |
various |
Migration from Laravel
| Laravel | Suprnova |
|---|---|
RateLimiter::for('api', fn ($req) => Limit::perMinute(60)) |
RateLimiter::define("api", |req| Limit::per_minute(60).into()) or RateLimiter::r#for(...) |
RateLimiter::hit($key, $decay) |
RateLimiter::hit(key, decay).await? |
RateLimiter::tooManyAttempts($key, $max) |
RateLimiter::too_many_attempts(key, max).await? |
RateLimiter::availableIn($key) |
RateLimiter::available_in(key).await? |
RateLimiter::attempt($key, $max, $cb, $decay) |
RateLimiter::attempt(key, max, || async { ... }, decay).await? |
RateLimiter::retriesLeft($key, $max) |
RateLimiter::retries_left(key, max).await? |
RateLimiter::cleanRateLimiterKey($key) |
RateLimiter::clean_rate_limiter_key(key) |
Limit::perMinute(60)->by($ip)->response(fn () => abort(429)) |
Limit::per_minute(60).by(ip).response(|_| HttpResponse::text("...").status(429)) |
Limit::perMinutes(3, 100) |
Limit::per_minutes(3, 100) |
Limit::none() |
Limit::none() |
throttle:api middleware |
ThrottleRequestsMiddleware::by_name("api") |
throttle:60,1 middleware |
ThrottleRequestsMiddleware::with(60, 1, "") |
X-RateLimit-Limit/Remaining/Reset + Retry-After headers |
Same headers, same shape |
Why Suprnova diverges
Laravel ships one shape: Illuminate\Cache\RateLimiter (Cache-backed fixed-window counter) with Illuminate\Routing\Middleware\ThrottleRequests as its HTTP wrapper. Suprnova ships both that shape and a native sliding-window driver SPI because two real questions need two real answers.
A Cache-backed counter is the right answer to "I have named limiters, response callbacks, after-callbacks for failed-login-only counting, and I want to be source-compatible with Laravel migrations." It's the wrong answer to "I need exact one-slot-per-request sliding-window enforcement against a Redis ZSET with atomic Lua eval and no separate timer key." That second question is what most Rust services hitting Tokio's concurrency limits actually have, so RateLimiterDriver + RateLimitMiddleware exist alongside, not behind a feature flag.
The backend-error policy is also a Suprnova addition. Laravel's middleware never surfaces a "the limiter is broken" decision because PHP's per-request lifecycle hides it - the next request gets a fresh process. A long-lived Tokio worker that loses Redis for ten seconds must decide what to do with the requests arriving during that window; BackendErrorPolicy::FailOpen (default) vs FailClosed is that decision exposed explicitly.
Next
- Middleware - how middleware composes, runs, and short-circuits in the request chain
- Cache - the store the Laravel-shape
RateLimiterfacade is built on - Configuration - typed config for the cache and Redis backends
- Auth Flows -
LoginThrottleMiddlewareand the brute-force lockout pattern build on this surface - Error Model - why
Result<HttpResponse, HttpResponse>lets the middleware short-circuit cleanly
