Actix Web

Keys and custom extractors

Limit by API key, tenant, user or anything else in the request, with the built-in extractors or your own.

The extractor decides who a request counts against. Each distinct key gets its own bucket. The key is combined with the limiter's namespace before it is stored, so limiters never share buckets by accident.

ExtractorKeys by
PeerIp, RealIpClient IP; see Client IPs.
HeaderA request header, raw or hashed.
GlobalNothing: one bucket for every request.
CompositeTwo extractors joined, such as tenant and IP.
extractor_fn(..)A closure you write.
impl KeyExtractorA type you write, without allocating per request.

API keys and other headers: Header

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

let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("apikey")
    // hashed(): the raw key never appears in limiter state, Redis or logs.
    .extractor(Header::new("x-api-key")?.hashed())
    .rate(RateLimit::per_minute_or_panic(1_000.0))
    .build()?;
Key only on values an earlier middleware has authenticated. A raw header is chosen by the client, and every new value gets a fresh, full bucket. Wrap this limiter inside your authentication middleware, or validate the value in a custom extractor.

A missing header is a key error (400 by default). Values longer than 1024 bytes are rejected, or truncated before hashing with hashed(). Header names must be lowercase.

One bucket for everything: Global

Global puts every request in one bucket. Use it to protect something with a fixed total capacity, such as a slow downstream API:

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

let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(1))?)
    .namespace("reports")
    .extractor(Global)
    .rate(RateLimit::per_second_or_panic(20.0)) // 20 requests per second in total
    .build()?;

Two keys at once: Composite

Composite joins two extractors into one key, for example a per-IP limit inside each tenant:

use actix_trypema::extract::{Composite, Header, PeerIp};

let per_tenant_ip = Composite::new(Header::new("x-tenant-id")?, PeerIp::default());

// Nest for three parts: tenant, user and IP.
let per_tenant_user_ip = Composite::new(
    Header::new("x-tenant-id")?,
    Composite::new(Header::new("x-user-id")?.hashed(), PeerIp::default()),
);

The parts are joined so that different pairs never produce the same key.

Closure extractors

extractor_fn keys requests with a closure that returns Result<String, KeyError>. It can read anything on the request: headers, the path, cookies, query strings, or values earlier middleware stored in the request extensions.

The authenticated user

Authentication middleware usually stores the user in the request extensions. Key on it, and wrap the limiter inside that middleware so the user is there:

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

/// Inserted by your authentication middleware.
#[derive(Clone)]
struct UserId(u64);

let per_user = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("user")
    .extractor_fn(|req| {
        req.extensions()
            .get::<UserId>()
            .map(|user| user.0.to_string())
            .ok_or(KeyError::InvalidValue {
                reason: "request is not authenticated",
            })
    })
    .rate(RateLimit::per_minute_or_panic(300.0))
    .build()?;

A path parameter

Path parameters are available when the limiter wraps the scope or resource that declares them. On App::wrap the request has not been routed yet, and match_info() is empty.

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

let per_tenant = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("tenant")
    .extractor_fn(|req| {
        req.match_info()
            .get("tenant")
            .map(str::to_string)
            .ok_or(KeyError::InvalidValue {
                reason: "missing tenant",
            })
    })
    .rate(RateLimit::per_minute_or_panic(1_000.0))
    .build()?;

let app = App::new().service(
    web::scope("/tenants/{tenant}")
        .wrap(per_tenant)
        .route("/orders", web::get().to(|| async { "orders" })),
);
use actix_trypema::{KeyError, Local, TrypemaLimiter};
use trypema::{RateLimit, WindowSize};

let per_session = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("session")
    .extractor_fn(|req| {
        req.cookie("session")
            .map(|cookie| cookie.value().to_string())
            .ok_or(KeyError::InvalidValue {
                reason: "missing session cookie",
            })
    })
    .rate(RateLimit::per_minute_or_panic(120.0))
    .build()?;

ServiceRequest::cookie needs actix-web's default cookies feature.

Validating before keying

A closure can also reject values it doesn't recognize, so unknown keys never get a bucket. This one accepts only known API keys and puts the plan into the key (see plan tiers):

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

/// Stand-in for a real key store: API key, account, plan.
const API_KEYS: [(&str, &str, &str); 2] = [
    ("basic-key", "account-1", "basic"),
    ("pro-key", "account-2", "pro"),
];

let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("apikey")
    .extractor_fn(|req| {
        let presented = req
            .headers()
            .get("x-api-key")
            .and_then(|value| value.to_str().ok())
            .ok_or(KeyError::InvalidValue {
                reason: "missing api key",
            })?;

        let (_, account, plan) = API_KEYS
            .iter()
            .find(|(key, _, _)| *key == presented)
            .ok_or(KeyError::InvalidValue {
                reason: "unknown api key",
            })?;

        Ok(format!("{plan}:{account}"))
    })
    .rate(RateLimit::per_minute_or_panic(60.0))
    .build()?;

Implementing KeyExtractor

