Actix Web

Observability

Try limits in shadow mode, read each limiter's decision in handlers, export metrics, and reset or change individual keys.

Shadow mode

permissive(true) runs a limiter without enforcing it: usage is tracked and every decision is recorded, but every request is admitted. Use it to size a new limit against real traffic before switching it on.

use actix_trypema::{LimitOutcome, Local, RateLimitInfo, Strategy, TrypemaLimiter, extract::PeerIp};
use actix_web::{App, HttpResponse, Responder, web};
use trypema::{RateLimit, WindowSize};

let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("ip")
    .extractor(PeerIp::default())
    .rate(RateLimit::per_minute_or_panic(60.0))
    // Absolute rejections record nothing in Trypema, so shadow counts under-count overload;
    // the suppressed strategy keeps accurate counts.
    .strategy(Strategy::Suppressed)
    .permissive(true)
    .build()?;

async fn handler(info: RateLimitInfo) -> impl Responder {
    match info.outcome() {
        LimitOutcome::Limited { .. } | LimitOutcome::Suppressed { admitted: false, .. } => {
            tracing::info!("would have been rate limited");
            HttpResponse::Ok().body("served anyway")
        }
        _ => HttpResponse::Ok().body("within limits"),
    }
}

let app = App::new().wrap(limiter).route("/", web::get().to(handler));

In shadow mode a key error forwards the request instead of answering 400, and a backend failure is admitted whatever on_backend_error says.

Reading decisions in handlers

record_outcome(true) records the decision while still enforcing it. Handlers read it with the RateLimitInfo extractor:

LimitOutcomeMeaning
Admitted { limit, remaining }Admitted; remaining is filled with remaining_header(true).
Limited { retry_after }Over the limit. Only reaches a handler in shadow mode.
Suppressed { factor, admitted }The suppressed strategy decided at random with this factor.
BypassedExcluded, allow-listed, cost 0, or a bypassed key error.
BackendErrorThe backend failed and the policy admitted the request.
use actix_trypema::{LimitOutcome, Local, RateLimitInfo, TrypemaLimiter, extract::PeerIp};
use actix_web::{HttpResponse, Responder};
use trypema::{RateLimit, WindowSize};

let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("ip")
    .extractor(PeerIp::default())
    .rate(RateLimit::per_minute_or_panic(60.0))
    .remaining_header(true)
    .record_outcome(true)
    .build()?;

async fn handler(info: RateLimitInfo) -> impl Responder {
    match info.outcome() {
        // Serve a lighter page to clients close to their limit.
        LimitOutcome::Admitted { remaining: Some(remaining), .. } if *remaining < 5 => {
            HttpResponse::Ok().body("light")
        }
        _ => HttpResponse::Ok().body("full"),
    }
}

Recording costs a small allocation per request, so it is off unless you turn it on.

Several limiters

When several recording limiters wrap a route, outcome() returns the innermost one, and for_namespace returns a specific one:

use actix_trypema::{LimitOutcome, RateLimitInfo};
use actix_web::{HttpResponse, Responder};

async fn handler(info: RateLimitInfo) -> impl Responder {
    let near_ip_limit = matches!(
        info.for_namespace("ip"),
        Some(LimitOutcome::Admitted { remaining: Some(0..=4), .. })
    );

    HttpResponse::Ok().body(if near_ip_limit { "slow down" } else { "ok" })
}

A handler that asks for RateLimitInfo when no limiter recorded anything gets 500 Internal Server Error, so turn on record_outcome on every limiter whose outcome a handler reads.

Metrics

With the metrics feature, every limiter reports through the metrics facade, so any recorder works, such as metrics-exporter-prometheus:

MetricTypeCounts
ratelimit_admitted_totalcounterAdmitted requests, including failures the policy admitted.
ratelimit_rejected_totalcounterRejected requests, including key-error rejections.
ratelimit_bypassed_totalcounterExcluded, allow-listed and zero-cost requests.
ratelimit_backend_errors_totalcounterRedis and hybrid failures (also counted as admitted or rejected).
ratelimit_backend_latency_secondshistogramBackend calls that await Redis.

Every metric carries namespace and strategy labels. Each request increments exactly one of the admitted, rejected and bypassed counters.

Logs

The middleware logs through tracing:

  • rejections and key errors at debug, with the namespace;
  • Redis and hybrid failures at warn, at most once per second per process, with the namespace and the failure kind.

Logs never include the key or the error text, which can contain client data or connection details.

Managing keys

derived_key(namespace, key) returns the key a limiter stores. Pass it to the backend's provider to inspect a client, lift a block, or change its rate in place.

With Local, the provider is synchronous:

use actix_trypema::{Local, derived_key};
use trypema::{RateLimit, WindowSize};

let backend = Local::new(WindowSize::seconds_or_panic(60))?;
let key = derived_key("ip", "203.0.113.7");
let limiter = backend.provider().absolute();

let used = limiter.get(&key); // requests in the current window
limiter.delete(&key); // forget the client: its next request starts fresh
limiter.set_rate_limit(&key, &RateLimit::per_minute_or_panic(600.0)); // raise its limit

With Redis and Hybrid, the calls are async and take a RedisKey:

use actix_trypema::{Redis, derived_key};
use trypema::{RateLimit, WindowSize, redis::RedisKey};

let backend = Redis::new(connection, WindowSize::minutes_or_panic(1))?;
let key = RedisKey::try_from(derived_key("apikey", "pro:account-2"))?;
let limiter = backend.provider().absolute();

limiter.delete(&key).await?;
limiter.set_rate_limit(&key, &RateLimit::per_minute_or_panic(1_000.0)).await?;

Use provider().suppressed() for limiters with Strategy::Suppressed. Hybrid providers record these changes in Redis, where the other instances pick them up the next time they read the key.