Actix Web

Responses

The headers admitted and rejected responses carry, and how to customize the rejection body and status.

A rejection

A rejected request never reaches the handler. It gets:

  • status 429 Too Many Requests (a responder can change it);
  • retry-after in 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.

ModeAdmitted responseRejected response
HeaderMode::Legacy (default)x-ratelimit-limit: 60x-ratelimit-limit: 60
HeaderMode::Ietfratelimit-policy: "ip";q=60;w=60ratelimit-policy: "ip";q=60;w=60 and ratelimit: "ip";r=0;t=17
HeaderMode::Offnonenone (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:

FieldMeaning
namespaceThe rejecting limiter.
keyThe stored key, as from derived_key.
strategyAbsolute or Suppressed.
limitThe capacity per window.
retry_afterBest-effort wait until capacity frees up; None when unknown.
remaining_after_waitingCapacity freed after waiting, when known.
suppression_factorThe 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.