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 sharded map of buckets: a request locks only its key's shard, and the sweep of inactive buckets locks one shard at a time. It reads tokio::time::Instant so start_paused tests can drive the clock (it does not follow TestClock) |
RATE_LIMIT_DRIVER=memory (default) |
RedisRateLimiter |
Redis ZSET + Lua atomic check-and-record; it reads suprnova::clock::now(), so TestClock moves its window (Moving the clock) |
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.
Per-address limit with ip_based
RateLimitMiddleware::ip_based(max_requests, window) allows each client address max_requests requests in window. It is the limit a login form or a public API needs, and it takes no key function and no driver:
use Duration;
use RateLimitMiddleware;
let twenty_a_minute = ip_based;
The arguments are a u32 request count and a Duration. Three details matter:
- The driver is the installed one. The middleware uses the rate limiter your application installed, which
RATE_LIMIT_DRIVERselects. It looks the limiter up when a request arrives, so you can build the middleware where you register routes, before the drivers boot. When no limiter is installed the lookup fails as a backend error, and the backend-error policy decides what the request gets. - The key names the address and the limit. The address is
Request::ip(), which resolves through the trusted proxies. The key also carries the limit's numbers, so two limits with different numbers never share a bucket. Two limits with the same numbers share one, so the budget belongs to the client, whichever route spends it. - A request with no address gets a bucket of its own. One shared bucket for all such requests would let one caller use it up and lock the others out. Every request the server accepts has a peer address, so only an in-process request has none.
.on_backend_error(...), .only_when(...) and .key_reads_body(...) chain onto it as they do onto RateLimitMiddleware::new. Set up APP_TRUSTED_PROXIES before you deploy behind a proxy, or every client shares one bucket.
Capping open connections with connections_per_ip
ip_based counts how often a client asks. It does not count what a client holds. A WebSocket is asked for once and then stays open, so a client that opens sockets and keeps them can use all the connections the server accepts. RateLimitMiddleware::connections_per_ip(max) gives each client address max open connections and refuses the next one with 429 Too Many Requests. Put it on a WebSocket route:
use RateLimitMiddleware;
use ws;
ws!.middleware;
It returns a ConnectionsPerIp, which is a Middleware. What it counts and how it answers:
- What is counted on a WebSocket route. A socket counts from the moment the middleware lets the upgrade pass until the session of the socket ends, which is when the handler has returned and the close handshake is done. If a later middleware refuses the upgrade, the place is given back at once. A peer that vanishes without closing holds its place until the handler notices, which is when a read or a write on the socket fails.
- What is counted on any other route. A request, while it is handled. The place is given back when the handler drops or consumes the request, and reading the body consumes it. A connection that is kept alive and idle is not counted, and a streamed response, such as server-sent events, outlives its place. Use this cap for WebSocket routes.
- The answer. A request over the cap gets status
429with the body429 Too Many Requests. It carries noRetry-Afterheader, because nothing says when a socket will close. - The counts are per process. They are kept in the memory of one process, which is the right place: the sockets an address holds in a process are what that process runs out of. Behind several replicas each replica counts for itself, so the cap for one address is
maxfor each replica. - IPv6 addresses count by /64. An IPv4 address counts by itself. An IPv6 address counts with its /64 network, because a subscriber line gets a /64 or more and a count for each of its addresses would be no cap. An IPv4 address written in IPv6 form,
::ffff:203.0.113.1, counts as the IPv4 address. - The address is
Request::ip(). It reads the forwarded headers of a trusted proxy and of nobody else. A request with no address to resolve is not counted. If you run your own accept loop, pass the peer address withsuprnova::server::handle_request_with_peer. Throughhandle_requestno request has an address, and nothing is capped.
A clone of a ConnectionsPerIp shares its counts, so one cap can guard several routes. open_for(address) returns the connections an address holds through the cap right now, counted by /64 for IPv6, which is useful in a test or a diagnostic.
The middleware counts through Request::hold_for_connection(hold). That method takes a guard value whose Drop gives the place back, and keeps it on the request. For a WebSocket upgrade the server moves the guards into the task that runs the socket and drops them when the task ends. For any other request they drop with the request. Use it in your own middleware when you take something that a connection has to give back:
use ;
;
;
hold_for_connection needs &mut Request and a value that is Send + Sync + 'static.
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.
Four 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, or a top-level string field of a JSON body, 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.- Both places are read, and they must agree. A blank
?email=does not hide the address in the body. A request whose query string and body name two different addresses is ambiguous - the handler reads one of them, and the key cannot know which - so every such request shares one bucket. It is also refused by, and counted against, the bucket of each address it names. A decoy in the place the handler ignores therefore cannot buy a fresh quota, and an address that has spent its own bucket gets no extra attempt through the shared one. No legitimate client is affected, because none sends two different addresses. 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?;
// Laravel's `hit($key)` defaults to a 60-second window; this is that call.
let n = hit_for_minute.await?;
// Burn one attempt in a window that ends at a given time. A time that has
// already passed opens no window: the hit counts in a window that is
// already open, and leaves no count behind otherwise.
let window_end = now + hours;
let n = hit_until.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).
RateLimiter::limiter(name) returns a callback that wraps the one you registered, as Laravel's does. When the result holds several limits and two or more of them share a key, each of those limits gets its fallback_key() instead, <key>:attempts:<max>:decay:<seconds>. Otherwise a per-minute limit and a per-hour limit on the same key count in one bucket, and the minute's limit trips on the hour's hits:
define;
// For the address 203.0.113.5, the two limits count under
// `203.0.113.5:attempts:60:decay:60` and `203.0.113.5:attempts:10000:decay:86400`.
ThrottleRequestsMiddleware::by_name("api") resolves the limiter through the same method and counts each limit under api:<key>, so the middleware and your own code read the same buckets: RateLimiter::attempts("api:203.0.113.5:attempts:60:decay:60") is the count the middleware keeps.
Limits that end up with one key share one bucket, and the middleware counts a request in it once for each of those limits, as Laravel's ThrottleRequests hits a key once for each limit:
- Two limits with the same key, maximum and window get the same fallback key. Two
Limit::per_minute(2).by("a")limits count a request twice underapi:a:attempts:2:decay:60, so they admit one request a minute. - Keys are compared as written, as Laravel compares them, so
caféandcafekeep their keys. They clean to one bucket (see Sanitising keys), which counts a request once for each limit, andRateLimiter::attempts("api:cafe")reads it.
Sanitising keys
Every counter method cleans its key with RateLimiter::clean_rate_limiter_key(key) before it touches the cache, as Laravel's cleanRateLimiterKey does. The key is first encoded as PHP's htmlentities encodes it under its default flags: each character with a named HTML 4.01 entity becomes that entity, &, <, >, and " included, and ' becomes '. Then each &name; marker of two or more letters is reduced to its first letter. So café and cafe count in one bucket, and a key is stored as a Laravel application sharing the cache store stores it.
use RateLimiter;
assert_eq!;
assert_eq!;
assert_eq!;
// Entities named with digits, and the apostrophe, stay encoded.
assert_eq!;
assert_eq!;
Cleaning a cleaned key changes it again (it's cleans to ita#039;s), so pass the raw key to hit, attempts, and the other counter methods.
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. Four constructors:
use ;
// The default limit - 60 requests a minute; see below.
default;
// Named limiter - resolves at request time via RateLimiter::limiter(name).
by_name;
// Inline max/decay/prefix - the literal Laravel `throttle:60,1` shape,
// counted for each signed-in user, or each address, on each path.
with;
// Explicit list of Limits - first-to-trip wins; most Rust-idiomatic.
with_limits;
.prefix(...) sets a key prefix on any of them. The limits of with_limits that share a key count under their fallback keys, as a named limiter's do, so the two limits above count under user:1:attempts:5000:decay:3600 and user:1:attempts:60:decay:60.
The default limit
ThrottleRequestsMiddleware::default() allows 60 requests a minute. The constants DEFAULT_MAX_ATTEMPTS (60) and DEFAULT_DECAY_SECONDS (60) hold the numbers. It is the shape of Laravel's default api limiter: Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()).
The bucket is one for each signed-in user, and one for each client address when nobody is signed in. The two are spelled apart, as user:<id> and ip:<address>, so a user whose id reads like an address shares no bucket with it. The user's bucket follows the user across routes and across addresses, and the address's bucket is shared by every route, as in Laravel. with(...) differs: it counts per user or address, and per path, under user:<id>:path:<path> or ip:<address>:path:<path>. Use .prefix(...) to give a group of routes a budget of its own.
The user is the one the default guard signed in. Run the session middleware before this one, or a signed-in user counts as an address. The address is Request::ip(), so the trusted proxies apply.
Named aliases
A route can name a throttle instead of building one. ThrottleRequestsMiddleware::from_alias_args reads the arguments of the alias the way Laravel reads throttle:60,1. Register it once at boot, then name it on routes with .middleware_named(...):
use register_middleware_alias_with_args;
use ;
register_middleware_alias_with_args;
let router = new
.post
.middleware_named;
| A route writes | It gets |
|---|---|
throttle |
ThrottleRequestsMiddleware::default() |
throttle:60 |
60 requests a minute, as with(60, 1, "") |
throttle:60,5 |
60 requests in 5 minutes |
throttle:60,5,uploads |
the same, with the key prefix uploads |
throttle:10|60,1 |
10 requests a minute for a guest, 60 for a signed-in user |
throttle:api |
the limiter named api, as by_name("api") |
The numeric forms count each signed-in user in a bucket of their own on each path, whatever address they connect from, and each guest by address. A first argument of two whole numbers joined by | is the guest's limit and the signed-in user's, as Laravel's resolveMaxAttempts reads it. Any other first argument that is not a number names a limiter, and a limiter takes no further arguments. The alias refuses a number that does not parse, a limit or a window of zero (either half of <guest>|<user> included), and more than three arguments. The route that names it then fails to register. See Middleware for how names resolve.
Wire it into a route group:
use ;
define;
let router = new
.get
.post
.middleware;
The client address behind a proxy
Key a limit 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 the TCP peer address unless the peer is listed in APP_TRUSTED_PROXIES. When the peer is a trusted proxy, req.ip() reads the client address from the right of X-Forwarded-For:
- It starts at the rightmost entry, which the peer wrote.
- It skips each entry that is a trusted proxy.
- It returns the first entry that is not a trusted proxy. That entry is the client. Nothing to its left was written by a proxy you trust.
Every proxy hop must be in the list. A proxy adds the address it saw to the right end of the header, and the client's own value stays at the left end. If a hop is missing from the list, req.ip() stops at that hop and takes it for the client, so every client behind it shares one address and one bucket. Behind a content delivery network in front of a proxy of your own, list the network's edge too.
APP_TRUSTED_PROXIES takes addresses and ranges in CIDR form, separated by commas:
APP_TRUSTED_PROXIES=10.0.0.5,173.245.48.0/20,2400:cb00::/32
Four more rules apply:
- A range must hold proxies and nothing else. A client that connects from a listed range is believed like a proxy, and it writes the address
req.ip()returns. The network of your cluster's pods and the range of a VPN have clients in them. /0is refused. A range of every address, such as0.0.0.0/0, makes every client a trusted proxy. Boot fails on it, as it does on an entry that is neither an address nor a range.X-Real-IPis read only when there is noX-Forwarded-For. With both headers present,X-Real-IPis ignored. A proxy that setsX-Real-IPand nothing else has to remove the client'sX-Forwarded-Forfrom the request.- The proxy has to write the header. A proxy that adds to
X-Forwarded-Foror replaces it is a proxy to list. A proxy that passes the client's header on unchanged is not, because the client then writes all of it. TheForwardedheader of RFC 7239 is not read.
An entry that is not an address, such as unknown, ends the walk, and req.ip() returns the proxy that wrote that entry. The clients of such a proxy share its address, and one limit.
The corollary matters as much: with APP_TRUSTED_PROXIES 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. See Requests for the full order in which ip() reads the request.
Response headers
Every wrapped response carries one pair:
X-RateLimit-Limit- the configuredmax_attempts.X-RateLimit-Remaining- retries left for this bucket.
Behind several limits, the pair is the one of the limit with the fewest retries left. When your handler sets X-RateLimit-Remaining itself, at or below that count, the middleware leaves your headers alone; above it, the middleware replaces them.
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.
Refused requests
The middleware checks every limit before it counts a request, as Laravel does, and a request it refuses there leaves every bucket's count where it was. After throttle:2,1 refuses a third request, the count is still 2, so a client that keeps retrying a refused request doesn't keep its own window full.
The middleware then counts each bucket with one conditional increment, which counts the request only while the bucket has room and refuses it without counting when the bucket is full. The cache store compares and counts in one atomic step, so a burst of concurrent requests admits at most the limit. A request that passes the check beside concurrent requests and then finds the bucket full is refused, and that bucket's count stays where it was. The middleware never takes a count back: a decrement can't be tied to the window it would undo, and if that window ended first, the decrement would land in the next window and let it admit one request too many.
A bucket counted earlier in the same request keeps its count when a later limit refuses the request, as in Laravel, which counts every limit of a request that passes its check. That count can refuse a request early but never admits one. The middleware counts the bucket with the fewest places left first, so the bucket that runs out is usually the first one counted.
The step is RateLimiter::increment_if_below(key, decay_seconds, amount, ceiling), built on Cache::increment_if_below. Call it yourself to count a hit only while a bucket has room.
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_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::hit($key) |
RateLimiter::hit_for_minute(key).await? |
RateLimiter::hit($key, $date) |
RateLimiter::hit_until(key, date).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 |
.middleware_named("throttle:api") or ThrottleRequestsMiddleware::by_name("api") |
throttle:60,1 middleware |
.middleware_named("throttle:60,1") with from_alias_args registered, or ThrottleRequestsMiddleware::with(60, 1, "") |
throttle:10|60,1 middleware |
.middleware_named("throttle:10|60,1") with from_alias_args registered |
throttle middleware (the default limit) |
ThrottleRequestsMiddleware::default() |
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.
ThrottleRequestsMiddleware::with, the throttle:60,1 shape, keeps the request path in its key, where Laravel keys the user or the address alone. Two routes behind the same inline limit therefore count apart; use a named limiter or with_limits for one budget across routes. Laravel checks every limit and then counts the request; Suprnova checks the same way, then counts each bucket with one conditional increment that refuses without counting when the bucket is full, so a concurrent burst can't slip past the limit between the check and the count. A request that loses that race is refused and leaves the full bucket's count where it was, where Laravel would have admitted and counted it.
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
