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

tower-rate-limiter

Keyed, fixed-window HTTP rate limiting middleware for Tower.

tower-rate-limiter lets an application decide who a request belongs to, what quota applies, and where usage is stored. The middleware owns the charging and HTTP response flow without coupling the core to Axum, Tokio, Redis, or a built-in identity policy.

Request
  -> optional bypass
  -> KeyExtractor
  -> LimitProvider
  -> Store::increment
  -> allow the ready inner service or return a response

When to use it

Use this crate when you need per-caller HTTP enforcement in a Tower service, for example:

  • one quota per authenticated account or API client;
  • an IP-based limit at an application boundary;
  • different quotas for free and paid plans;
  • route-specific policies composed as Tower Layers;
  • counters shared across processes through Redis.

This is not a global concurrency limiter or a backpressure mechanism. It charges requests to an application-defined client key and evaluates them against a fixed-window quota.

Design at a glance

ConcernApplication-facing seamIncluded option
Caller identityKeyExtractorIpKeyExtractor
Quota selectionLimitProviderfixed u64 via .limit(...)
Atomic usageStoreMemoryStore, optional RedisStore
Error and rejection responsesResponseFactoryDefaultResponseFactory

The Store is always explicit. This makes the counter’s ownership and sharing boundary visible at layer construction instead of hiding process-local state behind a default singleton.

Cargo features

FeatureDefaultAdds
memoryyesRuntime-independent, process-local MemoryStore
axumnoAxum ConnectInfo<SocketAddr> support in IpKeyExtractor
redisnoRedisStore backed by an existing multiplexed connection
redis-luanoRedis increment through Lua instead of MULTI/EXEC
runtime-tokionoTokio-compatible Redis async runtime
runtime-smolnoSmol-compatible Redis async runtime

The Redis Store needs an increment implementation (redis or redis-lua) together with one async runtime (runtime-tokio or runtime-smol).

With default-features = false, the core remains usable with application-provided implementations and does not pull in Axum, Redis, or an async runtime.

Start here

  1. Follow the Quick start to construct a Tower layer.
  2. Browse the complete examples for progressively richer integrations.
  3. Review Configuration before choosing policy names, windows, and failure mode.
  4. Read How it works for exact charging semantics.
  5. Use Axum and Redis or implement Custom components.
  6. Check the Production guide before deploying behind a proxy or across replicas.

The API documentation is the complete type-level reference. The GitHub repository contains runnable examples.

Quick start

This guide builds a process-local limiter around a plain Tower service. It is the shortest path to the first working layer; the same layer can later be applied to an Axum router.

1. Add the dependency

The default feature enables the in-memory Store:

[dependencies]
tower-rate-limiter = "0.1"

The crate requires Rust 1.96 or newer.

2. Choose a client key

Define how a request becomes an application-owned client key and compose the Layer:

use std::{convert::Infallible, error::Error, future::ready, time::Duration};

use http::{Request, Response};
use tower::{Layer, service_fn};
use tower_rate_limiter::{KeyExtractor, MemoryStore, RateLimitLayer};

#[derive(Clone, Copy)]
struct StaticClient;

impl KeyExtractor for StaticClient {
    type Key = String;

    fn extract<B>(&self, _request: &Request<B>) -> Result<Self::Key, tower_rate_limiter::RateLimitError> {
        Ok(String::from("example-client"))
    }
}

fn main() -> Result<(), Box<dyn Error>> {
    dotenvy::dotenv().ok();

    let limiter = RateLimitLayer::builder(StaticClient)
        .policy_name("tower-example")
        .limit(100)
        .window(Duration::from_secs(60))
        .with_store(MemoryStore::new())
        .build()?;

    let _service = limiter.layer(service_fn(|_request: Request<()>| {
        ready(Ok::<Response<()>, Infallible>(Response::new(())))
    }));

    println!("constructed a Tower MemoryStore rate limiter");
    Ok(())
}

Run the complete example from the repository root:

cargo run --example tower_memory --features memory

The example uses one static key so its construction is easy to see. In a real service, extract a validated account ID, API-client ID, peer address, or another stable identity. Different callers must produce different keys; repeated requests from one caller must produce the same key.

3. Configure the policy deliberately

The minimal production-shaped builder is:

use std::time::Duration;
use tower_rate_limiter::{IpKeyExtractor, MemoryStore, RateLimitLayer};

let layer = RateLimitLayer::builder(IpKeyExtractor::new())
    .policy_name("public-api")
    .limit(100)
    .window(Duration::from_secs(60))
    .with_store(MemoryStore::new())
    .build()?;
Ok::<(), tower_rate_limiter::ConfigError>(())

build() validates configuration. A policy name cannot be empty and a window must be at least one millisecond. The typed builder also prevents build() until a Store has been supplied.

Builder defaults

SettingDefault
Limit1 request
Window60 seconds
Policy namedefault-policy
Store errorsReject with 503 Service Unavailable
Response fieldsIETF draft 11

