Actix Web

Limits

Set rates and windows, give plans different limits, charge expensive requests more, shed load gradually, and stack several limiters.

Rate and window

The backend fixes the window; the limiter sets the rate. The capacity per window is the rate times the window, rounded down:

use actix_trypema::{Local, TrypemaLimiter, extract::PeerIp};
use trypema::{RateLimit, WindowSize};

// 10 requests per second over a 60-second sliding window: 600 per window.
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("ip")
    .extractor(PeerIp::default())
    .rate(RateLimit::per_second_or_panic(10.0))
    .build()?;

RateLimit has per_second, per_minute, per_hour, per_day, per_week and per_month constructors; WindowSize has seconds, minutes, hours, days, weeks and months. Each has an _or_panic twin. The window is sliding: a request counts until a full window has passed since it was made, so there is no burst at window boundaries.

A rate whose capacity rounds down to zero is rejected by build() with ConfigError::ZeroCapacity: RateLimit::per_hour(30.0) on a 60-second window admits 0.5 requests per window.

Plan tiers

rate_fn picks the rate per request, from the request and the extracted key:

use actix_trypema::{KeyError, Local, TrypemaLimiter};
use trypema::{RateLimit, WindowSize};

let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("apikey")
    // Your validation puts the plan in the key, for example "pro:account-2".
    .extractor_fn(|req| {
        req.headers()
            .get("x-plan-key")
            .and_then(|value| value.to_str().ok())
            .map(str::to_string)
            .ok_or(KeyError::InvalidValue { reason: "missing plan key" })
    })
    .rate_fn(|_req, key| {
        if key.starts_with("pro:") {
            RateLimit::per_minute_or_panic(1_000.0)
        } else {
            RateLimit::per_minute_or_panic(60.0)
        }
    })
    .build()?;
Trypema rates are sticky per key: the first rate stored for a key stays until its state expires. A rate that changes while the key stays the same is ignored. Put the plan in the key, as above, so an upgrade lands in a new bucket. To change an existing key's rate in place, see Managing keys.

The validating extractor example shows a complete version that looks keys up before trusting them.

Request costs

By default every request costs 1. cost_fn charges more for expensive requests; a cost of 0 forwards the request without counting it:

use actix_trypema::{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(100.0))
    .cost_fn(|req| match req.path() {
        "/export" => 20,   // a heavy report counts as 20 requests
        "/healthz" => 0,   // never counted
        _ => 1,
    })
    .build()?;

Keep costs at or below the window capacity. The local backend checks before it adds, so a request costing n can overshoot the limit by up to n - 1. The Redis and hybrid backends reject a request whose cost is more than the capacity left, so a cost above the whole capacity is never admitted.

Strategies

The default Strategy::Absolute admits until the limit, then rejects until capacity frees up. Strategy::Suppressed sheds load gradually instead: past a soft limit, a growing share of requests is rejected at random, until the hard limit rejects them all.

use std::time::Duration;

use actix_trypema::{Local, Strategy, 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(600.0))
    .strategy(Strategy::Suppressed)
    // Suppressed rejections have no natural wait time; advertise one.
    .suppressed_retry_hint(Duration::from_secs(5))
    .build()?;

The hard limit is the rate times the provider's hard limit factor (configured on the backend; see Suppressed). Admitted responses carry the current factor in x-ratelimit-suppression.

Skipping requests

exclude forwards matching requests without counting them. Each call adds a predicate, and a request matching any of them is skipped:

use actix_trypema::{Local, TrypemaLimiter, extract::PeerIp};
use actix_web::http::Method;
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))
    .exclude(|req| req.path() == "/healthz" || req.path().starts_with("/static/"))
    .exclude(|req| req.method() == Method::OPTIONS) // CORS preflights
    .build()?;

Several limiters on one app

Real APIs often combine limits: a generous per-IP limit everywhere, a strict one on login, and per-account quotas on the API. Each is its own limiter with its own namespace. They can share one backend:

use actix_trypema::{Local, TrypemaLimiter, extract::{Header, PeerIp}};
use actix_web::{App, web};
use trypema::{RateLimit, WindowSize};

let backend = Local::new(WindowSize::seconds_or_panic(60))?;

let per_ip = TrypemaLimiter::builder(backend.clone())
    .namespace("ip")
    .extractor(PeerIp::default())
    .rate(RateLimit::per_minute_or_panic(600.0))
    .build()?;

let login = TrypemaLimiter::builder(backend.clone())
    .namespace("login")
    .extractor(PeerIp::default())
    .rate(RateLimit::per_minute_or_panic(5.0))
    .build()?;

let per_account = TrypemaLimiter::builder(backend)
    .namespace("account")
    .extractor(Header::new("x-account-id")?) // set by your auth middleware
    .rate(RateLimit::per_minute_or_panic(1_000.0))
    .build()?;

let app = App::new()
    .wrap(per_ip)
    .service(
        web::resource("/login")
            .wrap(login)
            .route(web::post().to(|| async { "logged in" })),
    )
    .service(
        web::scope("/api")
            .wrap(per_account)
            .route("/orders", web::get().to(|| async { "orders" })),
    );

A request to /login counts against both ip and login, and is rejected by whichever runs out first.