A closure allocates a String on every request. For hot paths, or an extractor you reuse across limiters, implement KeyExtractor instead. It either borrows the key from the request or writes it into the provided KeyBuf, a 256-byte buffer that allocates nothing for short keys:

pub trait KeyExtractor: Send + Sync + 'static {
    fn extract<'a>(
        &self,
        req: &'a ServiceRequest,
        buf: &'a mut KeyBuf,
    ) -> Result<&'a str, KeyError>;
}

Borrowing a value straight from the request:

use actix_trypema::{KeyError, extract::{KeyBuf, KeyExtractor}};
use actix_web::dev::ServiceRequest;

/// Keys by the `x-tenant-id` header, accepting only short alphanumeric ids.
struct TenantId;

impl KeyExtractor for TenantId {
    fn extract<'a>(
        &self,
        req: &'a ServiceRequest,
        _buf: &'a mut KeyBuf,
    ) -> Result<&'a str, KeyError> {
        let tenant = req
            .headers()
            .get("x-tenant-id")
            .and_then(|value| value.to_str().ok())
            .ok_or(KeyError::InvalidValue {
                reason: "missing tenant id",
            })?;

        let is_valid = !tenant.is_empty()
            && tenant.len() <= 32
            && tenant.bytes().all(|byte| byte.is_ascii_alphanumeric());

        if !is_valid {
            return Err(KeyError::InvalidValue {
                reason: "invalid tenant id",
            });
        }

        Ok(tenant)
    }
}

Building the key in the buffer, here from a user stored by authentication middleware. KeyBuf implements std::fmt::Write, so write! works without an intermediate String:

use std::fmt::Write as _;

use actix_trypema::{KeyError, extract::{KeyBuf, KeyExtractor}};
use actix_web::{HttpMessage, dev::ServiceRequest};

#[derive(Clone)]
struct User {
    org: u64,
    id: u64,
}

/// Keys by `{org}.{user}` for authenticated users.
struct OrgUser;

impl KeyExtractor for OrgUser {
    fn extract<'a>(
        &self,
        req: &'a ServiceRequest,
        buf: &'a mut KeyBuf,
    ) -> Result<&'a str, KeyError> {
        let extensions = req.extensions();
        let user = extensions.get::<User>().ok_or(KeyError::InvalidValue {
            reason: "request is not authenticated",
        })?;

        let _ = write!(buf, "{}.{}", user.org, user.id);
        Ok(buf.as_str())
    }
}

Use your extractor like any built-in one, alone or inside Composite:

use actix_trypema::{Local, TrypemaLimiter, KeyError, extract::{Composite, KeyBuf, KeyExtractor, PeerIp}};
use actix_web::dev::ServiceRequest;
use trypema::{RateLimit, WindowSize};

struct TenantId;

impl KeyExtractor for TenantId {
    fn extract<'a>(&self, req: &'a ServiceRequest, _buf: &'a mut KeyBuf) -> Result<&'a str, KeyError> {
        req.headers()
            .get("x-tenant-id")
            .and_then(|value| value.to_str().ok())
            .ok_or(KeyError::InvalidValue { reason: "missing tenant id" })
    }
}

let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("tenant-ip")
    .extractor(Composite::new(TenantId, PeerIp::default()))
    .rate(RateLimit::per_minute_or_panic(100.0))
    .build()?;

What makes a good key

  • Stable per client. The same client must produce the same key on every request.
  • Bounded. Every distinct key holds state for a window. Never key on raw client-chosen values without validating them.
  • No secrets. Keys are stored in Redis and appear in RejectionInfo. Hash credentials with Header::hashed(), or key on an account id instead.
  • Any length. Long keys are stored as a hash automatically, so size isn't a concern.

Key errors

When an extractor fails, on_key_error decides what happens:

PolicyEffect
KeyErrorPolicy::Reject (default)400 Bad Request with the body invalid rate limiting key. The body never echoes the request.
KeyErrorPolicy::BypassForward the request without counting it.
KeyErrorPolicy::UseGlobalKeyCount the request in one shared bucket for the namespace.
use actix_trypema::{KeyErrorPolicy, Local, TrypemaLimiter, extract::Header};
use trypema::{RateLimit, WindowSize};

// Anonymous requests (no API key) share one small bucket instead of being rejected.
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
    .namespace("apikey")
    .extractor(Header::new("x-api-key")?.hashed())
    .rate(RateLimit::per_minute_or_panic(100.0))
    .on_key_error(KeyErrorPolicy::UseGlobalKey)
    .build()?;

KeyError has four variants: MissingPeerAddr, MissingHeader { name }, MalformedForwardedChain and InvalidValue { reason }. Custom extractors usually return InvalidValue with a short static reason.

The stored key

derived_key(namespace, key) returns exactly what a limiter stores for a key. Use it to inspect or reset one client; see Managing keys.

use actix_trypema::derived_key;

assert_eq!(derived_key("ip", "203.0.113.7"), "ip_r_203.0.113.7");