Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Request Throttling

Caelix provides backend-neutral fixed-window request throttling for routes generated by #[controller]. Importing ThrottleModule enables a global policy, while controller and method attributes can override or disable it.

The default behavior is:

  • 60 accepted requests per 60 seconds;
  • a separate counter for every client identity, HTTP method, and static route template;
  • client identity derived from the immediate socket IP address;
  • process-local memory storage with at most 100,000 active buckets;
  • a normal Caelix 429 Too Many Requests JSON response after the limit;
  • Retry-After on rejected responses only.

Enable global throttling

Import ThrottleModule once in the application module:

#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata, ThrottleModule};

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .import::<ThrottleModule>()
            .import::<UsersModule>()
            .import::<ReportsModule>()
    }
}
}

ThrottleModule is global and explicitly exports the throttle service. Unannotated macro-generated controller methods throughout the module tree now inherit the default policy.

Importing the module does not throttle:

  • native Actix or Axum routes;
  • OpenAPI and Swagger UI endpoints;
  • missing-route handlers;
  • WebSocket upgrades or messages;
  • Socket.IO handlers;
  • microservice message handlers.

Throttling is deliberately limited to Caelix HTTP controller methods.

How counters are isolated

The effective key contains:

  1. the identity returned by the configured ThrottleTracker;
  2. the HTTP method;
  3. the static controller route template.

For example, these requests use separate buckets:

GET  /users/42
GET  /users/99
POST /users/42
GET  /reports/42

Both /users/42 and /users/99 map to the same static template when declared as #[get("/{id}")], so the two GET requests share a bucket for the same client. The POST request and the reports route use different buckets.

Query-string values are not part of the route key. This prevents clients from bypassing a quota by changing arbitrary query parameters.

Fixed-window semantics

Each bucket starts its window on the first attempt:

12:00:10  first attempt; the 60-second window begins
12:01:09  still inside the same window
12:01:10  the bucket has expired; the next attempt starts a new window

The store atomically increments before deciding whether to accept the request. All attempts count, including rejected attempts. Under a policy with limit = 2, the observed counts are therefore:

attempt 1 -> accepted, count 1
attempt 2 -> accepted, count 2
attempt 3 -> rejected, count 3
attempt 4 -> rejected, count 4

Parallel requests cannot independently observe the same previous count when the store correctly implements the atomic ThrottleStore::increment contract.

Controller and method policies

Apply one policy to every route in a controller:

#![allow(unused)]
fn main() {
use caelix::{Response, Result, controller, throttle};

#[controller("/reports")]
#[throttle(limit = 10, window_seconds = 60)]
impl ReportController {
    #[get("")]
    async fn list(&self) -> Result<Response<Vec<Report>>> {
        Ok(Response::Body(self.service.list().await?))
    }

    #[get("/{id}")]
    async fn find_one(&self, #[param] id: String) -> Result<Response<Report>> {
        Ok(Response::Body(self.service.find_one(id).await?))
    }
}
}

Apply a different policy to one method:

#![allow(unused)]
fn main() {
#[get("/export")]
#[throttle(limit = 2, window_seconds = 300)]
async fn export(&self) -> Result<Response<Export>> {
    Ok(Response::Body(self.service.export().await?))
}
}

Disable throttling for one method:

#![allow(unused)]
fn main() {
#[get("/health")]
#[skip_throttle]
async fn health(&self) -> Result<Response<&'static str>> {
    Ok(Response::Body("ok"))
}
}

Disable inherited throttling for a controller:

#![allow(unused)]
fn main() {
#[controller("/internal-health")]
#[skip_throttle]
impl HealthController {
    // These routes are not throttled unless a method re-enables throttling.
}
}

Re-enable one method on a skipped controller:

#![allow(unused)]
fn main() {
#[controller("/callbacks")]
#[skip_throttle]
impl CallbackController {
    #[post("/provider")]
    async fn provider_callback(&self) -> Result<Response<()>> {
        // Not throttled.
        Ok(Response::no_content())
    }

    #[post("/manual")]
    #[throttle(limit = 5, window_seconds = 60)]
    async fn manual_callback(&self) -> Result<Response<()>> {
        // Throttled despite the controller-level skip.
        Ok(Response::no_content())
    }
}
}

The precedence rule is:

method annotation > controller annotation > imported global policy

