Responses
A rejection
A rejected request never reaches the handler. It gets:
- status
429 Too Many Requests(a responder can change it); retry-afterin whole seconds, rounded up, when a wait time is known;- the rate limit headers of the chosen mode;
- a body from the responder; the default is
Too many requests, retry in 17s.
Header modes
headers(..) chooses which rate limit headers responses carry. Headers the handler already set
are never overwritten, and requests that bypass the limiter carry none.
| Mode | Admitted response | Rejected response |
|---|---|---|
HeaderMode::Legacy (default) | x-ratelimit-limit: 60 | x-ratelimit-limit: 60 |
HeaderMode::Ietf | ratelimit-policy: "ip";q=60;w=60 | ratelimit-policy: "ip";q=60;w=60 and ratelimit: "ip";r=0;t=17 |
HeaderMode::Off | none | none (retry-after is still sent) |
Ietf follows draft-ietf-httpapi-ratelimit-headers-09, with the namespace as the policy name. The
draft is still changing, so the mode is marked unstable.
Turn headers off on login and other abuse-prone routes, where they tell an attacker exactly how fast to go:
use actix_trypema::{HeaderMode, Local, TrypemaLimiter, extract::PeerIp};
use trypema::{RateLimit, WindowSize};
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
.namespace("login")
.extractor(PeerIp::default())
.rate(RateLimit::per_minute_or_panic(5.0))
.headers(HeaderMode::Off)
.build()?;
Remaining quota
remaining_header(true) adds the remaining count to admitted responses: x-ratelimit-remaining
in Legacy mode, and the ratelimit field in Ietf mode. It costs one extra read per
request: in memory for Local, a Redis round trip for Redis, and a local estimate for
Hybrid (which then skips its synchronous fast path).
use actix_trypema::{HeaderMode, Local, TrypemaLimiter, extract::PeerIp};
use trypema::{RateLimit, WindowSize};
// Admitted: ratelimit-policy: "api";q=100;w=60 and ratelimit: "api";r=42;t=60
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
.namespace("api")
.extractor(PeerIp::default())
.rate(RateLimit::per_minute_or_panic(100.0))
.headers(HeaderMode::Ietf)
.remaining_header(true)
.build()?;
With Strategy::Suppressed, Legacy mode also sends x-ratelimit-suppression: 0.250, the
current suppression factor.
Responders
The responder builds the rejection body. It receives the response builder with the status,
retry-after and rate limit headers already set.
JSON
With the json feature, JsonResponder sends
{"error":"rate_limited","namespace":"ip","retry_after_seconds":17}:
use actix_trypema::{JsonResponder, Local, TrypemaLimiter, extract::PeerIp};
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))
.responder(JsonResponder)
.build()?;
Your own
Implement RejectionResponder for full control, including the status:
use actix_trypema::{Local, RejectionInfo, RejectionResponder, TrypemaLimiter, extract::PeerIp};
use actix_web::{HttpResponse, HttpResponseBuilder};
use trypema::{RateLimit, WindowSize};
/// RFC 9457 problem details.
struct ProblemResponder;
impl RejectionResponder for ProblemResponder {
fn respond(&self, info: &RejectionInfo, mut builder: HttpResponseBuilder) -> HttpResponse {
let retry = info
.retry_after
.map(|wait| format!(", \"retry_after\": {}", wait.as_secs_f64().ceil()))
.unwrap_or_default();
builder.content_type("application/problem+json").body(format!(
"{{\"type\": \"about:blank\", \"title\": \"Too Many Requests\", \"status\": 429, \"limit\": {}{retry}}}",
info.limit
))
}
}
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
.namespace("ip")
.extractor(PeerIp::default())
.rate(RateLimit::per_minute_or_panic(60.0))
.responder(ProblemResponder)
.build()?;
Change the status with builder.status(..), for example to answer 503 while shedding load:
use actix_trypema::{RejectionInfo, RejectionResponder};
use actix_web::{HttpResponse, HttpResponseBuilder, http::StatusCode};
struct ShedResponder;
impl RejectionResponder for ShedResponder {
fn respond(&self, info: &RejectionInfo, mut builder: HttpResponseBuilder) -> HttpResponse {
builder
.status(StatusCode::SERVICE_UNAVAILABLE)
.body(format!("{} is busy, try again shortly", info.namespace))
}
}
What a responder can see
RejectionInfo describes the rejection:
| Field | Meaning |
|---|---|
namespace | The rejecting limiter. |
key | The stored key, as from derived_key. |
strategy | Absolute or Suppressed. |
limit | The capacity per window. |
retry_after | Best-effort wait until capacity frees up; None when unknown. |
remaining_after_waiting | Capacity freed after waiting, when known. |
suppression_factor | The factor, for suppressed rejections. |
The same RejectionInfo is attached to the rejection response's extensions, so outer
middleware such as ErrorHandlers can read it too.

