Rate Limiting
Per-IP sliding-window rate limiting with a global singleton, per-route custom limiters, and a Redis-backed distributed limiter for multi-instance deployments.
rust-web-server ships a built-in sliding-window rate limiter in src/rate_limit/mod.rs. It is keyed by a string (typically the client IP) and is safe to share across threads.
Quick start
Add RateLimitLayer to the middleware stack to enforce the global limit on every request:
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::middleware::RateLimitLayer;
let app = App::new().wrap(RateLimitLayer);RateLimitLayer calls rate_limit::global().check(&connection.client.ip) on every request. When the budget is exceeded it returns 429 Too Many Requests without forwarding to the inner application.
Global singleton
rate_limit::global() returns a &'static RateLimiter initialized once from environment variables:
| Variable | Default | Meaning |
|---|---|---|
RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS |
1000 |
Requests allowed per window |
RWS_CONFIG_RATE_LIMIT_WINDOW_SECS |
60 |
Sliding window length in seconds |
RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS=500
RWS_CONFIG_RATE_LIMIT_WINDOW_SECS=30or in rws.config.toml:
[rate_limit]
max_requests = 500
window_secs = 30The rws binary enforces this global limit only when max_requests is set
to a positive number (an unset limit means no global limit, not 1000/60s).
Turning it on needs a restart; changing the numbers later is hot-reloadable
via SIGHUP. In your own app, add .wrap(RateLimitLayer) to enforce it.
RateLimiter API
check(key) -> bool
Records a timestamp for key and returns true when the call is within the budget. Returns false — without recording — when the budget is exhausted.
use rust_web_server::rate_limit::RateLimiter;
let limiter = RateLimiter::new(100, 60); // 100 req / 60 s
if limiter.check("192.168.1.1") {
// request is within budget — proceed
} else {
// return 429 Too Many Requests
}remaining(key) -> u32
Returns the number of requests key may still make in the current window without being rate-limited. Useful for X-RateLimit-Remaining response headers.
let left = limiter.remaining("192.168.1.1");
// add to response headers:
// X-RateLimit-Remaining: {left}retry_after(key) -> u64
Seconds a rejected key should wait before trying again. The window length — a safe upper bound, since the oldest request in the window may expire sooner. The two 429 paths (RateLimitLayer and PerRouteRateLimit) attach it to every rejection:
Retry-After: {window_secs}set_limits(max_requests, window_secs)
Updates the limits on a live limiter without a restart. Called automatically by config_reload::reload() on SIGHUP.
limiter.set_limits(200, 60); // new limit takes effect immediatelyreset(key)
Clears all recorded timestamps for key. Useful in tests.
limiter.reset("192.168.1.1");Hot reload
Send SIGHUP or POST /admin/config/reload to update the global limiter without restarting. The new RWS_CONFIG_RATE_LIMIT_* values are read from the environment and applied atomically via set_limits.
Custom per-route limiter
Create a RateLimiter with custom parameters and call check in your handler directly:
use std::sync::Arc;
use rust_web_server::rate_limit::RateLimiter;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::error::{AppError, IntoResponse};
struct State {
login_limiter: RateLimiter,
}
fn login_handler(
state: &Arc<State>,
req: &Request,
conn: &ConnectionInfo,
) -> Response {
if !state.login_limiter.check(&conn.client.ip) {
return AppError::TooManyRequests.into_response();
}
// proceed with login logic ...
Response::new()
}
// Wire up:
let state = Arc::new(State {
login_limiter: RateLimiter::new(5, 60), // 5 attempts per minute
});Wrap the limiter in Arc to share it across handler invocations.
RateLimitLayer uses the global singleton. For endpoints that need tighter limits (login, password reset, OTP verification), create a separate RateLimiter and call check inside the handler as shown above.
Distributed rate limiting with RedisRateLimiter
RateLimiter tracks state in process memory. Run two or more rws instances behind a load balancer and each one enforces its own budget independently — the effective limit for a given client multiplies by the number of replicas instead of staying constant. RedisRateLimiter fixes this by keying the counter on a shared Redis server, so every instance increments and reads the same value.
use rust_web_server::rate_limit::RedisRateLimiter;
// 100 requests / 60s, shared across every rws instance pointed at this Redis server
let limiter = RedisRateLimiter::new("127.0.0.1:6379", None, 100, 60);
match limiter.check("192.168.1.1") {
Ok(true) => { /* within budget — proceed */ }
Ok(false) => { /* budget exhausted — return 429 */ }
Err(e) => { /* Redis unreachable — decide fail-open vs fail-closed yourself */ }
}RedisRateLimiter::from_env() builds one from environment variables:
| Variable | Default | Meaning |
|---|---|---|
RWS_REDIS_HOST |
127.0.0.1 |
Redis server host |
RWS_REDIS_PORT |
6379 |
Redis server port |
RWS_REDIS_PASSWORD |
(none) | Passed to Redis AUTH if set |
RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS |
1000 |
Requests allowed per window |
RWS_CONFIG_RATE_LIMIT_WINDOW_SECS |
60 |
Window length in seconds |
use rust_web_server::rate_limit::RedisRateLimiter;
let limiter = RedisRateLimiter::from_env();How it differs from the in-process RateLimiter
RateLimiter records a sliding-window log of timestamps per key. RedisRateLimiter instead uses a fixed-window counter: each key lives under one Redis string with a TTL equal to the window length, and every call issues an atomic INCR. This is the standard distributed rate-limiting pattern — Redis guarantees INCR is atomic across every concurrent caller, so the count stays correct no matter how many rws processes are incrementing it at once.
The tradeoff versus a true sliding window: a client can burst up to 2x max_requests across a window boundary (once near the end of one window, once right after the next one starts). For most deployments this is an acceptable price for atomic, coordination-free enforcement across a cluster.
check, unlike RateLimiter::check, returns Result<bool, std::io::Error> — the network call to Redis can fail. Callers decide whether an unreachable Redis server should fail open (allow the request) or fail closed (reject it); RedisRateLimiter does not pick one for you.
API
RedisRateLimiter::new(addr, password, max_requests, window_secs)— connect toaddr(e.g."127.0.0.1:6379").RedisRateLimiter::from_env()— build from the environment variables above.check(key) -> Result<bool, io::Error>— atomically increments the shared counter forkeyand returns whether the call is within budget.remaining(key) -> Result<u32, io::Error>— requestskeymay still make in the current window.set_limits(max_requests, window_secs)— update limits on a live limiter; takes effect on the nextcheck/remaining.reset(key) -> Result<(), io::Error>— deletes the Redis key forkey. Useful in tests.
Cloning a RedisRateLimiter is cheap — all clones share the same underlying TCP connection, mirroring RedisSessionStore's connection-sharing model (see Sessions).