A real application should set a stable policy name, a quota, and a window deliberately. Layers that share a Store, policy name, and extracted key intentionally share usage.

What happens on each request

The middleware first extracts a key and resolves the quota. Only after both steps succeed does it increment the Store. A provider failure therefore consumes no quota, while a charged request is not refunded based on the downstream response.

The first limit requests are allowed. Request limit + 1 is rate limited, and rejected requests continue to increment usage without extending the active fixed window.

For a limit of 2, the sequence is:

RequestUsage::usedRemainingResult
111inner service called
220inner service called
330429 Too Many Requests

The response from the second request has zero remaining quota but is still allowed. Exhaustion is enforced only when used > limit.

Next steps

  • Read How it works for the public extension points.
  • Browse the complete examples for Tower, Axum, dynamic quotas, proxy handling, and Redis.
  • Review every builder option in Configuration.
  • Use Axum and Redis when the service needs framework or shared-store integration.
  • Browse the repository’s examples/ for dynamic quotas, nested policies, proxy handling, and custom error responses.

Examples

The examples progress from a framework-independent Tower service to shared Redis counters. Every page below embeds the complete source file used by the repository, so the documentation and the compiled example cannot drift independently.

ExampleWhat it demonstratesRequired features
Tower with MemoryStoreMinimal Tower Layer compositionmemory
Request-derived quotasCustom key extraction, LimitProvider, downstream contextmemory
Axum with nested policiesConnectInfo, global and route-scoped policiesaxum,memory
Trusted forwarded addressesDeployment-owned proxy trust policyaxum,memory
Axum with RedisShared Store, namespace, custom error responsesaxum,redis,runtime-tokio

Run an example from the repository root with the command shown on its page. The Axum examples start an HTTP server and keep running until interrupted. The Redis example additionally requires REDIS_URL to reference a reachable Redis server.

These programs are intentionally small. They demonstrate the limiter boundary, not production authentication, proxy configuration, connection supervision, observability, or graceful shutdown. Review the Production guide before adapting them to a deployed service.

Tower with MemoryStore

This is the smallest complete example. It defines a KeyExtractor, creates an explicit process-local Store, builds a policy, and composes the resulting Layer around a Tower service.

cargo run --example tower_memory --features memory

Complete source

use std::{convert::Infallible, error::Error, future::ready, time::Duration};

use http::{Request, Response};
use tower::{Layer, service_fn};
use tower_rate_limiter::{KeyExtractor, MemoryStore, RateLimitLayer};

#[derive(Clone, Copy)]
struct StaticClient;

impl KeyExtractor for StaticClient {
    type Key = String;

    fn extract<B>(&self, _request: &Request<B>) -> Result<Self::Key, tower_rate_limiter::RateLimitError> {
        Ok(String::from("example-client"))
    }
}

fn main() -> Result<(), Box<dyn Error>> {
    dotenvy::dotenv().ok();

    let limiter = RateLimitLayer::builder(StaticClient)
        .policy_name("tower-example")
        .limit(100)
        .window(Duration::from_secs(60))
        .with_store(MemoryStore::new())
        .build()?;

    let _service = limiter.layer(service_fn(|_request: Request<()>| {
        ready(Ok::<Response<()>, Infallible>(Response::new(())))
    }));

    println!("constructed a Tower MemoryStore rate limiter");
    Ok(())
}

The static key deliberately puts every request in the same bucket. Replace it with an application-owned caller identity in a real service.

Request-derived quotas

This example extracts a user ID, chooses a quota from request state, and reads RateLimitContext inside the downstream service. It also sends enough requests to show the first rejected request for both plans.

cargo run --example tower_dynamic --features memory

Complete source

use std::{
    convert::Infallible,
    error::Error,
    future::{Ready, ready},
    time::Duration,
};

use http::{Request, Response};
use tower::{Layer, Service, ServiceExt, service_fn};
use tower_rate_limiter::{KeyExtractor, LimitProvider, MemoryStore, RateLimitContext, RateLimitError, RateLimitLayer};

/// Extract the application-owned user identity used as the Client Key.
#[derive(Clone, Copy)]
struct UserIdKeyExtractor;

impl KeyExtractor for UserIdKeyExtractor {
    type Key = String;

    fn extract<B>(&self, request: &Request<B>) -> Result<Self::Key, RateLimitError> {
        request
            .headers()
            .get("x-user-id")
            .and_then(|value| value.to_str().ok())
            .filter(|value| !value.is_empty())
            .map(str::to_owned)
            .ok_or_else(|| {
                RateLimitError::Key(
                    String::from("invalid_user_id"),
                    String::from("x-user-id is missing, empty, or invalid UTF-8"),
                )
            })
    }
}

/// Resolve a different quota from the request's application-owned plan.
#[derive(Clone, Copy)]
struct PlanLimitProvider;

