tower-rate-tier
Tier-based rate limiting middleware for Tower.
Every SaaS API needs rate limiting by user plan (free/pro/enterprise). tower-rate-tier eliminates the 200-400 lines of custom middleware you'd otherwise write.
Features
- Named tiers — Define
free,pro,enterprise(or any names) with distinct quotas - Request cost/weight — Expensive endpoints consume more quota (
/export= 20,/search= 5) - Async identifier — Extract
(user_id, tier)from headers, JWT, API keys, or request body - GCRA algorithm — Smooth rate enforcement, with no burst at window boundaries
- Shared limits across instances — Redis backend (feature
redis) with an atomic GCRA script and Redis's own clock - Pluggable storage — In-memory (DashMap) with automatic GC, Redis, or your own via the
Storagetrait - Safe defaults — Unknown tiers and unidentified requests never bypass the limit unless you opt in
- Testable clock — Deterministic time control in tests with
FakeClock - Standard headers —
X-RateLimit-Limit,Remaining,Reset(Unix timestamp),Retry-After - Callbacks —
on_limitedandon_eventfor metrics/logging, custom 429 response builder - Tower-native — Works with Axum, Hyper, or any Tower service whose response body can be built from a
String(Tonic support is planned)
Quick Start
Add to your Cargo.toml:
[]
= "0.3"
Define Tiers
use ;
let tier = builder
.tier
.tier
.tier
.default_tier
.build;
Identify Users
With a closure (simple cases):
use ;
let layer = new
.identifier_fn;
Apply to Routes
Give expensive endpoints a higher cost with cost_fn. It runs inside the
middleware, so it works when the limiter is added with Router::layer:
use ;
let layer = layer.cost_fn;
let app = new
.route
.route
.route
.route
.layer;
The tier_cost(n) layer also sets a cost, but it must wrap the limiter. A
route's own .layer(tier_cost(n)) runs inside a limiter added with
Router::layer, so its cost arrives too late and is ignored.
Rate Limit Response
When a user exceeds their quota, the middleware returns:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710432000
Retry-After: 2450
Content-Type: application/json
{"error":"rate limit exceeded","tier":"free","retry_after":2450}
Custom 429 Response
let layer = new
.identifier_fn
.rate_limited_response;
Metrics / Logging
let layer = new
.identifier_fn
.on_limited
.on_event;
A request whose cost is above its tier's limit can never succeed, so it is
answered with 403 Forbidden and no Retry-After, without touching storage.
Optional Features
# Body-based identification (opt-in, buffers request body)
= { = "0.3", = ["buffered-body"] }
# Limits shared by every instance through Redis
= { = "0.3", = ["redis"] }
Redis: Limits Shared by Every Instance
With the redis feature, RedisStorage keeps the rate-limit state in Redis,
so every instance of a service enforces the same limits:
use Arc;
use ;
let conn = open?
.get_connection_manager
.await?;
let tier = builder
.tier
.storage
.build;
- Each check is one atomic Lua script (
EVALSHA, reloaded afterNOSCRIPT). Time comes from Redis'sTIME, so instances never disagree about the clock. - Keys are
trt:<tier>:<sha1(user_id)>and expire when the bucket is full again, so nothing needs cleaning up. For ids with little entropy (IP addresses, emails), add.key_secret(secret)so they cannot be recovered from Redis. - A check that takes longer than 100 ms (
.timeout(..)) counts as a storage error and follows the storage error policy. - Works with
ConnectionManager, multiplexed and cluster connections.
See examples/axum_api_key.rs for a complete
service that also looks up each API key's tier in Redis.
Custom Storage Backend
Implement the Storage trait for any other backend. It receives a
StorageKey { user_id, tier }; encode it so that distinct pairs never share
state (for example, length-prefix the parts instead of joining them with :).
let custom_storage: = new;
let tier = builder
.tier
.storage // GC disabled automatically for custom backends
.build;
Testing
Use FakeClock for deterministic rate limit tests:
use FakeClock;
async
Handling Unidentified Requests
let tier = builder
.on_missing // Use default tier (403 if none is set)
// .on_missing(OnMissing::Allow) // No rate limiting
// .on_missing(OnMissing::Deny(StatusCode::FORBIDDEN)) // Block
.build;
Handling Unknown Tiers
If the identifier returns a tier that is not configured (a typo, a plan the
limiter does not know yet, or a value a client can influence), the request is
not let through unlimited. By default it gets the default tier's quota in
the user's own bucket, or 403 Forbidden when no default tier is set:
let tier = builder
.on_unknown_tier // Default tier's quota (default)
// .on_unknown_tier(OnUnknownTier::Deny(StatusCode::FORBIDDEN)) // Block
// .on_unknown_tier(OnUnknownTier::Allow) // No rate limiting (opt-in)
.build;
Storage Error Behavior
let layer = new
.on_storage_error; // Fail open (default)
// .on_storage_error(OnStorageError::Deny); // Fail closed (503)
Minimum Supported Rust Version
Rust 1.75 for the default features and buffered-body, and Rust 1.88 with
redis (required by the redis crate). The MSRV is only raised in minor
releases, and every raise is noted in the changelog.
Upgrading from 0.2
0.3 has breaking changes; the changelog lists them all. The ones that need code changes:
RateLimitInfoandRateLimited:reset_atis nowreset_after: Duration. UseRateLimited::retry_after_secs()for aRetry-Afterheader.- Custom
Storagebackends:check_and_updatereceives aStorageKey { user_id, tier }instead of a joined&str. matchonOnMissing,OnStorageErrororCheckErrorneeds a_ =>arm.- Per-route costs under axum's
Router::layer: usecost_fninstead of a route's own.layer(tier_cost(n)), which was silently ignored.
Behavior that changed on purpose:
- An unknown tier gets the default tier's quota instead of no limit.
OnMissing::UseDefaultwithout a default tier answers 403 instead of no limit; useOnMissing::Allowto keep the old behavior.- A request costing more than the tier's limit gets 403 instead of a 429 that could never succeed.
Comparison
Checked against each crate's source in October 2026:
| Feature | tower_governor 0.8 | tokio-rate-limit 0.10 | axum_gcra 0.1 | tower-rate-tier 0.3 |
|---|---|---|---|---|
| Named tiers (per-plan quotas) | No | No | No | Yes |
| Limits shared across instances | No | No | No | Yes (Redis) |
| Request cost/weight | No | Yes | No | Yes |
| Custom storage backend | No | No | No | Yes |
| Injectable clock for tests | No | Paused Tokio time | Explicit now argument |
FakeClock |
| Frameworks | Tower layer | Axum, Tonic | Axum | Tower layer (Axum, Hyper) |
| Algorithm | GCRA | Token / leaky bucket | GCRA | GCRA |
License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT License (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.