lilydocs
HTTP APIWebSocketConsumer
Security3 min read

Rate limiting

Connect Redis's atomic counter operation to an HTTP guard to enforce quotas and Retry-After.

Your application owns the HTTP rate-limit key, quota, window, and failure policy. lilyrs::redis provides an atomic fixed-window operation on Redis for this policy. ApiRateLimitGuard in this guide is application code.

Dependencies and Redis#

A running Redis service is required. Add the redis feature to your existing lilyrs dependency and declare the tokio dependency used by the example:

Cargo.toml additions
lilyrs = { version = "0.1.0", features = ["http-api", "redis"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
TOML
[cache]
mode = "single"
provider = "redis"
redis_url = "redis://127.0.0.1:6379/0"
key_namespace = "users-api"
operation_timeout_secs = 2

The redis feature enables single mode. The cache service manages configuration and the connection lifecycle through DI. Its package name is lily_redis and its facade module is lilyrs::redis; the configuration section remains [cache].

A per-IP quota guard#

rate_limit.rs
use std::{sync::Arc, time::Duration};
use lilyrs::redis::CacheService;
use lilyrs::http_api::{
    CancellationToken, ExecutionCancellation, Extensions, GuardInitError,
    GuardRejection, GuardTrait, HttpErrorCode, Request,
};

pub struct ApiRateLimitGuard { cache: Arc<CacheService> }

fn unavailable() -> GuardRejection {
    GuardRejection::service_unavailable(HttpErrorCode::new("RATE_LIMIT_UNAVAILABLE").unwrap()).unwrap()
}

#[lilyrs::http_api::async_trait::async_trait]
impl GuardTrait for ApiRateLimitGuard {
    async fn new(extensions: Arc<Extensions>) -> Result<Self, GuardInitError> {
        let cache = extensions.get_service::<CacheService>(None).await
            .map_err(|_| GuardInitError::Dependency {
                guard: "ApiRateLimitGuard", reason: "Redis cache unavailable".into(),
            })?;
        Ok(Self { cache })
    }

    async fn can_activate(&self, request: &mut Request, stopping: ExecutionCancellation)
        -> Result<(), GuardRejection>
    {
        let ip = request.client_ip().ok_or_else(unavailable)?;
        // This policy allows 100 requests per IP per 60 seconds in the selected endpoint group.
        let key = format!("http:public-api:{ip}");
        let token = CancellationToken::new();
        let operation = self.cache.operation_context(token.clone()).map_err(|_| unavailable())?;
        let work = self.cache.fixed_window_rate_limit_with_context(
            &key, 100, Duration::from_secs(60), &operation,
        );
        tokio::pin!(work);
        let decision = tokio::select! {
            biased;
            _ = stopping.cancelled() => { token.cancel(); work.await }
            result = &mut work => result,
        }.map_err(|_| unavailable())?;
        if decision.allowed() { return Ok(()); }
        Err(GuardRejection::too_many_requests(
            HttpErrorCode::new("RATE_LIMITED").unwrap(), decision.retry_after(),
        ).map_err(|_| unavailable())?)
    }
}

Add #[guard(ApiRateLimitGuard)] to the actions you want to protect. This example shares a quota of 100 requests per IP per 60 seconds across that endpoint group. If the quota is exceeded, it returns 429 with the backend decision's Retry-After; if Redis is unavailable, it returns 503.

Choose a key#

For a per-user limit, run AuthGuard first and derive the key from the verified principal. An IP-based limit can count users behind NAT together. If there is a reverse proxy, trusted proxy configuration must be correct.

A fixed-window policy can allow bursts at window boundaries. Account for this when choosing a quota. max_in_flight_requests limits concurrent capacity; it is not a rate limit over time.

Connect cancellation#

HTTP ExecutionCancellation does not directly convert into Redis's CancellationToken. The example creates a separate token; on cancellation, it cancels that token and awaits the same operation. It does not leave a detached cancellation task behind.

The shorter ICache::fixed_window_rate_limit(...) path uses the configured operation deadline, but does not automatically connect HTTP cancellation.

Sources for this page v0.1.0

This guide is based on the reviewed framework commit.

QUICK START
Documentation
Source code