Manual contentsDigging DeeperBrowse 103 chapters
Manual 15 min read

Rate Limiting

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 std::sync::Arc;
use std::time::Duration;
use suprnova::rate_limit::memory::InMemoryRateLimiter;
use suprnova::rate_limit::{RateLimiterDriver, SlidingWindowConfig};

let limiter: Arc<dyn RateLimiterDriver> = Arc::new(InMemoryRateLimiter::new());
let cfg = SlidingWindowConfig {
    max_requests: 60,
    window: Duration::from_secs(60),
};
let ok = limiter.try_acquire("user:42", &cfg).await?;
if !ok {
    let wait = limiter.retry_after("user:42", &cfg).await?;
    // wait is the Option<Duration> until the oldest slot in the bucket
    // ages out.
}

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 std::sync::Arc;
use std::time::Duration;
use suprnova::container::App;
use suprnova::rate_limit::{
    BackendErrorPolicy, RateLimitMiddleware, RateLimiterDriver, SlidingWindowConfig,
};

let limiter: Arc<dyn RateLimiterDriver> =
    App::resolve_make::<dyn RateLimiterDriver>().unwrap();

let mw = RateLimitMiddleware::new(
    limiter,
    SlidingWindowConfig {
        max_requests: 100,
        window: Duration::from_secs(60),
    },
    |req| format!("route:{}", req.path()),
)
.on_backend_error(BackendErrorPolicy::FailClosed);

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 suprnova::rate_limit::{identity_key, names_identity};

let per_recipient = RateLimitMiddleware::new(
    limiter.clone(),
    SlidingWindowConfig { max_requests: 3, window: Duration::from_secs(900) },
    |req| identity_key(req, "email", "auth-issuance"),
)
.key_reads_body(4096)
.only_when(|req| names_identity(req, "email"))
.on_backend_error(BackendErrorPolicy::FailClosed);

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_body buffers 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_when skips the limiter for requests that name nobody. Without it those fall into identity_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.com and alice@example.com reach 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 every hit. Initial seed is 0 (via Cache::add).
  • K:timer - i64 unix-seconds-since-epoch when the window ends, set via Cache::add so 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 suprnova::RateLimiter;

// Burn one attempt; seeds the window if missing.
let n = RateLimiter::hit("login:1.2.3.4", 60).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 = RateLimiter::hit_and_check("login:1.2.3.4", 5, 60).await?;
if over_limit { /* return 429 */ }

// Increment by N; useful for "cost-weighted" limits (each request burns
// more than one attempt).
let n = RateLimiter::increment("api:user:1", 60, 5).await?;

// Read the current count (0 when never hit or expired).
let attempts = RateLimiter::attempts("login:1.2.3.4").await?;

// Number of seconds until the window reopens (0 when no window open).
let secs = RateLimiter::available_in("login:1.2.3.4").await?;

// Retries left before tripping.
let remaining = RateLimiter::remaining("login:1.2.3.4", 5).await?;
// retries_left is the Laravel-spelt alias of remaining.
let remaining = RateLimiter::retries_left("login:1.2.3.4", 5).await?;

// Is the bucket over its limit RIGHT NOW (with window still open)?
let over = RateLimiter::too_many_attempts("login:1.2.3.4", 5).await?;

// Drop only the counter (timer stays - the window is still pinned).
RateLimiter::reset_attempts("login:1.2.3.4").await?;

// Drop both counter and timer.
RateLimiter::clear("login:1.2.3.4").await?;

attempt() workflow

Run a callback only when the bucket is under quota; the hit is only burned when the callback runs:

let result = RateLimiter::attempt(
    "login:1.2.3.4",
    5,
    || async { do_login_work().await },
    60,
).await?;
match result {
    Some(value) => { /* callback ran, attempt counted */ }
    None => { /* over limit, callback was NOT run */ }
}

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 suprnova::{Limit, RateLimiter};

// At boot - `define` is the primary Rust-side name.
RateLimiter::define("api", |req| {
    // `req.ip()`, not the raw `X-Forwarded-For` header - see below.
    let key = req.ip().unwrap_or_else(|| "anon".into());
    Limit::per_minute(60).by(format!("ip:{key}")).into()
});

// Laravel-side alias - same thing under the keyword-escape spelling.
RateLimiter::r#for("uploads", |_req| Limit::per_hour(100).into());

// Resolve.
let cb = RateLimiter::limiter("api").unwrap();
let limit_result = cb(&request);

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" via Limit::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!(RateLimiter::clean_rate_limiter_key("a&amp;b"), "aab");

Limit builder

The data type returned by named-limiter callbacks. Shorthand constructors mirror Laravel's Limit::per*:

use suprnova::Limit;
use std::time::Duration;

Limit::per_second(10, 1);           // 10 per 1 second (max_attempts, decay_seconds)
Limit::per_minute(60);              // 60 per minute
Limit::per_minutes(5, 100);         // 100 per 5 minutes (decay-first, Laravel signature)
Limit::per_hour(1_000);             // 1000/hr
Limit::per_hours(6, 5_000);         // 5000 per 6 hours
Limit::per_day(10_000);             // 10000/day
Limit::per_days(7, 50_000);         // 50000 per 7 days
Limit::new(123, Duration::from_secs(45));  // bare ctor

// Builder chain.
let l = Limit::per_minute(5)
    .by("user:42")
    .response(|req| {
        suprnova::HttpResponse::text("blocked").status(429)
    })
    .after(|response| response.status_code() >= 400);
  • .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 when callback(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 suprnova::{Limit, ThrottleRequestsMiddleware};

// Named limiter - resolves at request time via RateLimiter::limiter(name).
ThrottleRequestsMiddleware::by_name("api");

// Inline max/decay/prefix - the literal Laravel `throttle:60,1` shape.
ThrottleRequestsMiddleware::with(60, 1, "myroute");

// Explicit list of Limits - first-to-trip wins; most Rust-idiomatic.
ThrottleRequestsMiddleware::with_limits(vec![
    Limit::per_hour(5_000).by("user:1"),
    Limit::per_minute(60).by("user:1"),
]);

Wire it into a route group:

use suprnova::{Limit, RateLimiter, Router, ThrottleRequestsMiddleware};

RateLimiter::define("api", |req| {
    Limit::per_minute(60)
        .by(req.ip().unwrap_or_else(|| "anon".into()))
        .into()
});

let router = Router::new()
    .get("/api/items", list_items)
    .post("/api/items", create_item)
    .middleware(ThrottleRequestsMiddleware::by_name("api"));

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 configured max_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 = Router::new()
    .get("/api/items", list_items)
    .middleware(RateLimitMiddleware::new(limiter_driver, cfg, key_fn))
    .middleware(ThrottleRequestsMiddleware::by_name("api"));

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 RateLimiter facade is built on
  • Configuration - typed config for the cache and Redis backends
  • Auth Flows - LoginThrottleMiddleware and the brute-force lockout pattern build on this surface
  • Error Model - why Result<HttpResponse, HttpResponse> lets the middleware short-circuit cleanly