Limits
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()?;
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.

