Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Axum with Redis

This example shares usage through Redis, separates transport keys with a namespace, limits both peer addresses and user IDs, and maps application-specific identity failures to HTTP statuses.

Set REDIS_URL before running it:

export REDIS_URL=redis://127.0.0.1:6379/
cargo run --example axum_redis --no-default-features --features axum,redis,runtime-tokio

The server listens on http://127.0.0.1:3000.

Complete source

use std::{env, error::Error, net::SocketAddr, time::Duration};

use axum::{Router, routing::get};
use http::{Request, Response, StatusCode};
use tower_rate_limiter::{
    IpKeyExtractor, KeyExtractor, RateLimitError, RateLimitLayer, RedisStore, ResponseFactory, ResponseReason,
};

/// Demo extractor: read a client key from `X-User-Id`.
/// Real apps should resolve identity in an earlier auth layer and read an extension instead.
#[derive(Clone, Copy)]
struct UserIdKeyExtractor;

impl KeyExtractor for UserIdKeyExtractor {
    type Key = String;

    fn extract<B>(&self, request: &Request<B>) -> Result<Self::Key, RateLimitError> {
        let value = request
            .headers()
            .get("x-user-id")
            .ok_or_else(missing_user_id)?
            .to_str()
            .map_err(|_| invalid_user_id())?;

        if value.is_empty() {
            return Err(missing_user_id());
        }

        Ok(value.to_owned())
    }
}

fn missing_user_id() -> RateLimitError {
    RateLimitError::Key(
        String::from("missing_user_id"),
        String::from("x-user-id header is required"),
    )
}

fn invalid_user_id() -> RateLimitError {
    RateLimitError::Key(
        String::from("invalid_user_id"),
        String::from("x-user-id must be valid UTF-8"),
    )
}

/// Example-only HTTP mapping for the application-owned user identity extractor.
#[derive(Clone, Copy, Debug, Default)]
struct AuthResponseFactory;

impl<B> ResponseFactory<B> for AuthResponseFactory
where
    B: Default,
{
    fn build(&self, _request: Request<B>, reason: ResponseReason) -> Response<B> {
        let status = match &reason {
            ResponseReason::Error(RateLimitError::Key(code, _)) if code == "missing_user_id" => {
                StatusCode::UNAUTHORIZED
            },
            ResponseReason::Error(RateLimitError::Key(code, _)) if code == "invalid_user_id" => StatusCode::BAD_REQUEST,
            _ => reason.status_code(),
        };

        let mut response = Response::new(B::default());
        *response.status_mut() = status;
        response
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    dotenvy::dotenv().ok();
    let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
    let client = redis::Client::open(redis_url).expect("Failed to open Redis client");
    let connection = client.get_multiplexed_async_connection().await?;
    let store = RedisStore::new(connection).with_namespace("axum-redis");

    let global_limiter = RateLimitLayer::builder(IpKeyExtractor::new())
        .policy_name("global-limit")
        .limit(10)
        .window(Duration::from_secs(60))
        .with_key_encoder(|k| k.to_string())
        .with_store(store.clone())
        .build()?;

    let user_limiter = RateLimitLayer::builder(UserIdKeyExtractor)
        .policy_name("user-limit")
        .limit(3)
        .window(Duration::from_secs(60))
        .with_key_encoder(|k| k.to_string())
        .response_factory(AuthResponseFactory)
        .with_store(store)
        .build()?;

    let auth_routes = Router::new()
        .route("/login", get(|| async { "login" }))
        .merge(Router::new().route("/me", get(|| async { "me" })).layer(user_limiter));
    let app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .nest("/auth", auth_routes)
        .layer(global_limiter);

    let address: SocketAddr = "127.0.0.1:3000".parse()?;
    let listener = tokio::net::TcpListener::bind(address).await?;
    println!("listening on http://{address}");
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_user_id_is_unauthorized() {
        let response = AuthResponseFactory.build(Request::new(()), ResponseReason::Error(missing_user_id()));

        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[test]
    fn invalid_user_id_is_bad_request() {
        let response = AuthResponseFactory.build(Request::new(()), ResponseReason::Error(invalid_user_id()));

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[test]
    fn unrelated_key_failure_keeps_default_server_error() {
        let response = AuthResponseFactory.build(
            Request::new(()),
            ResponseReason::Error(RateLimitError::Key(
                String::from("peer_ip_unavailable"),
                String::from("missing peer"),
            )),
        );

        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
    }
}

The example uses Tokio because Axum runs on Tokio. RedisStore itself also supports Smol when redis (or redis-lua) is combined with runtime-smol. The example owns connection creation, while RedisStore owns only atomic fixed-window usage. A production application should additionally define connection recovery, timeouts, shutdown, and observability.