impl LimitProvider for PlanLimitProvider {
    // This example does a local lookup, so a ready future is sufficient. A database or
    // remote configuration lookup can return its own asynchronous future here.
    type Future = Ready<Result<u64, RateLimitError>>;

    fn limit<B>(&self, request: &Request<B>) -> Self::Future {
        let limit = match request.headers().get("x-plan").and_then(|value| value.to_str().ok()) {
            Some("premium") => 5,
            _ => 2,
        };

        ready(Ok(limit))
    }
}

async fn call<S>(service: &mut S, user_id: &str, plan: &str) -> Result<Response<()>, Infallible>
where
    S: Service<Request<()>, Response = Response<()>, Error = Infallible>,
{
    let request = Request::builder()
        .header("x-user-id", user_id)
        .header("x-plan", plan)
        .body(())
        .expect("valid demo request");

    service.ready().await?.call(request).await
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let limiter = RateLimitLayer::builder(UserIdKeyExtractor)
        .policy_name("plan-limit")
        .limit_provider(PlanLimitProvider)
        .window(Duration::from_secs(60))
        .with_store(MemoryStore::new())
        .build()?;

    let inner = service_fn(|request: Request<()>| async move {
        if let Some(context) = request.extensions().get::<RateLimitContext>() {
            for entry in context.policies() {
                println!(
                    "  downstream context: policy={} limit={} remaining={}",
                    entry.policy_name, entry.limit, entry.remaining
                );
            }
        }

        Ok::<Response<()>, Infallible>(Response::new(()))
    });
    let mut service = limiter.layer(inner);

    println!("free plan: 2 requests allowed");
    for attempt in 1..=3 {
        let response = call(&mut service, "user-free", "free").await?;
        println!(
            "  request {attempt}: {} ({})",
            response.status(),
            response
                .headers()
                .get("RateLimit")
                .and_then(|value| value.to_str().ok())
                .unwrap_or("no RateLimit header")
        );
    }

    println!("premium plan: 5 requests allowed");
    for attempt in 1..=6 {
        let response = call(&mut service, "user-premium", "premium").await?;
        println!(
            "  request {attempt}: {} ({})",
            response.status(),
            response
                .headers()
                .get("RateLimit")
                .and_then(|value| value.to_str().ok())
                .unwrap_or("no RateLimit header")
        );
    }

    Ok(())
}

The headers stand in for application-owned identity and plan state. Production code should normally authenticate first and place validated values in request extensions.

Axum with nested policies

This example installs a global policy around the application and a stricter policy around the auth routes. It also shows the ConnectInfo<SocketAddr> setup required by IpKeyExtractor and an IP allowlist that bypasses both policies.

%%{init: {"themeVariables": {"fontSize": "10px"}, "flowchart": {"curve": "basis", "useMaxWidth": false, "padding": 5, "nodeSpacing": 15, "rankSpacing": 20}}}%%
flowchart LR
    request["Request"] --> allowlisted{"Peer allowlisted?<br/>ConnectInfo IP"}
    allowlisted -- "Yes" --> bypass["Bypass both Layers"]
    bypass --> bypass_response["Handler response<br/>No rate-limit fields"]
    allowlisted -- "No" --> global["global-limit<br/>Charges every route"]
    global --> auth_route{"Under /auth?"}
    auth_route -- "No" --> other["Other handler"]
    other --> global_response["Handler response<br/>Global field only"]
    auth_route -- "Yes" --> auth["auth-limit<br/>Charges auth routes"]
    auth --> auth_handler["Auth handler"]
    auth_handler --> auth_response["Handler response<br/>Auth and global fields"]

    classDef entry fill:#ede9fe,stroke:#8b5cf6,color:#3b0764,stroke-width:2px
    classDef decision fill:#fef3c7,stroke:#f59e0b,color:#78350f,stroke-width:1.5px
    classDef policy fill:#dbeafe,stroke:#3b82f6,color:#172554,stroke-width:1.5px
    classDef success fill:#dcfce7,stroke:#22c55e,color:#14532d,stroke-width:1.5px
    classDef bypass fill:#f1f5f9,stroke:#64748b,color:#1e293b,stroke-width:1.5px

    class request entry
    class allowlisted,auth_route decision
    class global,auth policy
    class global_response,auth_response success
    class bypass,bypass_response,other,auth_handler bypass
cargo run --example axum_memory --features axum,memory

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

Complete source

use axum::{Router, extract::ConnectInfo, routing::get};
use http::Request;
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::Arc;
use std::{error::Error, net::SocketAddr, time::Duration};
use tower_rate_limiter::{IpKeyExtractor, MemoryStore, RateLimitLayer};