A method #[skip_throttle] disables either an inherited controller policy or the global policy. A method #[throttle(...)] replaces an inherited policy and re-enables a skipped controller.

Qualified attributes are also supported:

#![allow(unused)]
fn main() {
#[caelix::skip_throttle]
#[caelix::controller("/public")]
impl PublicController {
    #[get("/limited")]
    #[caelix::throttle(limit = 5, window_seconds = 60)]
    async fn limited(&self) -> caelix::Result<&'static str> {
        Ok("ok")
    }
}
}

Controller attribute order does not change the result.

Attribute validation

Both limit and window_seconds are required and must be positive integer literals:

#![allow(unused)]
fn main() {
#[throttle(limit = 10, window_seconds = 60)]
}

The controller macro rejects:

  • missing limit or window_seconds;
  • duplicate arguments;
  • zero values;
  • expressions, variables, or non-integer values;
  • unknown arguments;
  • conflicting #[throttle] and #[skip_throttle] settings.

Explicitly annotated routes require ThrottleModule. Missing registration, unrepresentable windows, and missing configuration dependencies fail during application startup instead of waiting for the first request.

Programmatically constructed policies can be checked directly:

#![allow(unused)]
fn main() {
ThrottlePolicy::new(100, 60).validate()?;
}

Request execution order

For a throttled generated route, Caelix performs request work in this order:

  1. collect the request method, path, headers, and immediate socket peer;
  2. construct RequestContext;
  3. resolve the throttle service and effective route policy;
  4. derive the client identity;
  5. atomically increment and check the store;
  6. run guards;
  7. buffer the body, if the route has body or multipart extractors;
  8. resolve and run interceptors and the controller method.

Rejected requests do not run guards, buffer the body, invoke interceptors, or execute the controller. This also means an over-limit request with an oversized body receives 429, not a payload-size error.

Trackers run before guards. A custom authenticated-user tracker must derive the identity directly from request credentials, normally through an injected authentication service. It cannot depend on request-context extensions that a guard has not populated yet.

Rejection response

Rejected requests use the existing Caelix exception shape:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 37
{
  "status": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded"
}

Retry-After contains the remaining fixed-window duration in whole seconds, rounded up and never lower than 1. Successful requests do not receive rate-limit headers.

Disable the rejection header through configuration:

#![allow(unused)]
fn main() {
let options = ThrottleOptions::default()
    .with_retry_after_header(false);
}

The status and JSON error body remain unchanged.

Configure the global policy

Implement ThrottleConfig and import the configured module type:

#![allow(unused)]
fn main() {
use caelix::{
    Container, Module, ModuleMetadata, Result, ThrottleConfig, ThrottleModule,
    ThrottleOptions, ThrottlePolicy,
};

pub struct ApiThrottleConfig;

impl ThrottleConfig for ApiThrottleConfig {
    fn options(_: &Container) -> Result<ThrottleOptions> {
        Ok(ThrottleOptions {
            policy: ThrottlePolicy::new(120, 60),
            ..ThrottleOptions::default()
        })
    }
}

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .import::<ThrottleModule<ApiThrottleConfig>>()
            .import::<ApiModule>()
    }
}
}

The custom global policy applies only to routes without an effective explicit controller or method policy.

Memory store

MemoryThrottleStore is the default store:

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{MemoryThrottleStore, ThrottleOptions};

let options = ThrottleOptions::default()
    .with_store(Arc::new(MemoryThrottleStore::with_capacity(25_000)));
}

Its behavior is:

  • counters are local to one process;
  • increments are atomic within that process;
  • the default capacity is 100,000 active buckets;
  • expired buckets are removed opportunistically;
  • when capacity is full, the oldest active bucket is evicted;
  • internal cleanup indexes are bounded as the store churns.

Capacity is the maximum number of active identity/method/route buckets, not the maximum number of clients. One client using ten controller routes may occupy ten buckets.

The memory store is suitable for one application process or tests. Multiple processes, containers, hosts, or independently running workers do not share its state. Use a shared atomic store when the quota must be consistent across instances.

Custom shared store

Implement ThrottleStore for Redis, a database, or another shared backend:

