Rate limiting
Per-route rate limiting — a guard, a default limit from env, per-handler overrides via #[meta(Throttle::…)].
The throttler turns a unit of work into “at most N per window, per
caller”. One ThrottlerModule import provides the in-memory counter (an
[InMemoryThrottler] fixed-window store); one
#[use_guards(ThrottlerGuard)] binds it wherever a guard binds; one
#[meta(Throttle::per_minute(10))] overrides the limit for a single
handler. Over the limit, an HTTP route answers 429 Too Many Requests
with a Retry-After header, and the other edges answer through their own
error frame.
ThrottlerGuard checks all four request-carrying edges, and each keys
its bucket on the unit that edge addresses joined with the caller it can
see:
| Edge | Bucket |
|---|---|
| HTTP | the matched route pattern + the client address |
| GraphQL | the field name — an operation carries no client address, so every caller shares that field’s bucket |
| MCP | the tool or prompt name — same, and the operation runs on a task rmcp spawned |
| WS | the event name + the connection — the peer that keyed the upgrade is gone by the time a message runs |
That is not a nicety: /graphql and /mcp are EdgePosture::Exempt and a
WS message runs after the upgrade’s chain returned, so none of the three
is reachable from an HTTP-scope binding. Bind the guard globally as well
where those mounts are public, so the request carrying the document, the
session or the upgrade is metered per address too.
Only HTTP carries per-unit metadata, so #[meta(Throttle::…)] overrides the
module default there and nowhere else; the other three count against
ThrottlerConfig’s limit.
Bind it per route, or per controller. use_guards_global works and reads
the same #[meta(Throttle::…)] — the guard pool runs post-routing, see
the request lifecycle — but it meters
every route the pool reaches at the module default, which is a wider
decision than rate limiting usually is.
Install
Section titled “Install”cargo add nest-rs --features throttlerOne replica, one counter. Across replicas each process keeps its own, so four pods let four times the configured rate through — the cross-process store is a second feature on the same page:
cargo add nest-rs --features redis-throttlerWire it in
Section titled “Wire it in”use nest_rs::core::module;use nest_rs::throttler::ThrottlerModule;
#[module(imports = [ThrottlerModule::for_root(None)])]pub struct ApiModule;That import is the whole wiring. It registers the shared store and the
ThrottlerGuard that reads it, both as global infrastructure — so the guard
resolves from any module that binds it, and nothing goes in a controller
module’s providers. Listing ThrottlerGuard there as well is a duplicate
registration and fails the boot naming it.
Passing None loads ThrottlerConfig from NESTRS_THROTTLER__*; the
defaults (60 requests per 60s window) apply when nothing is set.
Pass Some(ThrottlerConfig { ... }) to pin the limit in code — the pinned
value is the base NESTRS_THROTTLER__* overlays, field by field, on the same
dual-path rule the rest of the framework follows.
NESTRS_THROTTLER__LIMIT=120NESTRS_THROTTLER__WINDOW_SECS=60# Not a throttler setting — the transport's, shared with `ClientIp`.NESTRS_HTTP__TRUSTED_PROXIES=10.0.0.1,10.0.0.2Guard a controller
Section titled “Guard a controller”use nest_rs::http::controller;use nest_rs::throttler::ThrottlerGuard;
#[controller(path = "/posts")]#[use_guards(ThrottlerGuard)]pub struct PostsController { /* … */ }Every route on this controller now goes through the throttler. The default limit applies unless a handler overrides it.
Override per route
Section titled “Override per route”use nest_rs::http::{controller, routes};use nest_rs::throttler::{Throttle, ThrottlerGuard};
#[controller(path = "/posts")]#[use_guards(ThrottlerGuard)]pub struct PostsController { /* … */ }
#[routes]impl PostsController { #[post("/")] #[meta(Throttle::per_minute(10))] async fn create(&self, /* … */) -> Result<Json<Post>> { /* … */ }
#[get("/")] async fn list(&self, /* … */) -> Result<Json<Vec<Post>>> { /* … */ }}#[meta] attaches the limit to the route; the guard reads it back through
the Reflector at request time. create is capped at 10/min; list
inherits the module default. Forms:
| Form | Meaning |
|---|---|
Throttle::per_second(n) | n requests per second |
Throttle::per_minute(n) | n requests per minute |
Throttle::new(n, Duration::from_secs(s)) | n requests per s seconds |
What “per client” means
Section titled “What “per client” means”The client key is the direct peer IP by default — every request
coming from the same IP shares the same window. If the request arrives
through a reverse proxy you have listed in
NESTRS_HTTP__TRUSTED_PROXIES, the throttler reads the rightmost hop that
is not itself a trusted proxy — from Forwarded (RFC 7239), else
X-Forwarded-For, else X-Real-IP — and counts that as the client.
Rightmost, not leftmost — and the difference is the whole defence. A proxy appends the address it received the request from to the right of the chain, so the genuine client is the last hop your infrastructure wrote. A caller can only prepend, and a prepended entry lands to the left of the genuine one: it can neither mint a fresh bucket by rotating a value nor drain a victim’s by forging theirs. Keying on the leftmost hop is exactly the spoofable rule this avoids — if you reproduce this keying anywhere else, reproduce it in this direction.
The rest of the rule:
- A forwarding header from a peer that is not a listed proxy is ignored — a direct client cannot bypass its own bucket by setting one.
- An unparseable hop in the chain is skipped, never used as a key.
- An unparseable IP in
TRUSTED_PROXIESaborts the boot naming the offending value — never a silent skip. - A request with no peer addr (synthetic, in-process) is counted
against a literal
"global"bucket.
The list is NESTRS_HTTP__TRUSTED_PROXIES, on the transport, not on the
throttler: which proxies a deployment believes decides who every request is
attributed to — the ClientIp extractor’s answer as much
as the rate-limit bucket’s. One list, so a 429 and the log line explaining it
can never name different callers.
Run it
Section titled “Run it”This is what an over-limit response looks like — the eleventh call in a minute, against the default window:
$ for i in {1..11}; do curl -s -o /dev/null -w "%{http_code} retry=%{header.retry-after}s\n" \ -X POST http://localhost:3002/posts -H "Authorization: Bearer $TOKEN" -d '{...}' done200 retry=s200 retry=s… (8 more 200s)429 retry=47sRetry-After is the time until the window resets — clients respecting
it back off cleanly and resume on the same window.
Alternative stores — Redis, and anywhere else
Section titled “Alternative stores — Redis, and anywhere else”The counter sits behind a ThrottlerStore trait. InMemoryThrottler
(fixed-window, process-local) ships with the module and covers a single
replica. Across replicas each process would keep its own counter, so an app on
four pods lets four times the configured rate through — which is why the
cross-process store ships too.
Redis, shipped
Section titled “Redis, shipped”Enable it at the top of this page, then:
use nest_rs::core::module;use nest_rs::redis::{RedisModule, RedisThrottlerModule};use nest_rs::throttler::ThrottlerModule;
#[module(imports = [ ThrottlerModule::for_root(None), // the policy and the guard — unchanged RedisModule::for_root(None), // the one Redis connection (NESTRS_REDIS__URL) RedisThrottlerModule, // the counters move to Redis])]pub struct AppModule;Three lines, three shapes. ThrottlerModule::for_root stays exactly as it was:
it owns the policy (NESTRS_THROTTLER__* — the limit and the window are the
throttler’s whatever store counts them) and the ThrottlerGuard that applies it.
RedisThrottlerModule is a bare import that declares RedisThrottler as the
dyn ThrottlerStore, which supersedes the port’s in-process default wherever
the three fall in imports; it runs over the connection RedisModule::for_root
opens, and an app that also queues shares that one connection with its
producer. You add a line to move the counters off-process and remove none. A
second vendor binding beside it is a named boot failure, never a silent
last-one-wins.
The counter is a single EVAL — INCR, read the TTL, set it on the first hit —
so it is atomic across replicas with no check-then-act race. A backend failure
denies: a rate limiter that fails open is not a rate limiter.
Writing your own
Section titled “Writing your own”use async_trait::async_trait;use nest_rs::throttler::{Decision, Throttle, ThrottlerStore};
pub struct MyThrottler { /* your client */ }
#[async_trait]impl ThrottlerStore for MyThrottler { async fn hit(&self, key: &str, limit: Throttle) -> Decision { // Count one hit for `key`; return `allowed` and, when denied, the // `Retry-After` duration. todo!() }}A store counts and nothing else. Which limit a route runs under is the port’s
policy, carried by the guard; your binding is a bare module that declares
your store as the dyn ThrottlerStore
(provide_declared_factory_after::<Arc<dyn ThrottlerStore>, YourConnection, _, _>(BACKEND_REMEDY, …))
so it supersedes the in-process default and contests a second vendor’s by name.
The trait is async, so a networked implementor awaits its round-trip
directly — no block_in_place bridge occupying a runtime worker per check, and
no panic on a current-thread runtime.
There is no trusted_proxies on the trait, and that is deliberate: a store
counts hits, while who a hit belongs to is the transport’s answer
(nest_rs_http::ClientOrigin, configured by NESTRS_HTTP__TRUSTED_PROXIES).
A backend that had to be told about proxies would be a second place the same
question is answered.
Reference
Section titled “Reference”crates/nest-rs-throttler/—ThrottlerModule,ThrottlerGuard,ThrottlerConfig,Throttle,InMemoryThrottler.
Going further
Section titled “Going further”- Configuration —
NESTRS_THROTTLER__*follows the same env → struct dual path every module uses. - Guards — what
#[use_guards(ThrottlerGuard)]binds, and why a global guard is the wrong shape here.