// check if the request is from an allowlisted IP address
fn is_allowlisted(request: &Request<()>, allowlist: &HashSet<IpAddr>) -> bool {
    request
        .extensions()
        .get::<ConnectInfo<SocketAddr>>()
        .is_some_and(|addr| allowlist.contains(&addr.ip()))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let ip = "127.0.0.1".parse::<IpAddr>().unwrap();
    let allowlist = Arc::new(HashSet::from([ip]));

    let key_extractor = IpKeyExtractor::new();
    let global_limiter = RateLimitLayer::builder(key_extractor)
        .policy_name("global-limit")
        .limit(10)
        .window(Duration::from_secs(60))
        .with_store(MemoryStore::new())
        .build()?;

    let auth_allowlist = Arc::clone(&allowlist);
    let auth_limiter = RateLimitLayer::builder(key_extractor)
        .policy_name("auth-limit")
        .skip(move |request| is_allowlisted(request, &auth_allowlist))
        .limit(3)
        .window(Duration::from_secs(60))
        .with_store(MemoryStore::new())
        .build()?;

    let auth_routes = Router::new()
        .route("/login", get(|| async { "login" }))
        .layer(auth_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}");
    // ANCHOR: serve
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
    // ANCHOR_END: serve
    Ok(())
}

An allowlisted request reaches the handler without quota metadata. A non-allowlisted request to /auth/login can consume both policies; if the inner auth policy rejects it, the already-recorded outer charge is not refunded.

Trusted forwarded addresses

This example demonstrates where an application-owned forwarding-header policy can live. It assumes a trusted Nginx proxy removes client-provided forwarding headers and writes the value received by the application.

cargo run --example axum_x_forwarded_for --features axum,memory

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

Complete source

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

use axum::{Router, routing::get};
use http::Request;
use tower_rate_limiter::{IpKeyExtractor, KeyExtractor, MemoryStore, RateLimitError, RateLimitLayer};

/// The deployment's trusted Nginx proxy strips client-provided forwarding headers and writes
/// this value. The crate deliberately does not provide this trust policy itself.
#[derive(Clone, Copy)]
struct TrustedForwardedAddress;

impl KeyExtractor for TrustedForwardedAddress {
    type Key = IpAddr;

    fn extract<B>(&self, request: &Request<B>) -> Result<Self::Key, RateLimitError> {
        if let Some(header) = request.headers().get("x-forwarded-for")
            && let Ok(value) = header.to_str()
        {
            for candidate in value.split(',') {
                if let Ok(address) = candidate.trim().parse::<IpAddr>() {
                    return Ok(address);
                }
            }
        }

        IpKeyExtractor::new().extract(request)
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let key_extractor = TrustedForwardedAddress;
    let limiter = RateLimitLayer::builder(key_extractor)
        .policy_name("forwarded-limit")
        .limit(100)
        .window(Duration::from_secs(60))
        .with_store(MemoryStore::new())
        .build()?;
    let app = Router::new().route("/health", get(|| async { "ok" })).layer(limiter);

    let address: SocketAddr = "127.0.0.1:3001".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(())
}

Do not copy this parser unless the deployment enforces the trust assumption described above. On an internet-facing server that accepts arbitrary X-Forwarded-For, a client could select or rotate its own rate-limit key.

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.

Configuration

RateLimitLayer::builder(...) uses concrete generic types for the extractor, Store, quota provider, and response factory. Most configuration is shared immutable state, so cloning a finished Layer is cheap and produces services with the same policy.

Builder reference

MethodDefaultPurpose
with_store(store)noneSelect the required usage Store
limit(n)1Use one fixed quota for every request
limit_provider(provider)fixed providerResolve a quota from each request
policy_name(name)default-policySet the stable policy and counter identity
window(duration)60 secondsSet the fixed-window duration
response_factory(factory)empty default responsesCustomize middleware-produced responses
store_failure_mode(mode)RejectReject or fail open after Store failure
rate_limit_fields(fields)Draft11Select or disable response fields
with_key_encoder(fn)raw scoped keyTransform the complete key before storage
skip(predicate)never bypassExempt trusted requests before charging

Calling limit(...), limit_provider(...), with_store(...), or response_factory(...) replaces the previously selected component of that kind.

Policy identity and counter sharing

Before crossing the Store seam, the middleware scopes the extracted client key with the policy name. Therefore Layers intentionally share a counter only when all of these match:

  • the same logical Store data;
  • the same policy name;
  • the same extracted client key;
  • equivalent key encoding.

The window is passed separately to the Store. If two policies have different windows, give them different names even when their limits happen to match. Treat a policy name as a stable identifier, not as display text.

Dynamic quotas

Use .limit_provider(...) when the quota comes from validated request state. The provider runs after key extraction and before Store usage:

key failure       -> reject; Store not called
quota failure     -> reject; Store not called
quota resolved    -> atomically increment Store

A provider may return an asynchronous future, but request-local extensions are usually preferable to repeating authentication or remote account lookup inside the limiter. See Custom components for the trait shape.

Store failure mode

StoreFailureMode::Reject is the default. It returns the response selected by ResponseFactory without calling the inner service.

StoreFailureMode::Allow favors availability. On a Store error—or invalid Store usage—the inner service is called without RateLimitContext or rate-limit fields because no trustworthy quota state exists. Key and quota failures never fail open.

%%{init: {"themeVariables": {"fontSize": "10px"}, "flowchart": {"curve": "basis", "useMaxWidth": false, "padding": 5, "nodeSpacing": 16, "rankSpacing": 20}}}%%
flowchart TD
    store["Store result"] --> valid{"Ok and used ≥ 1?"}
    valid -- "Yes" --> evaluate["Evaluate quota<br/>Allowed or rate limited"]
    valid -- "No" --> mode{"StoreFailureMode"}
    mode -- "Reject · Error(Store)" --> factory["ResponseFactory"]
    mode -- "Allow" --> allow["Call inner service<br/>without quota metadata"]

