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
| Concern | Application-facing seam | Included option |
|---|---|---|
| Caller identity | KeyExtractor | IpKeyExtractor |
| Quota selection | LimitProvider | fixed u64 via .limit(...) |
| Atomic usage | Store | MemoryStore, optional RedisStore |
| Error and rejection responses | ResponseFactory | DefaultResponseFactory |
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
| Feature | Default | Adds |
|---|---|---|
memory | yes | Runtime-independent, process-local MemoryStore |
axum | no | Axum ConnectInfo<SocketAddr> support in IpKeyExtractor |
redis | no | RedisStore backed by an existing multiplexed connection |
redis-lua | no | Redis increment through Lua instead of MULTI/EXEC |
runtime-tokio | no | Tokio-compatible Redis async runtime |
runtime-smol | no | Smol-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
- Follow the Quick start to construct a Tower layer.
- Browse the complete examples for progressively richer integrations.
- Review Configuration before choosing policy names, windows, and failure mode.
- Read How it works for exact charging semantics.
- Use Axum and Redis or implement Custom components.
- 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
| Setting | Default |
|---|---|
| Limit | 1 request |
| Window | 60 seconds |
| Policy name | default-policy |
| Store errors | Reject with 503 Service Unavailable |
| Response fields | IETF 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:
| Request | Usage::used | Remaining | Result |
|---|---|---|---|
| 1 | 1 | 1 | inner service called |
| 2 | 2 | 0 | inner service called |
| 3 | 3 | 0 | 429 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.
| Example | What it demonstrates | Required features |
|---|---|---|
| Tower with MemoryStore | Minimal Tower Layer composition | memory |
| Request-derived quotas | Custom key extraction, LimitProvider, downstream context | memory |
| Axum with nested policies | ConnectInfo, global and route-scoped policies | axum,memory |
| Trusted forwarded addresses | Deployment-owned proxy trust policy | axum,memory |
| Axum with Redis | Shared Store, namespace, custom error responses | axum,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
| Method | Default | Purpose |
|---|---|---|
with_store(store) | none | Select the required usage Store |
limit(n) | 1 | Use one fixed quota for every request |
limit_provider(provider) | fixed provider | Resolve a quota from each request |
policy_name(name) | default-policy | Set the stable policy and counter identity |
window(duration) | 60 seconds | Set the fixed-window duration |
response_factory(factory) | empty default responses | Customize middleware-produced responses |
store_failure_mode(mode) | Reject | Reject or fail open after Store failure |
rate_limit_fields(fields) | Draft11 | Select or disable response fields |
with_key_encoder(fn) | raw scoped key | Transform the complete key before storage |
skip(predicate) | never bypass | Exempt 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 type | Typical starting point |
|---|---|
| Abuse protection on a public read endpoint | Allow may be acceptable |
| Login, expensive work, paid quota, or write protection | Prefer 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:
- Evaluate the optional bypass predicate.
- Extract the client key synchronously.
- Resolve the quota, possibly asynchronously.
- Derive the policy-scoped Store key and apply optional key encoding.
- Atomically increment usage for the configured window.
- Validate and evaluate returned usage.
- 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:
| Outcome | Status |
|---|---|
| Rate limited | 429 Too Many Requests |
| Client key failure | 500 Internal Server Error |
| Quota failure | 500 Internal Server Error |
| Store failure | 503 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
qis the configured request quota.wis the configured fixed-window duration.ris the quota remaining after the current request.tis 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 >= 1and the remainingreset_afterduration; - 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
| Requirement | MemoryStore | RedisStore |
|---|---|---|
| Single process | yes | yes |
| Counters shared across replicas | no | yes |
| External service required | no | yes |
| Survives process restart | no | usually, subject to Redis persistence |
| Runtime dependency in the adapter | none | Tokio- 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
429behavior 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::Allowis 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.