Testing Rate Limiters and Quotas in Rust Web Services
A limiter is a function of time, so tests should control time. Assert the boundaries: the last allowed request, the first rejected one and the moment capacity returns.

Rate limiting code is simple to write and easy to get slightly wrong: off by one at the limit, a window that never resets or one key affecting another. Tests that rely on real time are slow and flaky, so the first rule is to take control of the clock. The examples use Rust 1.90, Tokio 1.47 and Axum 0.8, with the token bucket from building a rate limiter.
Algorithm tests with paused time
use std::time::Duration;
#[tokio::test(start_paused = true)]
async fn allows_burst_then_rejects() {
let mut bucket = TokenBucket::new(3, 1.0); // capacity 3, refill 1 per second
assert!(bucket.try_acquire());
assert!(bucket.try_acquire());
assert!(bucket.try_acquire());
assert!(!bucket.try_acquire(), "fourth request exceeds the burst");
}
#[tokio::test(start_paused = true)]
async fn refills_at_the_configured_rate() {
let mut bucket = TokenBucket::new(2, 1.0);
assert!(bucket.try_acquire());
assert!(bucket.try_acquire());
assert!(!bucket.try_acquire());
tokio::time::advance(Duration::from_millis(999)).await;
assert!(!bucket.try_acquire(), "not yet one full second");
tokio::time::advance(Duration::from_millis(2)).await;
assert!(bucket.try_acquire(), "one token has accumulated");
assert!(!bucket.try_acquire());
}
#[tokio::test(start_paused = true)]
async fn never_exceeds_capacity_after_a_long_pause() {
let mut bucket = TokenBucket::new(2, 1.0);
tokio::time::advance(Duration::from_secs(3600)).await;
assert!(bucket.try_acquire());
assert!(bucket.try_acquire());
assert!(!bucket.try_acquire(), "tokens are capped at capacity");
}start_paused = true freezes Tokio's clock, and advance moves it exactly. The 999 millisecond and 1001 millisecond steps test the boundary around one second. This works because the limiter reads tokio::time::Instant, not std::time::Instant (injected clocks). If your limiter uses its own Clock trait, give tests a fake with a settable now.
Fixed and sliding windows
A fixed window counter needs boundary tests that a token bucket does not: requests at the end of one window and the start of the next.
#[tokio::test(start_paused = true)]
async fn fixed_window_resets_at_the_boundary() {
let limiter = FixedWindow::new(2, Duration::from_secs(60));
assert!(limiter.check("k").await);
assert!(limiter.check("k").await);
assert!(!limiter.check("k").await);
tokio::time::advance(Duration::from_secs(60)).await;
assert!(limiter.check("k").await, "new window");
}State the known weakness in a test too: a burst straddling two windows can allow double the limit, which is why sliding windows exist (distributed rate limiting).
Per-key isolation
#[tokio::test(start_paused = true)]
async fn one_client_cannot_exhaust_anothers_budget() {
let limiter = KeyedLimiter::new(1, 1.0);
assert!(limiter.check("alice").await);
assert!(!limiter.check("alice").await);
assert!(limiter.check("bob").await, "bob has his own bucket");
}Also test that the map of keys does not grow without bound: after a long idle period, entries should be evicted (limiter.len() returns to zero).
The HTTP contract
use axum::{body::Body, http::{Request, StatusCode}, routing::get, Router};
use tower::ServiceExt;
fn app() -> Router {
Router::new()
.route("/api", get(|| async { "ok" }))
.layer(rate_limit_layer(2, Duration::from_secs(60)))
}
#[tokio::test(start_paused = true)]
async fn returns_429_with_retry_after() {
let app = app();
for _ in 0..2 {
let res = app.clone().oneshot(Request::get("/api").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
let res = app.clone().oneshot(Request::get("/api").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
let retry: u64 = res.headers()["retry-after"].to_str().unwrap().parse().unwrap();
assert!((1..=60).contains(&retry));
tokio::time::advance(Duration::from_secs(61)).await;
let res = app.oneshot(Request::get("/api").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}Clients rely on Retry-After, so assert its range, and consider RateLimit-Remaining headers if you expose them. Identify the client key from the right source: tests should send distinct X-Forwarded-For values from a trusted proxy address and check they are separate buckets, plus that the header from an untrusted peer is ignored (behind a reverse proxy, Axum rate limiting).
Concurrency
A limiter that checks and decrements non-atomically allows extra requests under load:
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_requests_never_exceed_budget() {
let limiter = std::sync::Arc::new(KeyedLimiter::new(50, 0.0)); // no refill
let mut tasks = Vec::new();
for _ in 0..500 {
let l = limiter.clone();
tasks.push(tokio::spawn(async move { l.check("k").await }));
}
let mut allowed = 0;
for t in tasks {
if t.await.unwrap() {
allowed += 1;
}
}
assert_eq!(allowed, 50);
}With a zero refill rate the allowed count must be exactly the capacity. Run it repeatedly; the race, if there is one, shows quickly. For lock-free implementations, model check the core with loom (loom).
Shared stores
A Redis-backed limiter needs integration tests against a real Redis (Testcontainers), since Lua script behavior and expiry are the point. Verify key expiry by advancing the server clock or using short windows. Keep the algorithm tests above, which are fast, as the main safety net.