    resolution["Key or quota resolution<br/>before Store::increment"] -- "Error(Key or Quota)" --> factory
    factory --> reject["Middleware response<br/>500 or 503 by default"]

    classDef input fill:#ede9fe,stroke:#8b5cf6,color:#3b0764,stroke-width:2px
    classDef decision fill:#fef3c7,stroke:#f59e0b,color:#78350f,stroke-width:1.5px
    classDef success fill:#dcfce7,stroke:#22c55e,color:#14532d,stroke-width:1.5px
    classDef danger fill:#ffe4e6,stroke:#f43f5e,color:#881337,stroke-width:1.5px
    classDef process fill:#dbeafe,stroke:#3b82f6,color:#172554,stroke-width:1.5px

    class store,resolution input
    class valid,mode decision
    class evaluate,allow success
    class factory process
    class reject danger

Choose this per policy based on the cost of under-enforcement:

Policy typeTypical starting point
Abuse protection on a public read endpointAllow may be acceptable
Login, expensive work, paid quota, or write protectionPrefer Reject

This table is operational guidance, not an automatic security policy.

Key encoding

By default, the complete scoped key is passed to the Store unchanged. Use .with_key_encoder(...) when raw identities must not appear in transport keys or when a backend needs a constrained representation.

The callback runs synchronously in the middleware future. It must be deterministic, collision-resistant for the application’s key space, non-blocking, free of I/O, and non-panicking. The crate does not choose a hashing algorithm or detect collisions.

Changing the encoder changes counter identity. Roll it out as a policy migration: old counters will not automatically merge into the new representation.

Bypass

.skip(...) receives &Request<()>, which contains the request head and extensions but no body. When it returns true, the request reaches the inner service without key extraction, quota resolution, Store usage, rate-limit fields, or context.

The predicate is synchronous. Use only cheap, trusted request state such as an authentication result or deployment-validated peer extension. Do not treat an arbitrary client-supplied header as an allowlist signal.

How it works

tower-rate-limiter separates rate-limit policy from application concerns through four narrow interfaces.

The request lifecycle

For each request presented to the Layer, the service follows this order:

  1. Evaluate the optional bypass predicate.
  2. Extract the client key synchronously.
  3. Resolve the quota, possibly asynchronously.
  4. Derive the policy-scoped Store key and apply optional key encoding.
  5. Atomically increment usage for the configured window.
  6. Validate and evaluate returned usage.
  7. Call the already-ready inner service or build an immediate response.
%%{init: {"themeVariables": {"fontSize": "10px"}, "flowchart": {"curve": "basis", "useMaxWidth": false, "padding": 5, "nodeSpacing": 14, "rankSpacing": 18}}}%%
flowchart TD
    request["Request"] --> skip{"Skip predicate matches?"}
    skip -- "Yes" --> skipped_inner["Inner service"]
    skipped_inner --> skipped_response["Inner response<br/>No rate-limit fields"]
    skip -- "No" --> key["KeyExtractor"]
    key -- "Key error" --> response_factory["ResponseFactory"]
    key --> limit["LimitProvider"]
    limit -- "Quota error" --> response_factory
    limit --> store["Store::increment"]
    store --> usage{"Valid Store result?<br/>Ok and used ≥ 1"}
    usage -- "No or Store error" --> failure_mode{"Store failure mode"}
    failure_mode -- "Allow" --> fail_open_inner["Inner service<br/>No quota metadata"]
    fail_open_inner --> fail_open_response["Inner response<br/>No rate-limit fields"]
    failure_mode -- "Reject" --> response_factory
    usage -- "Yes" --> decision{"used > limit?"}
    decision -- "Yes · RateLimited" --> response_factory
    response_factory --> middleware_response["Middleware-produced response<br/>429, 500, or 503 by default"]
    decision -- "No" --> context["Add RateLimitContext<br/>to the request"]
    context --> inner["Inner service"]
    inner --> response["Inner response<br/>Rate-limit fields appended"]

