lilydocs
HTTP APIWebSocketConsumer
Security recipes4 min read

Rate limiting

Limit connection attempts and message rates separately, and connect Redis decisions to a WebSocket guard.

max_connections limits concurrent connection capacity; a message queue limit bounds waiting work. A rate limiter measures how many operations a key can perform within a period. These three limits are not interchangeable.

Choose the right phase#

Limit Extension point Rejection
Connection attempts Handshake middleware HTTP 429 and permitted Retry-After before 101
Message rate on an open connection Message guard WebSocket error, correlated ACK, or Close

For a handshake key, use trusted client_ip() or verified policy data. Choose message keys by Principal, tenant, route, or connection. HTTP 429 is not returned after 101.

Cache dependency and configuration#

Add redis to the first application's lilyrs feature list and declare the tokio-util dependency:

Cargo.toml dependencies
lilyrs = { version = "0.1.0", features = ["websocket", "redis"] }
tokio-util = "0.7"
Local lily.toml
[cache]
mode = "single"
provider = "redis"
redis_url = "redis://127.0.0.1:6379/0"
key_namespace = "chat-api"
operation_timeout_secs = 2

This configuration is for local development. Configure real deployment connections and secrets for your Redis environment. The facade's redis feature selects single mode, where CacheService is a Singleton. For factory mode, choose redis-factory instead, use a named-cell resolver, and adapt the single-service example below. Do not rename the [cache] configuration section to match the package name.

Per-subject message quota#

The example uses a fixed window of 20 messages per 10 seconds per verified subject. Add it as src/rate_limit.rs; choose .guard::<rate_limit::MessageRateLimit>() on the builder or #[guard(MessageRateLimit)] on the relevant action/controller.

rate_limit.rs
use std::{sync::Arc, time::Duration};
use lilyrs::redis::{CacheOperationContext, CacheService};
use lilyrs::websocket::{ExecutionCancellation, Extensions, WebSocketErrorCode, async_trait};
use lilyrs::websocket::guard::{GuardInitializationError, WebSocketGuardRejection, WsGuard};
use lilyrs::websocket::middleware::WsMessageExchange;
use tokio_util::sync::CancellationToken;

pub struct MessageRateLimit { cache: Arc<CacheService> }
fn reject(code: &'static str, message: &'static str) -> WebSocketGuardRejection {
    WebSocketGuardRejection::error(WebSocketErrorCode::new(code).expect("sabit kod"), message)
        .expect("sabit mesaj")
}
#[async_trait]
impl WsGuard for MessageRateLimit {
    async fn new(extensions: Arc<Extensions>) -> Result<Self, GuardInitializationError> {
        let cache = extensions.get_service::<CacheService>(None).await
            .map_err(|_| GuardInitializationError::MissingDependency)?;
        Ok(Self { cache })
    }
    async fn can_activate(&self, exchange: &mut WsMessageExchange,
        stopping: ExecutionCancellation) -> Result<(), WebSocketGuardRejection>
    {
        let principal = exchange.principal()
            .ok_or_else(|| reject("AUTH_REQUIRED", "Kimlik doğrulaması gerekli."))?;
        let key = format!("ws:chat:write:{}", principal.subject());
        let token = CancellationToken::new();
        let _cancel_on_drop = token.clone().drop_guard();
        let remaining = exchange.deadline().saturating_duration_since(tokio::time::Instant::now());
        let operation = CacheOperationContext::new(token.clone(), remaining.min(Duration::from_secs(1)))
            .map_err(|_| reject("LIMIT_UNAVAILABLE", "İşlem şu an değerlendirilemiyor."))?;
        let work = self.cache.fixed_window_rate_limit_with_context(
            &key, 20, Duration::from_secs(10), &operation,
        );
        tokio::pin!(work);
        let result = tokio::select! {
            biased;
            _ = stopping.cancelled() => { token.cancel(); work.await }
            result = &mut work => result,
        }.map_err(|_| reject("LIMIT_UNAVAILABLE", "İşlem şu an değerlendirilemiyor."))?;
        if result.allowed() { Ok(()) }
        else { Err(reject("RATE_LIMITED", "Çok sık mesaj gönderiyorsunuz.")) }
    }
}

Download the complete example.

The key is an example. If tenants share subject values, add the tenant and the action group sharing the limit to the key model. Fixed windows may permit bursts at boundaries; choose quotas according to application load.

Cancellation and backend failures#

The WS cancellation raw token is not public. The example creates an application-owned token, cancels it when the WS signal arrives, and awaits the operation future within the same invocation. drop_guard() also cancels the token on early drop. No detached task is created.

The operation duration is bounded by the remaining message budget and a one-second local cap. Backend failure does not silently fall back to a local counter; it rejects with LIMIT_UNAVAILABLE. This preserves consistency in the distributed quota decision; design a different degraded mode separately if required.

Passing retry_after() to a client requires an explicit application DTO/response decision. Guard ACK rejection does not accept arbitrary metadata objects. This example was compiled; it must not be treated as a completed live Redis quota load test.

Sources for this page v0.1.0

This guide is based on the reviewed framework commit.

QUICK START
Documentation
Source code