Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rate Limiting

Rate limiting is a first-class, pluggable concern: a small trait in capi_rate_limit and a set of implementations in capi_limiters. You can pace requests proactively (a local budget), reactively (obey the server’s X-RateLimit-* / Retry-After), or both — and swap the algorithm without touching endpoints. The same trait meters the transfer plane, so a byte throttle is a rate limiter denominated in bytes.

The trait

pub trait RateLimiter: Debug + MaybeSend + MaybeSync + 'static {
    fn try_acquire(&self, now: Instant) -> Result<(), Duration>;                    // required
    fn try_acquire_n(&self, now: Instant, n: u64) -> Result<(), Duration> { … }     // charge n units; default: one operation
    fn peek_delay(&self, now: Instant) -> PeekDelay { PeekDelay::Unsupported }      // the wait, without consuming a slot
    fn peek_delay_n(&self, now: Instant, n: u64) -> PeekDelay { … }
    fn unit(&self) -> LimiterUnit { LimiterUnit::Operations }                       // what the budget is counted in
    fn update_from_headers(&self, clock: &ApiClock, headers: &HeaderMap)
        -> Result<(), HeaderUpdateError> { Ok(()) }                                // learn from the server
}

try_acquire is the heart and the only required method: Ok(()) to proceed, Err(delay) to wait that long first. try_acquire_n charges n units of whatever the limiter is denominated in — the default charges one operation whatever n says, which is right for a requests-per-minute limiter, where a request costs one request however many bytes it carries. unit() declares the denomination: Operations (the default), Bytes, Tokens (model tokens, or any quota a service meters a request’s content by), Other(&'static str), or Unspecified for the no-op limiters and for a composite whose members disagree. WaitableRateLimiter adds wait_until_ready(clock).

Limiters are carried as a RateLimitNone, Static(&'static dyn RateLimiter), or Arc(Arc<dyn RateLimiter>) — and installed through the sealed ToRateLimiter trait, which is implemented for &'static T, Arc<T>, and RateLimit itself. NO_RATE_LIMIT is the empty carrier, and ApiConfig::default_rate_limiter returns it unless a config says otherwise.

Proactive: GCRA

The Generic Cell Rate Algorithm paces requests against a local budget — smooth, allocation-free, and no_std-friendly (it is arithmetic on a theoretical arrival time). GcraLimiter is the implementation, and Rate expresses the budget:

let limiter = GcraLimiter::per_second(2, 3);   // 2 req/s sustained, burst of 3
let rate = Rate::per_second(10);               // or per_minute / per_hour / per_day / unlimited

Don’t conflate the two per_seconds: GcraLimiter::per_second(rate, burst) takes two arguments (sustained rate + burst); Rate::per_second(tokens) takes one.

Reactive: header-based

When the server publishes its limits, obey them rather than guessing. HeaderLimiter reads quota headers (X-RateLimit-Remaining / -Reset) and Retry-After off each response through update_from_headers, so your pacing tracks the server’s actual state. It is built from a description of the headers the service sends — HeaderLimiter::from_headers(..) with a HeaderPolicy naming the remaining/reset headers and how the reset is spelled (ResetAfter::Seconds, Timestamp, HttpDate, SecondsOrHttpDate, Duration), or HeaderLimiter::retry_after(RetryAfter::Seconds | Auto | ..) for a service that only ever says Retry-After. Combine a GcraLimiter (a proactive floor) with a HeaderLimiter (reactive correction) for the best of both.

Who feeds the reactive limiter is the traversal: on the final attempt’s response, before any pipeline member can transform it, the traversal hands the head to the request’s limiter — best-effort, so a limiter that cannot read the headers keeps the parameters it has. Only the primary traversal entry does this; a credential mint skips the gate, and a capture has no response.

Cost-aware limiters

Not every quota counts requests. A byte-rate cap counts bytes; a model API’s tokens-per-minute counts tokens. MeteredLimiter is a GCRA budget spent in a declared unit — MeteredLimiter::bytes_per_second(bytes, burst_bytes), or MeteredLimiter::new(gcra, LimiterUnit::Tokens) — whose try_acquire_n charges the amount it is handed and admits regardless of it, so a single operation larger than the burst waits rather than being refused forever.

Composition follows from the denomination. Pair a requests-per-minute limiter with a tokens-per-minute one, call try_acquire_n with a token count, and each member reads n its own way: the operations limiter charges one, the token limiter charges n. The one composition that cannot work is two members denominated in different metered quantities, since only one n crosses the call — the composite’s unit() reports Unspecified for exactly that case.

The transfer plane is where a bytes-denominated limiter is driven. Throttle::with_limiter(impl ToRateLimiter) from capi_extras is a transfer stage that charges each chunk’s length against the limiter — the only place try_acquire_n is called with n > 1 today — and one Throttle seated as a shared stage becomes a process-wide bandwidth cap (Middleware). A throttle handed a limiter denominated in operations paces per chunk rather than per byte, which is logged when it happens and not refused.

Header-driven limiters

Build a header limiter from the contract published by the API being called:

use capi_rs::rate_limiters::{HeaderLimiter, ResetAfter, RetryAfter, RETRY_AFTER};

let quota = HeaderLimiter::from_headers(
    "x-quota-remaining",
    ResetAfter::Timestamp("x-quota-reset"),
    "x-quota-limit",
);
let retry = HeaderLimiter::retry_after(RetryAfter::Seconds);

from_headers accepts the quota, reset, and capacity header names, while retry_after selects how to parse the standard Retry-After header. The generic RETRY_AFTER static accepts both standard forms: delay-seconds and HTTP-date.

A static limiter costs nothing to reference — and it is process-global, one admission history for every client in the process that names it. A service whose quota is partitioned by account, resource, or endpoint wants one HeaderLimiter per partition instead, held in an Arc.

Wiring it up

Where the gate sits decides what a wait can and cannot affect. The traversal runs the rate-limit gate as its fifth step — after the Late phase, before the seal — so every member has shaped the request before the lane sleeps, and every signature is computed after the wait rather than staled by it (Middleware has the full order).

A client-wide default comes from the config. BaseConfig::with_default_rate_limit(..) installs it, and ApiConfig::default_rate_limiter() reports it; a wrapping config forwards the inner one’s:

let config = BaseConfig::new(base_url).with_default_rate_limit(&RETRY_AFTER);

fn default_rate_limiter(&self) -> impl ToRateLimiter {
    self.inner.default_rate_limiter()
}

An individual endpoint overrides rate_limit(&self, config, context) — which by default delegates to config.default_rate_limiter() — when one operation has a tighter budget than the rest of the API. Return a shared handle from it, a &'static limiter or an Arc-held one resolved from the config, never a limiter built fresh in the method: the hook is re-resolved once per send, for the primary request and again for each follow-up a decoder issues (a next page, a reconnect), and only a shared instance carries its admission history across those sends.

A single call adjusts its own limiter through the scoped request — api.scoped().with_rate_limit(limiter) to substitute one, .no_rate_limit() to skip the gate for this send.

When more than one limiter applies, composition is max-delay-wins: a tuple of up to four limiters, or a Vec<RateLimit>, acquires only when every member allows, waiting the longest delay any of them asks. Every member is visited on each attempt and the order of the tuple does not matter — which is unlike the pipeline’s members, which run in phase order, and unlike runners, which nest.

Next: runners, the layer above the decoder that can drive a query more than once.