    classDef entry fill:#ede9fe,stroke:#8b5cf6,color:#3b0764,stroke-width:2px
    classDef process fill:#dbeafe,stroke:#3b82f6,color:#172554,stroke-width:1.5px
    classDef decision fill:#fef3c7,stroke:#f59e0b,color:#78350f,stroke-width:1.5px
    classDef success fill:#dcfce7,stroke:#22c55e,color:#14532d,stroke-width:1.5px
    classDef neutral fill:#f1f5f9,stroke:#64748b,color:#1e293b,stroke-width:1.5px
    classDef danger fill:#ffe4e6,stroke:#f43f5e,color:#881337,stroke-width:1.5px

    class request entry
    class key,limit,store,response_factory,context,inner process
    class skip,usage,failure_mode,decision decision
    class response success
    class skipped_inner,skipped_response,fail_open_inner,fail_open_response neutral
    class middleware_response danger

Charging happens before the downstream call. The middleware does not refund quota when a handler returns an error, because the request already consumed application work.

Client key extraction

KeyExtractor synchronously derives an application-owned client key from the request. The crate does not decide whether callers are identified by an account, credential owner, peer address, or another value.

IpKeyExtractor reads a peer SocketAddr extension and returns its IpAddr. It deliberately does not interpret forwarding headers: proxy trust belongs at the application boundary.

Quota resolution

LimitProvider asynchronously resolves a request’s quota. Calling .limit(n) uses a fixed u64, while a custom provider can select a quota from validated request state.

Quota resolution completes before the Store is charged. Key and quota failures always reject the request and never fail open.

Usage storage

Store atomically increments a scoped key and returns:

Usage { used, reset_after }

The Layer scopes the client key with its policy name; the window remains a separate Store argument. Use distinct policy names when policies must not share usage.

A valid Store result always has used >= 1. Returning used == 0 is treated as a Store failure and follows the configured Store failure mode. reset_after is the remaining duration of the current window, not the originally configured duration.

Store failures reject by default. Applications that explicitly prefer availability can select StoreFailureMode::Allow; the inner service is then called without claiming quota metadata.

Fixed-window behavior

The first increment starts a window. Later increments update usage but do not move its end time. After expiry, the next charged request starts a new window.

The first limit charged requests are allowed. Request limit + 1 is rejected, but still increments usage without extending the window. This behavior is predictable and inexpensive, but traffic may burst around a boundary: a caller can use the end of one window and the beginning of the next in quick succession.

Sliding windows, token buckets, weighted requests, and refunds are outside the current interface.

Responses and context

ResponseFactory maps middleware outcomes to the application’s response body, status, headers, and logging policy. The default factory returns:

OutcomeStatus
Rate limited429 Too Many Requests
Client key failure500 Internal Server Error
Quota failure500 Internal Server Error
Store failure503 Service Unavailable

Allowed requests receive RateLimitContext in their extensions. Its policy entries contain the policy name, resolved limit, used and remaining quota, and reset duration. The context is absent on bypass and fail-open paths; downstream code should treat absence as “no trustworthy limiter metadata,” not as “unlimited.”

Nested Layers append policies instead of overwriting context or response fields. See Rate limit fields for the wire representation.

Request bypass

RateLimitBuilder::skip accepts a synchronous predicate over the request head. A bypassed request reaches the inner service without key extraction, quota resolution, Store usage, response fields, or RateLimitContext.

Only use application-trusted headers or extensions in this predicate. Prefer a validated identity extension over matching a raw credential.

Layer scope and composition

Where the Layer is installed determines which routes enter the policy. Use separate Layers for separate route scopes, and give semantically different policies distinct names.

Nested limiters charge independently. If an outer policy allows the request, an inner policy may still reject it; the outer charge is not refunded. On allowed requests, context entries and response fields are appended in composition order.

Tower readiness

The middleware respects Tower’s readiness contract: it calls the same inner service instance that was observed ready. Key extraction, quota resolution, and Store access happen after the service call has begun, so application code should still apply timeouts and load-shedding at the appropriate service boundaries.

Rate limit fields

Allowed and rate-limited responses can advertise quota state using RateLimit-Policy and RateLimit. Rate-limited responses also include Retry-After even when the other fields are disabled.

Durations are rounded up to whole seconds. Remaining quota uses saturating subtraction, so it stays at zero after a caller exceeds the limit.

Draft 11 (default)

For a policy named public-api, a quota of 100, and a 60-second window:

RateLimit-Policy: "public-api";q=100;w=60
RateLimit: "public-api";r=99;t=60
  • q is the configured request quota.
  • w is the configured fixed-window duration.
  • r is the quota remaining after the current request.
  • t is the Store-reported time until reset.

The optional quota-unit (qu) and partition-key (pk) parameters are not emitted. Omitting qu means the quota unit is requests.

Draft 7

Select the older representation explicitly:

use tower_rate_limiter::RateLimitFields;

let builder = tower_rate_limiter::RateLimitLayer::builder(tower_rate_limiter::IpKeyExtractor::new());
let builder = builder.rate_limit_fields(RateLimitFields::Draft7);

It produces fields shaped like:

RateLimit-Policy: 100;w=60
RateLimit: limit=100, remaining=99, reset=60

Draft 7 does not carry the configured policy name in either field.

Disabled

Use RateLimitFields::Disabled when another gateway owns these headers or clients must not receive quota metadata. A rejected request still receives:

Retry-After: 42

Retry-After reflects the remaining active window rounded up to seconds.

Nested policies

Nested Layers append their values rather than replacing an existing field. Clients should parse the fields as structured lists and must not assume there is exactly one policy.

The fields are a client-facing projection of current state, not a substitute for server-side enforcement. A custom ResponseFactory controls the response body and status, while the middleware adds the configured rate-limit fields afterward.

Custom components

The core has four application-facing seams. Implement only the ones whose policy belongs to your application; the included fixed provider, default response factory, and Stores cover common cases.

Custom client identity

KeyExtractor is synchronous and body-generic:

use http::Request;
use tower_rate_limiter::{KeyExtractor, RateLimitError};

#[derive(Clone)]
struct AuthenticatedAccount {
    id: String,
}

#[derive(Clone, Copy)]
struct AccountKey;

impl KeyExtractor for AccountKey {
    type Key = String;

