Cookbook / Public APIs
Rate limits and quotas
Per-client limits that differ by plan, daily quotas, standard RateLimit headers, and one limit shared by every instance.
A rate limit protects you from abuse; a quota is what a plan includes. Axeon's RateLimiter does both. Rates use a smooth GCRA (a token bucket with bursts), and Quota::daily counts requests per UTC day. Quotas can depend on the request, for example on the caller's plan.
use axeon::middleware::rate_limit::{Quota, RateLimiter};
use axeon::Server;
/// Set by your authentication middleware in a real app.
#[derive(Clone)]
struct Plan {
key_id: String,
daily: u32,
}
fn main() {
let mut app = Server::new();
// A fake authentication step: the plan comes from a header, for the demo.
app.middleware(axeon::middleware::from_fn(|mut req, next| async move {
if let Some(key) = req.get_header("x-api-key").map(String::from) {
let daily = if key.starts_with("pro_") { 1000 } else { 5 };
req.insert_extension(Plan { key_id: key, daily });
}
next.handle(req).await
}));
app.middleware(
RateLimiter::new(Quota::per_minute(60).burst(10)) // anonymous callers, per IP
.key_by(|req| req.extension::<Plan>().map(|plan| plan.key_id.clone()))
.quota_by(|req| req.extension::<Plan>().map(|plan| Quota::daily(plan.daily)))
.headers(true) // RateLimit-Limit, -Remaining, -Reset and -Policy
.trust_proxy(false), // true only behind a proxy that sets X-Forwarded-For
);
app.get("/v1/ping", |_req| async { "pong" });
app.listen("127.0.0.1:3000").expect("server failed to start");
}
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/v1/ping -H 'x-api-key: free_1'
done # five 200s, then 429
curl -si http://127.0.0.1:3000/v1/ping -H 'x-api-key: pro_1' | grep -i ratelimit
Sharing the limit across instances#
By default each instance keeps its own counters, so N instances allow N times the limit. Put the state in Redis and every instance enforces one limit:
cargo add axeon-redis
use axeon_redis::RedisRateLimitStore;
let redis = axeon_redis::connect("redis://127.0.0.1:6379").await?;
app.middleware(
RateLimiter::new(Quota::per_minute(120).burst(20))
.store(RedisRateLimitStore::new(redis.clone()))
.prefix("api:")
.headers(true),
);
Notes#
- A refused request gets
429withRetry-After. With.headers(true)theRateLimit-*headers go on every response so clients can pace themselves. Quota::per_minute(n).burst(b)lets a client sendbrequests at once, thenna minute on average.Quota::daily(n)resets at midnight UTC and suits plan quotas.- Quota state is kept per plan: a client that upgrades starts with fresh counters instead of inheriting the old ones.
- If the store fails (Redis is down), requests are let through and the error is logged. Use
.fail_open(false)to answer503instead. - Redis counters use Redis' own clock, so instances with skewed clocks still agree.