Throttler
The throttler turns a route into “at most N requests per window, per
client”. It ships as a guard on the HTTP transport: one
ThrottlerModule import provides the in-memory counter (an
[InMemoryThrottler] fixed-window store); one
#[use_guards(ThrottlerGuard)] binds it to a route or controller; one
#[meta(Throttle::per_minute(10))] overrides the limit for a single
handler. Over the limit, the response is 429 Too Many Requests with
a Retry-After header.
ThrottlerGuard must be a per-route guard, never a global one — a global
guard runs before routing, so the route’s #[meta(Throttle)] is not yet
attached.
Install
Section titled “Install”cargo add nest-rs-throttlerMount the throttler
Section titled “Mount the throttler”use nest_rs_core::module;use nest_rs_throttler::ThrottlerModule;
#[module(imports = [ThrottlerModule::for_root(None)])]pub struct ApiModule;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 wins over the environment, on the same dual-path rule the
rest of the framework follows.
NESTRS_THROTTLER__LIMIT=120NESTRS_THROTTLER__WINDOW_SECS=60NESTRS_THROTTLER__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 = "/items")]#[use_guards(ThrottlerGuard)]pub struct ItemsController { /* … */ }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 = "/items")]#[use_guards(ThrottlerGuard)]pub struct ItemsController { /* … */ }
#[routes]impl ItemsController { #[post("/")] #[meta(Throttle::per_minute(10))] async fn create(&self, /* … */) -> Result<Json<Item>> { /* … */ }
#[get("/")] async fn list(&self, /* … */) -> Result<Json<Vec<Item>>> { /* … */ }}#[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_THROTTLER__TRUSTED_PROXIES, the throttler reads the leftmost
X-Forwarded-For hop instead and counts that as the client.
The rule is strict and adversarial-aware:
- An unconfigured
X-Forwarded-Foris ignored — a direct client cannot bypass its own bucket by setting the header. - 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.
What an over-limit response looks like
Section titled “What an over-limit response looks like”$ for i in {1..11}; do curl -s -o /dev/null -w "%{http_code} retry=%{header.retry-after}s\n" \ -X POST http://localhost:8080/items -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, sliding window, anywhere
Section titled “Alternative stores — Redis, sliding window, anywhere”The counter is hidden behind a ThrottlerStore trait. The default
InMemoryThrottler (fixed-window, process-local) is what ships with the
module; a third-party crate can implement the trait against any backend —
the canonical use case is nest-rs-throttler-redis for a distributed
counter shared across replicas:
use std::net::IpAddr;use nest_rs_throttler::{Decision, Throttle, ThrottlerStore};
pub struct RedisThrottler { client: redis::Client, default: Throttle, trusted_proxies: Vec<IpAddr>,}
impl ThrottlerStore for RedisThrottler { fn hit(&self, key: &str, limit: Throttle) -> Decision { // INCR a key with TTL = limit.window; on the first hit, set the TTL. // Return `allowed = count <= limit.limit` and `retry_after = pttl`. // ... }
fn default_limit(&self) -> Throttle { self.default } fn trusted_proxies(&self) -> &[IpAddr] { &self.trusted_proxies }}The trait is sync on purpose — keeping Guard::check free of an extra
await point for the in-memory default. A Redis implementor either fronts
the call with tokio::task::block_in_place, or — preferable — wraps a
non-async driver (redis::Commands) on a dedicated thread pool.
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.
Reference
Section titled “Reference”crates/nest-rs-throttler/—ThrottlerModule,ThrottlerGuard,ThrottlerConfig,Throttle,InMemoryThrottler.