    fn extract<B>(&self, request: &Request<B>) -> Result<Self::Key, RateLimitError> {
        request
            .extensions()
            .get::<AuthenticatedAccount>()
            .map(|account| account.id.clone())
            .ok_or_else(|| RateLimitError::Key(
                "account_missing".into(),
                "authenticated account extension is missing".into(),
            ))
    }
}

The key must implement Clone + Hash + Eq + Debug + Display. Prefer a stable, non-secret identifier. Authentication and credential validation should happen in an earlier application layer.

Request-derived quota

LimitProvider returns a concrete future so it can perform local or asynchronous resolution:

use std::future::{Ready, ready};
use http::Request;
use tower_rate_limiter::{LimitProvider, RateLimitError};

struct AccountPlan {
    requests_per_window: u64,
}

#[derive(Clone, Copy)]
struct PlanQuota;

impl LimitProvider for PlanQuota {
    type Future = Ready<Result<u64, RateLimitError>>;

    fn limit<B>(&self, request: &Request<B>) -> Self::Future {
        let limit = request
            .extensions()
            .get::<AccountPlan>()
            .map_or(10, |plan| plan.requests_per_window);
        ready(Ok(limit))
    }
}

A provider error must use RateLimitError::Quota(code, message). It rejects before Store usage and never follows the Store fail-open setting.

Custom Store

A Store must atomically increment the complete opaque key and preserve fixed-window semantics. The public interface is:

use std::{future::Future, time::Duration};
use tower_rate_limiter::{RateLimitError, Usage};

trait StoreShape: Clone + Send + Sync + 'static {
    type Future: Future<Output = Result<Usage, RateLimitError>> + Send + 'static;
    fn increment(&self, key: &str, window: Duration) -> Self::Future;
}

The illustrative StoreShape mirrors tower_rate_limiter::Store. An implementation must:

  • make increment and first-window creation one atomic operation;
  • start expiry only on the first increment;
  • avoid extending expiry on later or rejected requests;
  • return usage including the current increment;
  • return used >= 1 and the remaining reset_after duration;
  • map backend failures to RateLimitError::Store(code, message).

The Store receives a policy-scoped key. It must treat that string as opaque and must not reconstruct client or policy identity from its format.

Custom responses

ResponseFactory receives the original request and a structured reason:

use http::{Request, Response};
use tower_rate_limiter::{ResponseFactory, ResponseReason};

#[derive(Clone, Copy)]
struct ApiResponseFactory;

impl<B: Default> ResponseFactory<B> for ApiResponseFactory {
    fn build(&self, _request: Request<B>, reason: ResponseReason) -> Response<B> {
        let mut response = Response::new(B::default());
        *response.status_mut() = reason.status_code();
        response
    }
}

Match ResponseReason::RateLimited(limit, usage) to describe quota exhaustion, or ResponseReason::Error(...) to map key, quota, and Store failures. The middleware adds RateLimit, RateLimit-Policy, and Retry-After after the factory returns where applicable.

Avoid placing secrets, raw credentials, or connection details in stable error codes, diagnostic messages, response bodies, or logs.

Axum and Redis

The core stays independent of a web framework and async runtime. Optional features provide focused adapters without taking ownership of application lifecycle or trust policy.

Axum

Enable Axum integration together with the default in-memory Store:

[dependencies]
tower-rate-limiter = { version = "0.1", features = ["axum", "memory"] }

IpKeyExtractor can then read Axum’s ConnectInfo<SocketAddr>. The server must supply connection information when serving the router:

    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;

See Axum with nested policies for the complete source.

If ConnectInfo is missing, IpKeyExtractor returns a key error with code peer_ip_unavailable. Enabling the feature alone is not enough; the server construction shown above is what inserts the peer address.

