Observability
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:
LimitOutcome | Meaning |
|---|---|
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. |
Bypassed | Excluded, allow-listed, cost 0, or a bypassed key error. |
BackendError | The 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:
| Metric | Type | Counts |
|---|---|---|
ratelimit_admitted_total | counter | Admitted requests, including failures the policy admitted. |
ratelimit_rejected_total | counter | Rejected requests, including key-error rejections. |
ratelimit_bypassed_total | counter | Excluded, allow-listed and zero-cost requests. |
ratelimit_backend_errors_total | counter | Redis and hybrid failures (also counted as admitted or rejected). |
ratelimit_backend_latency_seconds | histogram | Backend 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.

