tower-rate-tier 0.2.0

Tier-based rate limiting middleware for Tower
Documentation

tower-rate-tier

Tier-based rate limiting middleware for Tower.

Crates.io Documentation CI License

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, no burst-at-boundary issues (used by Stripe, GitHub, Shopify)
  • Pluggable storage — In-memory (DashMap) with automatic GC; custom backends via Storage trait
  • Testable clock — Deterministic time control in tests with FakeClock
  • Standard headers — X-RateLimit-Limit, Remaining, Reset (Unix timestamp), Retry-After
  • Callbacks — on_limited for metrics/logging, custom 429 response builder
  • Tower-native — Works with Axum, Tonic, Hyper, or any Tower-based framework

Quick Start

Add to your Cargo.toml:

[dependencies]

tower-rate-tier = "0.2"

Define Tiers

use tower_rate_tier::{RateTier, Quota};

let tier = RateTier::builder()
    .tier("free", Quota::per_hour(100))
    .tier("pro", Quota::per_hour(5_000))
    .tier("enterprise", Quota::unlimited())
    .default_tier("free")
    .build();

Identify Users

With a closure (simple cases):

use tower_rate_tier::{TierLimitLayer, TierIdentity};

let layer = TierLimitLayer::new(tier)
    .identifier_fn(|headers| {
        let api_key = headers.get("X-Api-Key")?.to_str().ok()?.to_owned();
        Some(TierIdentity::new(api_key, "free"))
    });

Apply to Routes

use axum::{Router, routing::{get, post}};
use tower_rate_tier::tier_cost;

let app = Router::new()
    .route("/api/users", get(list_users))                   // cost: 1 (default)
    .route("/api/search", post(search).layer(tier_cost(5)))  // cost: 5
    .route("/api/export", post(export).layer(tier_cost(20))) // cost: 20
    .route("/health", get(health).layer(tier_cost(0)))       // free (no quota consumed)
    .layer(layer);

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 = TierLimitLayer::new(tier)
    .identifier_fn(|headers| { /* ... */ None })
    .rate_limited_response(|_user_id, tier, limited| {
        Response::builder()
            .status(StatusCode::TOO_MANY_REQUESTS)
            .header("Content-Type", "application/problem+json")
            .header("Retry-After", limited.retry_after.as_secs())
            .body(format!(r#"{{"type":"rate_limit","tier":"{}"}}"#, tier))
            .unwrap()
    });

Metrics / Logging

let layer = TierLimitLayer::new(tier)
    .identifier_fn(|headers| { /* ... */ None })
    .on_limited(|user_id, tier, limited| {
        eprintln!("rate limited: user={user_id} tier={tier} retry_after={:?}", limited.retry_after);
    });

Optional Features

# Body-based identification (opt-in, buffers request body)

tower-rate-tier = { version = "0.2", features = ["buffered-body"] }

Custom Storage Backend

Implement the Storage trait for your own backend:

let custom_storage: Arc<dyn Storage> = Arc::new(MyRedisStorage::new());

let tier = RateTier::builder()
    .tier("free", Quota::per_hour(100))
    .storage(custom_storage) // GC disabled automatically for custom backends
    .build();

Redis support is planned for v0.3.

Testing

Use FakeClock for deterministic rate limit tests:

use tower_rate_tier::clock::FakeClock;

#[tokio::test]
async fn test_rate_limit_expiry() {
    let clock = FakeClock::new();
    let limiter = RateTier::builder()
        .clock(clock.clone())
        .tier("free", Quota::per_hour(1))
        .build();

    assert!(limiter.check("user1", "free", 1).await.unwrap().is_ok());
    assert!(limiter.check("user1", "free", 1).await.unwrap().is_err());

    clock.advance(Duration::from_secs(3600));
    assert!(limiter.check("user1", "free", 1).await.unwrap().is_ok());
}

Handling Unidentified Requests

let tier = RateTier::builder()
    .on_missing(OnMissing::UseDefault)           // Use default tier
    // .on_missing(OnMissing::Allow)              // No rate limiting
    // .on_missing(OnMissing::Deny(StatusCode::FORBIDDEN)) // Block
    .build();

Storage Error Behavior

let layer = TierLimitLayer::new(tier)
    .on_storage_error(OnStorageError::Allow);  // Fail open (default)
    // .on_storage_error(OnStorageError::Deny); // Fail closed (503)

Comparison

Feature tower-governor tokio-rate-limit axum_gcra tower-rate-tier
Named tiers No No No Yes
Request cost/weight No No No Yes
Async identifier No Partial No Yes
Body-based identification No No No Yes
Custom storage No No No Yes
Testable clock No Yes No Yes
Tower-compatible Yes Axum only Axum only Yes
Algorithm GCRA Token bucket GCRA GCRA

License

Licensed under either of:

at your option.