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 RequestsJSON response after the limit; Retry-Afteron 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:
- the identity returned by the configured
ThrottleTracker; - the HTTP method;
- 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
limitorwindow_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:
- collect the request method, path, headers, and immediate socket peer;
- construct
RequestContext; - resolve the throttle service and effective route policy;
- derive the client identity;
- atomically increment and check the store;
- run guards;
- buffer the body, if the route has body or multipart extractors;
- 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
429body and optionalRetry-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 document429; #[skip_throttle]routes do not document429;- unannotated routes document
429only when their module tree importsThrottleModule.
The generated operation does not promise Retry-After because that header is
configurable at runtime.
Operational guidance
Before deploying throttling:
- Decide whether quotas must be process-local or shared across instances.
- Choose a memory capacity based on expected identities multiplied by active routes.
- Configure only verified proxy IPs or networks.
- Make sure custom Axum serving code provides
ConnectInfo<SocketAddr>. - Verify that custom stores atomically preserve first-hit expiry.
- Decide whether rejected responses should expose
Retry-After. - 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.