#![allow(unused)]
fn main() {
use std::time::Duration;

use caelix::{
    BoxFuture, Result, ThrottleStore, ThrottleStoreRecord,
};

pub struct RedisThrottleStore {
    pool: RedisPool,
}

impl RedisThrottleStore {
    pub fn new(pool: RedisPool) -> Self {
        Self { pool }
    }
}

impl ThrottleStore for RedisThrottleStore {
    fn increment<'a>(
        &'a self,
        key: &'a str,
        window: Duration,
    ) -> BoxFuture<'a, Result<ThrottleStoreRecord>> {
        Box::pin(async move {
            // Execute one atomic script/transaction that:
            // 1. creates a first-hit window if the key is absent;
            // 2. increments the count, including above-limit attempts;
            // 3. preserves the original expiry on later increments;
            // 4. returns the new count and remaining TTL.
            let (count, retry_after) =
                self.pool.increment_fixed_window(key, window).await?;

            Ok(ThrottleStoreRecord {
                count,
                retry_after,
            })
        })
    }
}
}

The operation must be atomic. A separate read followed by a write is not sufficient because concurrent requests could all observe the same old count. Do not extend the expiry on each increment: that would implement a sliding inactivity window rather than Caelix’s first-hit fixed window.

Storage errors are propagated through the normal exception response path. Caelix does not run guards or the controller after a store error.

Dependency-injected stores and trackers

ThrottleConfig can import a module and declare the providers it resolves. This keeps custom backends inside Caelix’s module visibility and lifecycle model:

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{
    Container, ModuleDef, ProviderDependency, Result, ThrottleConfig,
    ThrottleOptions, provider_dependencies,
};

pub struct AppThrottleConfig;

impl ThrottleConfig for AppThrottleConfig {
    fn imports() -> Vec<ModuleDef> {
        vec![ModuleDef::of::<RedisModule>()]
    }

    fn dependencies() -> Vec<ProviderDependency> {
        provider_dependencies![RedisPool]
    }

    fn options(container: &Container) -> Result<ThrottleOptions> {
        let pool = container.resolve::<RedisPool>()?;
        let store = Arc::new(RedisThrottleStore::new((*pool).clone()));

        Ok(ThrottleOptions::default().with_store(store))
    }
}
}

RedisModule must export RedisPool. The provider dependencies returned by dependencies() must match every provider resolved by options(), following the same explicit dependency rules as other Caelix factories.

Client trackers

ThrottleTracker decides which client identity participates in the key. Every returned identity is additionally isolated by method and route.

Direct IP tracking

IpThrottleTracker::new() ignores forwarding headers and uses only RequestContext::peer_addr():

#![allow(unused)]
fn main() {
let tracker = IpThrottleTracker::new();
let options = ThrottleOptions::default()
    .with_tracker(Arc::new(tracker));
}

This is the safe default for applications exposed directly to clients.

Trusted proxies and X-Forwarded-For

Only configure trusted proxies that overwrite or sanitize X-Forwarded-For:

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{IpThrottleTracker, ThrottleOptions};

let tracker = IpThrottleTracker::with_trusted_proxies([
    "127.0.0.1/32",
    "10.0.0.0/8",
    "192.0.2.15/32",
    "2001:db8:1234::/48",
])?;

let options = ThrottleOptions::default()
    .with_tracker(Arc::new(tracker));
}

The immediate socket peer must be inside one of these ranges before Caelix consults X-Forwarded-For. A header supplied by an untrusted peer is ignored.

For a trusted peer, Caelix parses the entire comma-separated chain and walks it from right to left. It skips trusted proxy addresses and selects the first untrusted address:

X-Forwarded-For: 203.0.113.42, 192.0.2.20, 10.1.0.8
socket peer:     10.1.0.9 (trusted)

selected client: 203.0.113.42

Repeated X-Forwarded-For field lines are combined in arrival order before the chain is evaluated. IPv4 and IPv6 addresses are supported.

If any forwarded value is malformed, tracking fails closed and request execution stops. If the immediate peer is not trusted, malformed forwarding data is irrelevant because the header is ignored.

Do not add a public internet range such as 0.0.0.0/0 or ::/0 to the trusted proxy list. That would allow clients to choose their own throttle identity.

API-key or authenticated-user tracker

A tracker can inject an authentication service and derive an identity from request credentials:

#![allow(unused)]
fn main() {
pub struct ApiClientTracker {
    auth: Arc<ApiAuthenticationService>,
}

impl ThrottleTracker for ApiClientTracker {
    fn track<'a>(
        &'a self,
        context: &'a RequestContext,
    ) -> BoxFuture<'a, Result<String>> {
        Box::pin(async move {
            let token = context
                .bearer_token()
                .ok_or_else(|| UnauthorizedException::new("missing API token"))?;

            let client = self.auth.authenticate(token).await?;
            Ok(format!("api-client:{}", client.id))
        })
    }
}
}

Return a stable, unambiguous identifier. Authentication or tracker failures are propagated, and the controller is not executed.

Runtime peer addresses

The built-in IP tracker requires the immediate socket address.

Actix

Generated wrappers read HttpRequest::peer_addr(). Application::listen provides it for normal TCP requests.

Axum

Generated wrappers extract ConnectInfo<SocketAddr>. Application::listen automatically serves the router with:

#![allow(unused)]
fn main() {
router.into_make_service_with_connect_info::<SocketAddr>()
}

If an application serves a Caelix Axum router through custom code, it must also enable socket connection information. Otherwise, IP tracking fails and the controller is not executed.

Testing

Both runtime test clients default to a loopback peer address, so the standard test flow works without extra setup:

#![allow(unused)]
fn main() {
#[caelix::test]
async fn rejects_after_the_route_limit() {
    let app = TestApplication::new::<AppModule>().await.unwrap();

    app.get("/reports")
        .send()
        .await
        .unwrap()
        .assert_status(StatusCode::OK);

    app.get("/reports")
        .send()
        .await
        .unwrap()
        .assert_status(StatusCode::TOO_MANY_REQUESTS);
}
}

Set a specific peer when testing identity isolation or proxy behavior:

#![allow(unused)]
fn main() {
use std::net::SocketAddr;

app.get("/reports")
    .peer_addr("203.0.113.10:41000".parse::<SocketAddr>().unwrap())
    .send()
    .await
    .unwrap();
}

The test response exposes headers:

#![allow(unused)]
fn main() {
let response = app.get("/reports").send().await.unwrap();
assert!(response.header("retry-after").is_some());
}

Useful integration cases include:

  • the global default on an unannotated controller;
  • method and controller overrides;
  • method re-enabling on a skipped controller;
  • method skipping an inherited policy;
  • isolation between routes, methods, identities, IPv4, and IPv6;
  • trusted and untrusted forwarding chains;
  • the 429 body and optional Retry-After;
  • guard, interceptor, and controller non-execution after rejection;
  • store and tracker failure propagation;
  • custom store and tracker injection.

OpenAPI

Generated OpenAPI operations include a 429 response only when throttling is active:

  • explicit #[throttle] routes always document 429;
  • #[skip_throttle] routes do not document 429;
  • unannotated routes document 429 only when their module tree imports ThrottleModule.

The generated operation does not promise Retry-After because that header is configurable at runtime.

Operational guidance

Before deploying throttling:

  1. Decide whether quotas must be process-local or shared across instances.
  2. Choose a memory capacity based on expected identities multiplied by active routes.
  3. Configure only verified proxy IPs or networks.
  4. Make sure custom Axum serving code provides ConnectInfo<SocketAddr>.
  5. Verify that custom stores atomically preserve first-hit expiry.
  6. Decide whether rejected responses should expose Retry-After.
  7. Load-test the intended policies and identity cardinality.

Throttling is one traffic-control layer, not a replacement for upstream connection limits, request-size limits, authentication, authorization, or edge denial-of-service protection.

Troubleshooting

Every request returns an internal error

If the default IP tracker is active, confirm that the runtime supplies a peer address. Custom Axum serving code commonly misses connection information. Also check store and tracker logs for propagated failures.

Clients behind a proxy share one quota

The socket peer is probably being used. Add only the actual proxy address or CIDR to IpThrottleTracker::with_trusted_proxies, and confirm that the proxy sets a valid X-Forwarded-For chain.

A client can change its apparent address

The trusted-proxy range is too broad, or the trusted proxy forwards client-supplied headers without sanitizing them. Restrict the range and make the edge proxy overwrite forwarding headers.

Limits differ between application instances

MemoryThrottleStore is process-local. Configure an atomic shared store for cluster-wide consistency.

An explicit route prevents startup

Confirm that ThrottleModule is imported and that both attribute values are positive, representable integer literals. For custom configuration, ensure that imported modules export every declared dependency.