Skip to content

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.

Terminal window
cargo add nest-rs-throttler
apps/api/src/module.rs
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.

Terminal window
NESTRS_THROTTLER__LIMIT=120
NESTRS_THROTTLER__WINDOW_SECS=60
NESTRS_THROTTLER__TRUSTED_PROXIES=10.0.0.1,10.0.0.2
src/items/http/controller.rs
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.

src/items/http/controller.rs
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:

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_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-For is ignored — a direct client cannot bypass its own bucket by setting the header.
  • 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.
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:8080/items -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, 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.

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