Forwarding headers are untrusted input. If the application sits behind a proxy, establish and test its proxy trust policy before producing a forwarded client address; the crate does not do this implicitly.

The trusted forwarded addresses example shows application-owned parsing for a deployment where Nginx strips client-provided forwarding headers and writes a trusted value. Copying the parser without the matching proxy configuration would allow clients to choose their own rate-limit identity.

Redis

Enable Redis when multiple processes need to share usage:

[dependencies]
tower-rate-limiter = { version = "0.1", default-features = false, features = ["redis", "runtime-tokio"] }

RedisStore accepts an already established redis::aio::MultiplexedConnection. The application continues to own URL parsing, connection setup, reconnection strategy, and shutdown.

The redis feature uses one MULTI/EXEC transaction to initialize the counter, increment it, and read its TTL. Use redis-lua in place of redis to perform the same fixed-window operation with Lua. Either implementation must be combined with runtime-tokio or runtime-smol. A missing or non-positive TTL is surfaced as a Store error rather than repaired implicitly.

Redis adds an rl: transport marker and the optional namespace after it receives the scoped key. Use a namespace to separate deployments or applications sharing one Redis database. Namespace is a transport concern; use distinct policy names for distinct rate-limit policies.

See Axum with Redis for complete connection setup, namespacing, a shared Store, and custom error responses.

Choosing a Store

RequirementMemoryStoreRedisStore
Single processyesyes
Counters shared across replicasnoyes
External service requirednoyes
Survives process restartnousually, subject to Redis persistence
Runtime dependency in the adapternoneTokio- or Smol-compatible Redis connection

Cloning MemoryStore shares its in-process state. Creating separate MemoryStore::new() values creates separate counter sets. With multiple application replicas, each in-memory Store enforces its own quota, so the effective aggregate allowance can grow with replica count.

Each cached entry expires with its fixed window. Moka treats the entry as absent after that point and eventually removes it through cache maintenance without a background task.

Production guide

A rate limiter sits on a trust and availability boundary. Before deploying, make identity, counter sharing, failure behavior, and rollout compatibility explicit.

Deployment checklist

  • Give every semantically distinct policy a stable, non-empty name.
  • Extract identity from validated application state or a verified peer address.
  • Define the trusted-proxy boundary before reading forwarding headers.
  • Use a shared Store when the quota must apply across replicas.
  • Decide whether Store failure rejects or fails open for each policy.
  • Apply timeouts and health monitoring to externally backed quota resolution and storage.
  • Verify emitted fields and 429 behavior from outside the service.
  • Avoid secrets in keys, error messages, response bodies, and logs.

Identity behind proxies

IpKeyExtractor reads the socket peer supplied in request extensions. Behind a reverse proxy, that peer is normally the proxy, so all clients may collapse into one key.

Do not fix this by trusting X-Forwarded-For from every request. The deployment must define which proxies are trusted and ensure they replace or sanitize client-supplied forwarding values. Then an application-owned extractor can derive a forwarded client address. If that guarantee cannot be made, use an authenticated identity or the direct peer address.

Multiple replicas

MemoryStore is process-local. If three replicas each allow 100 requests, a caller routed across all three may receive roughly 300 requests per window. Use RedisStore or an application-provided shared Store when the limit must be global across replicas.

Make load-balancer behavior part of the decision. Sticky routing may reduce the difference but does not make process-local state durable or authoritative.

Timeouts and failure policy

The limiter awaits a custom LimitProvider and Store future. Bound remote work with the application’s timeout strategy; an unbounded dependency call can hold the request even when the inner service was ready.

Monitor at least:

  • rate-limited responses by policy;
  • key, quota, and Store failures by stable error code;
  • fail-open events when StoreFailureMode::Allow is used;
  • latency of remote quota and Store operations;
  • Redis connectivity and command failures.

Fail-open responses intentionally contain no quota metadata. Record this path in application observability without exposing raw keys or credentials.

Policy changes and rollout

Changing a limit keeps the same active counter identity. Changing the policy name or key encoder creates a different identity, so new requests will not see the old counter.

During a rolling deployment, replicas with mismatched configuration may emit different fields or charge different counters. Coordinate changes to:

  • policy names;
  • window durations;
  • key extraction rules;
  • key encoding;
  • Store namespaces;
  • rate-limit field revision.

If a policy’s window changes, use a new policy name to prevent one logical counter from being used with incompatible window assumptions.

Smoke test

For a test policy with limit 2, send three requests using the same client identity:

request 1 -> inner response; remaining 1
request 2 -> inner response; remaining 0
request 3 -> 429; Retry-After present

Then repeat with a different identity and confirm it begins at its own quota. In a replicated deployment, alternate requests across replicas to verify they share one Store counter. Finally, exercise the chosen Store failure mode in a controlled environment.

Intentional scope

Version 0.1 focuses on fixed-window request counting. It does not provide sliding windows, token buckets, weighted requests, refunds, Redis Cluster lifecycle management, or a built-in forwarding-header trust policy.