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:
lilyrs = { version = "0.1.0", features = ["http-api", "redis"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }[cache]
mode = "single"
provider = "redis"
redis_url = "redis://127.0.0.1:6379/0"
key_namespace = "users-api"
operation_timeout_secs = 2The 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#
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.