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.

src/main.rs
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");
}
Terminal
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:

Terminal
cargo add axeon-redis
Rust
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 429 with Retry-After. With .headers(true) the RateLimit-* headers go on every response so clients can pace themselves.
  • Quota::per_minute(n).burst(b) lets a client send b requests at once, then n a 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 answer 503 instead.
  • Redis counters use Redis' own clock, so instances with skewed clocks still agree.