Skip to content

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:

EdgeBucket
HTTPthe matched route pattern + the client address
GraphQLthe field name — an operation carries no client address, so every caller shares that field’s bucket
MCPthe tool or prompt name — same, and the operation runs on a task rmcp spawned
WSthe 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.

Terminal window
cargo add nest-rs --features throttler

One 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:

Terminal window
cargo add nest-rs --features redis-throttler
apps/api/src/module.rs (from the demo)
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.

Terminal window
NESTRS_THROTTLER__LIMIT=120
NESTRS_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.2
crates/features/src/posts/http/controller.rs (from the demo)
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.

crates/features/src/posts/http/controller.rs (from the demo)
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:

FormMeaning
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

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_PROXIES aborts 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.

This is what an over-limit response looks like — the eleventh call in a minute, against the default window:

Terminal 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 '{...}'
done
200 retry=s
200 retry=s
(8 more 200s)
429 retry=47s

Retry-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.

Enable it at the top of this page, then:

apps/api/src/module.rs
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 EVALINCR, 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.

crates/mythrottler/src/throttler/store.rs
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.

  • crates/nest-rs-throttler/ThrottlerModule, ThrottlerGuard, ThrottlerConfig, Throttle, InMemoryThrottler.
  • ConfigurationNESTRS_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.