Rate Limiting

Pace or shed calls to a rate-sensitive dependency.

This page covers the rate limiter in depth. See Resilience for how it composes with the other primitives, and Circuit Breakers for the related "dependency is down" case.


A circuit breaker stops calls to a dependency that's down. A RateLimiter paces calls to a dependency that's up but rate-sensitive — a third-party API with a per-second quota, a database, an LLM endpoint billed per request. It smooths bursty traffic into a steady rate the downstream can absorb.

It's a token bucket: a bucket holds fractional tokens; each acquisition spends one; tokens replenish at a fixed rate up to a capacity. Refill is lazy — there's no background task. Every acquire reads a single Instant, adds elapsed × refill_per_sec tokens (capped at capacity), then decides. A workflow that never builds a limiter pays nothing. The bucket starts full, so a burst of up to capacity calls is admitted instantly before sustained traffic settles to the refill rate.

Like a breaker, a limiter is cheap to clone (it's an Arc inside) — share one Arc<RateLimiter> across every task that draws on the same quota so the budget is enforced globally, including across tasks running in parallel inside a split/join state. Internally it's a synchronous parking_lot::Mutex with no awaits held across the critical section.

RateLimiterPolicy

Build a policy with RateLimiterPolicy::per_second(n) (or ::new(tokens, period) for an arbitrary window) and tune it with the with_max_tokens / with_burst builders. Total bucket capacity is max_tokens + burst.

FieldTypeMeaning
max_tokensu32Steady-state bucket ceiling — and the size of the instantaneous burst a fresh limiter admits, since the bucket starts full. Defaults to tokens (one period's worth).
tokens_per_periodu32Tokens added per refill_period.
refill_periodDurationHow long it takes to add tokens_per_period tokens. per_second(n) sets this to one second.
burstu32Extra capacity above max_tokens for short spikes. Defaults to 0.

RateLimiter::new panics on a misconfigured policy at construction: max_tokens == 0 (a zero-capacity bucket could never admit a call) or a zero refill rate (tokens_per_period == 0 or a zero refill_period — the bucket would never replenish). Both are programmer errors, caught before any task runs.

Acquiring: try_acquire / acquire

The returned Permit is a lightweight RAII marker for the call's scope. Unlike a circuit-breaker permit (which records a success/failure outcome) or a semaphore permit (which returns capacity on drop), a token-bucket permit's drop is a no-op — the token was already spent at acquisition and the bucket refills on the clock, not on release.

use cano::prelude::*;
use std::sync::Arc;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Step { Call, Done }

#[derive(Clone)]
struct CallUpstream { limiter: Arc<RateLimiter> }

#[task(state = Step)]
impl CallUpstream {
    async fn run_bare(&self) -> Result<TaskResult<Step>, CanoError> {
        // Park until the shared budget admits this call, then proceed.
        let _permit = self.limiter.acquire().await;
        // ... call the rate-sensitive dependency ...
        Ok(TaskResult::Single(Step::Done))
    }
}

// 20 req/s, shared across every task that constructs from this Arc.
let limiter = Arc::new(RateLimiter::new(RateLimiterPolicy::per_second(20)));
let workflow = Workflow::bare()
    .register(Step::Call, CallUpstream { limiter: Arc::clone(&limiter) })
    .add_exit_state(Step::Done);

RateLimiter also implements Resource (no-op lifecycle), so instead of threading the Arc into each task you can register it once in Resources and look it up by key inside the task body — handy when several tasks share one quota.

Token bucket vs fixed window

The token bucket is a faithful governor — it keeps you under a long-run rate and smooths bursts — but it is not a faithful model of a "resets-at-a-boundary" quota like a usage quota's "N per 5 hours, resets at 14:00." It drips capacity back continuously and has no reset instant to display. When you need that shape, use WindowedRateLimiter: a fixed-window counter that admits the full quota at once, resets as a step at the boundary, and exposes used() / remaining() / resets_at(). It resets lazily (no background task) and, like the bucket, is cheap to clone and implements Resource.

RateLimiter (token bucket)WindowedRateLimiter (fixed window)
Replenishmentcontinuous drip at the refill ratestep reset at the boundary
After exhaustionone more unit every period/quotazero until the reset, then the full quota
resets_atnone (boundary-less)a displayable instant
Best forpacing outbound load under a ratemirroring a quota with a reset time

Weighted cost

Both limiters meter weighted units: try_acquire_n(cost) / acquire_n(cost) consume cost units instead of one (the no-argument try_acquire / acquire are _n(1)). A request-count limit uses cost = 1; a usage/token budget uses the call's cost (e.g. 1500 tokens). tokens_available() / time_until(cost) expose the live state for observability and retry-after.

Multi-level limiting (several tiers at once)

Real-world API limits often stack: a 5-hour cap and a weekly cap and a separate weekly cap for a single endpoint. MultiRateLimiter enforces them together — a request is admitted only if every applicable tier has room. Each tier is any Meter (a RateLimiter or a WindowedRateLimiter, mixed freely) with its own cost, so a request-count tier and a token-budget tier can share one gate.

The acquisition is atomic with no leak: it reserves each tier in turn, and if any tier rejects it drops the reservations gathered so far — refunding their units — so a partially-passing attempt never burns budget on the tiers that admitted it. (This is why a Reservation's drop refunds, unlike a committed Permit.) At most one tier's lock is held at a time, so there is no deadlock. On rejection it reports which tier blocked and the retry-after, as CanoError::RateLimited { tier, retry_after }.

use cano::prelude::*;
use std::sync::Arc;
use std::time::Duration;

let five_hour: Arc<dyn Meter> =
    Arc::new(WindowedRateLimiter::new(WindowPolicy::per_hours(500, 5)));
let weekly: Arc<dyn Meter> =
    Arc::new(WindowedRateLimiter::new(WindowPolicy::per_days(5_000, 7)));
let opus_weekly: Arc<dyn Meter> =
    Arc::new(WindowedRateLimiter::new(WindowPolicy::per_days(200, 7)));
// A usage/token budget metered in tokens, smoothed by a bucket.
let tokens: Arc<dyn Meter> = Arc::new(RateLimiter::new(
    RateLimiterPolicy::new(1_000_000, Duration::from_secs(60)).with_max_tokens(1_000_000),
));

let limiter = MultiRateLimiter::new()
    .with_tier("5h", five_hour, 1)
    .with_tier("weekly", weekly, 1)
    .with_tier("opus_weekly", opus_weekly, 1)
    .with_tier("tokens", tokens, 1500); // this call costs 1500 tokens

// Shed-load: which tier blocked, and for how long?
match limiter.try_acquire() {
    Ok(_permit) => { /* all tiers had room; proceed */ }
    Err(CanoError::RateLimited { tier, retry_after }) => {
        eprintln!("blocked by `{tier}`, retry after {retry_after:?}");
    }
    Err(_) => unreachable!(),
}

For a per-request subset — e.g. a non-Opus request that should skip the model-scoped tier — use try_acquire_for(&["5h", "weekly", "tokens"]) (or the async acquire_for). A tier with cost = 0 is inert (never blocks, never debited), another way to disable one conditionally.

Runnable examples: cargo run --example rate_limiter — two spawned workers share one 5 req/s bucket (timestamps land at ~200ms intervals). cargo run --example rate_limiter_multi — a 5h + weekly + per-model + token-budget gate showing shed-load, the blocking-tier report, zero-leak on rejection, per-request tier selection, and async parking.