Rate limiting
Budget caps bound total spend, but
do nothing about burst rate — a tenant can fire an arbitrary number
of steps per second until the cumulative cap trips.
TokenBucketRateLimiter closes that gap: a classic token bucket,
checked once per model step from the shared budget gate, so every
architecture is paced through one seam.
from loomflow import Agent, TokenBucketRateLimiter
agent = Agent(
"You are a support assistant.",
model="gpt-4.1-mini",
rate_limiter=TokenBucketRateLimiter(rps=5, burst=10),
)That’s the whole wiring. Every model step now acquires one token from
the calling user_id’s bucket before any provider call is made. The
default is None — no limiter, and the call site is short-circuited
entirely, so an agent without a limiter pays zero overhead.
TokenBucketRateLimiter(...)
TokenBucketRateLimiter(
rps: float, # sustained refill rate (tokens/sec)
burst: int, # bucket capacity
*,
per_user: bool = True, # independent bucket per user_id
mode: "throttle" | "raise" = "throttle",
max_users: int | None = 100_000,
user_idle_ttl_seconds: float | None = 86_400,
)| Parameter | Description |
|---|---|
rps | Sustained rate: tokens accrue at rps per second, up to burst. Must be > 0. |
burst | Bucket capacity — how many steps a quiet tenant may fire back-to-back before pacing kicks in. Must be >= 1. |
per_user | True (default) keeps an independent bucket per user_id (the anonymous None id gets its own bucket). False shares one global bucket across all callers. |
mode | "throttle" (default) waits until a token accrues, smoothing bursts into the configured rate. "raise" raises RateLimitExceeded immediately when the bucket is empty. |
max_users / user_idle_ttl_seconds | Bounds on the per-user bucket map — the same LRU cap (100k tenants) + idle TTL (24h) that StandardBudget uses for its per-user accounting, so an adversarial stream of fresh user_id values can’t grow the map without limit. |
One tenant exhausting its bucket never affects another tenant’s — per-user isolation is the point.
Throttle vs raise
mode="throttle" queues: acquire sleeps until the refill rate
frees a token, so bursts are smoothed rather than failed. Use it when
the caller is a background job or an internal pipeline that should
simply slow down.
mode="raise" fails fast with a typed error carrying a retry
hint — for callers who’d rather surface “slow down” (e.g. propagate a
429 to their own clients) than queue work:
from loomflow import Agent, TokenBucketRateLimiter
from loomflow.core.errors import RateLimitExceeded
agent = Agent(
"...", model="gpt-4.1-mini",
rate_limiter=TokenBucketRateLimiter(rps=2, burst=4, mode="raise"),
)
try:
result = await agent.run("hello", user_id="alice")
except RateLimitExceeded as exc:
print(exc.user_id) # "alice"
print(exc.retry_after) # e.g. 0.5 — seconds until the next token
# → return HTTP 429 with a Retry-After headerRateLimitExceeded vs RateLimitError
Two deliberately distinct errors:
RateLimitExceeded | RateLimitError | |
|---|---|---|
| Who fired | Loomflow’s own admission gate, before any provider call | The provider returned a 429 / quota-exhausted response |
| When | The tenant’s token bucket is empty and mode="raise" | Mid-call, surfacing through a model adapter |
| Handled by | Your code (propagate a 429, back off, shed load) | The retry policy automatically (it subclasses TransientModelError) |
| Carries | user_id, retry_after (limiter’s estimate) | retry_after (when the provider supplied one) |
Catch RateLimitError for upstream quota problems; catch
RateLimitExceeded for the framework’s own gate.
Budgets vs rate limits
The two govern different axes; production deployments usually want both.
| Budget caps | Rate limiting | |
|---|---|---|
| Bounds | Cumulative spend (tokens, cost, wall-clock) | Instantaneous rate (steps per second) |
| Question answered | ”How much may this tenant consume, total?" | "How fast may this tenant consume it?” |
| Failure shape | Run halts; result.interrupted = True with a budget:* reason | Step waits (throttle) or RateLimitExceeded (raise) |
| Resets | Never (cumulative) — or on your own schedule | Continuously, at rps tokens per second |
| Config | StandardBudget(BudgetConfig(...)) | TokenBucketRateLimiter(rps=, burst=) |
from datetime import timedelta
from loomflow import Agent, TokenBucketRateLimiter
from loomflow.governance.budget import BudgetConfig, StandardBudget
agent = Agent(
"...", model="gpt-4.1-mini",
budget=StandardBudget(BudgetConfig(per_user_max_cost_usd=2.0)),
rate_limiter=TokenBucketRateLimiter(rps=5, burst=10),
)Custom limiters: the RateLimiter protocol
The in-process token bucket is one implementation of a structural protocol. To plug in a distributed limiter (Redis, an API gateway), implement one method:
from loomflow import Agent
class RedisRateLimiter:
async def acquire(self, *, user_id: str | None) -> None:
# take one permit from your shared store;
# wait or raise RateLimitExceeded when none are available
...
agent = Agent("...", model="gpt-4.1-mini", rate_limiter=RedisRateLimiter())The agent loop calls acquire once before every model step, passing
the run’s user_id so per-tenant implementations can bucket
independently.
In-process state. TokenBucketRateLimiter buckets live in the
process. Behind a multi-worker deployment, each worker enforces the
limit independently — N workers means an effective rate of N × rps
per tenant. For a fleet-wide limit, implement the RateLimiter
protocol against a shared store. Evicting an idle tenant’s bucket
(LRU / TTL) resets it to a full burst.