Skip to main content

Rate Limiting & Quotas

Introduction

In L244 (Designing Your AI API) we ended on a promise: every AI endpoint needs auth and rate limits. This is that lesson. Rate limiting is how you keep an AI service affordable, fair, and abuse-resistant — and it has a twist that trips up everyone coming from normal web APIs.

Why meter at all? Because an AI call is uniquely costly to leave open:

  • Cost — you're billed per token, so an unmetered client (or a bug, or an attacker) can run up a five-figure bill overnight.
  • Fairness / capacity — one heavy user must not be able to starve everyone else of throughput.
  • Abuse / DoSunbounded consumption is OWASP LLM10 (from L239 — The OWASP LLM Top 10); mass querying can also be used to extract your model.
  • Your own ceiling — you have provider RPM/TPM caps too, so you must shape traffic to stay under them.

The AI-specific twist — and the heart of this lesson: you limit on two dimensions at once, not one. A normal API counts requests. An LLM API also counts tokens, because tokens are the real unit of cost and capacity — and the token limit usually bites first.

Scope: this is you rate-limiting your clients (server-side enforcement). It's the mirror of L234 (Fallbacks, Retries & Circuit Breakers), which was about handling being rate-limited by an upstream provider. Same headers, opposite side of the wire.

Infographic titled 'Rate Limiting & Quotas', the second lesson of the Deploying & Scaling section. The premise: AI calls are expensive, slow, and abusable, so you meter them — and the twist is that an LLM is limited on TWO axes at once. TWO BUCKETS, IN PARALLEL (left): an RPM bucket (requests/minute, token-bucket that refills at the rate and absorbs bursts) and a TPM bucket (tokens/minute — the real cost & capacity unit, usually binds first — because one 200k-token call equals fifty 4k-token calls). WHY METER AT ALL: runaway cost (per-token billing), fairness (one user can't starve others), abuse/DoS (unbounded consumption, LLM10), and your own upstream provider caps. Algorithms: token bucket (bursty), sliding window (accurate), leaky bucket (smooth). THE POLICY (right), layered & tiered: tier the limits (free vs pro vs enterprise — different RPM/TPM); rate limit is not a quota (rate = slow down now per-minute; quota = out of allowance per-day/month, tokens or dollars); enforce per global / tenant / user (three layers); pre-estimate tokens before the call or the queue deadlocks on TPM. When you throttle, return 429 + Retry-After + RateLimit-* headers so clients back off, and degrade gracefully (queue / cheaper model). Three cards: limit tokens not just requests (dual token-buckets, pre-estimate); rate limits vs quotas, tiered (token-bucket & sliding-window in Redis); be a good citizen on 429 (Retry-After, honor it when you're throttled, degrade). Section roadmap: designing your AI API, rate limiting & quotas, CI/CD for prompts & evals, versioning, scaling & reliability, observability.

The Twist — Limit Tokens, Not Just Requests

Here's the mistake that causes mysterious throttling and deadlocked queues: counting only requests. LLM providers — and you, in your own service — limit on both:

  • RPM (requests per minute) — how many calls you accept.
  • TPM (tokens per minute) — how many tokens (prompt + completion) you process.

Either can throttle independently, and TPM usually binds first, because requests are wildly uneven in size. A single call with a 200,000-token context costs as much as fifty 4,000-token calls. So a service that's nowhere near its request limit can be maxed out on tokens — and a naive request-only limiter lets those huge calls through until the token budget (and your bill) explodes.

The 2025 best practice is a dual-bucket design — two token-buckets running in parallel, one for requests and one for tokens — and a request is admitted only if both have room. And critically, pre-estimate the tokens before the call (with tiktoken / count_tokens), or you hit the worst failure mode: you queue 500 requests that all fit RPM, none fit TPM, and the queue deadlocks.

The lab makes this visceral: hold RPM fixed and drag tokens-per-request — watch the binding limit flip from RPM to TPM, and your served throughput collapse, with the request count never changing. In LLM land, tokens are the budget.

The Algorithms — Token Bucket & Friends

How do you actually enforce a limit? Four classic algorithms, each a different trade between burst-tolerance and accuracy:

  • Token bucket (the default) — a bucket holds up to N tokens and refills at a steady rate; each request spends a token. It allows short bursts (spend the whole bucket) then settles to the refill rate. Flexible and the most common choice — and note the name overload: here a "token" is a request permit, separate from LLM tokens (for TPM, the bucket literally holds LLM tokens).
  • Leaky bucket — requests drain at a constant rate regardless of arrival; smooths bursts into a steady stream. Stricter, less bursty.
  • Fixed window — count requests per clock-minute. Dead simple, but allows a double burst at the boundary (max at 11:59:59 + max at 12:00:00).
  • Sliding window — a rolling 60-second count (log or weighted counter). Accurate, fixes the boundary burst, slightly more state.

In production these run in a shared, atomic store — typically Redis — so the count is correct across all your server instances (a per-process counter is useless behind a load balancer). And the cleanest place to enforce them is the model gateway (L219 — The Model Gateway & Router), so one policy covers every service and entry point.

For most AI services: token bucket (for bursts) on both the RPM and TPM dimensions, in Redis, at the gateway. Reach for sliding-window when you need strict, boundary-accurate counting.

# Dual token-bucket: a request is admitted only if BOTH the request- and token-buckets have room.
# (Atomic in Redis in production; simplified here.)
class DualLimiter:
    def __init__(self, rpm, tpm):
        self.req = TokenBucket(capacity=rpm, refill_per_sec=rpm / 60)
        self.tok = TokenBucket(capacity=tpm, refill_per_sec=tpm / 60)

    def admit(self, est_tokens: int):                 # est_tokens = PRE-FLIGHT estimate (tiktoken/count_tokens)
        if not self.req.has(1):
            return Reject(limit="RPM", retry_after=self.req.seconds_until(1))
        if not self.tok.has(est_tokens):              # the big-context call fails HERE, long before RPM
            return Reject(limit="TPM", retry_after=self.tok.seconds_until(est_tokens))
        self.req.take(1); self.tok.take(est_tokens)   # spend from BOTH buckets
        return Admit()
    # after the call, reconcile self.tok with the ACTUAL tokens used (completion length is unknown up front)

Rate Limits vs. Quotas — and Tiering Them

Two words people use interchangeably and shouldn't, because they solve different problems:

  • A rate limit is a short-window control — "you may do X per minute." It smooths bursts and protects capacity moment-to-moment. Exceed it and you get throttled right now (a 429), but you can try again in seconds.
  • A quota is a long-window budget"you may do Y per day / month" (often in tokens or dollars). It caps total spend / allowance. Exceed it and you're done until the period resets (or you upgrade) — it's about billing and entitlement, not instantaneous capacity.

You need both: a rate limit so a burst can't overwhelm you, and a quota so a (legitimate or runaway) user can't blow the monthly budget. And you tier them by plan — free vs pro vs enterprise get different RPM/TPM and monthly token/$ allowances (exactly how the providers do it: OpenAI scales from Tier 1's ~500 RPM to Tier 5's ~10,000). Enforce across three layers so the blast radius of any one actor is bounded:

  • Global — protect the whole service / your provider ceiling.
  • Per tenant — one customer org can't starve the others.
  • Per user / key — one user (or a leaked key) can't starve their own org.

Rule of thumb: rate limit = "slow down now," quota = "you're out of allowance." Tier both, enforce at every layer, and tie the quota to your cost controls (the cost-optimization section) so limits and billing agree.

When You Throttle — the 429 Contract

Throttling isn't just blocking — it's a conversation with the client, and being a good API citizen makes the difference between a client that backs off gracefully and one that hammers you into a worse outage:

  • Return 429 Too Many Requests — the standard status for rate/quota exceeded.
  • Include Retry-After — tell the client exactly how long to wait. This is the single most useful header; a client that honors it recovers cleanly.
  • Include RateLimit-* headers — the standard RateLimit-Limit / -Remaining / -Reset (and providers' x-ratelimit-*) so well-behaved clients can self-throttle before they hit the wall.
  • Distinguish rate-limit from quota — a retryable "slow down" (429 + short Retry-After) is different from "you're out of monthly quota" (429 with an insufficient_quota-style code — don't tell them to retry in 2 seconds; tell them to upgrade).

And the mirror image, from L234 (Fallbacks, Retries & Circuit Breakers): when you are the client being throttled by a provider, honor their Retry-After instead of out-clevering it — retrying sooner isn't smart, it just counts against your limit and makes things worse.

Finally, degrade gracefully rather than hard-failing the user: queue the request, shed load, or fall back to a cheaper/smaller model that's within budget. A throttle should feel like "a moment, please," not a brick wall.

The whole contract: say 429, say when to come back (Retry-After), expose the budget (RateLimit-*), and soften the landing (degrade).

from fastapi import Request
from fastapi.responses import JSONResponse

@app.middleware("http")
async def rate_limit(request: Request, call_next):
    dec = limiter.admit_for(request.state.api_key, est_tokens=estimate_tokens(request))
    if not dec.ok:
        return JSONResponse(status_code=429, content={"error": dec.reason}, headers={
            "Retry-After": str(dec.retry_after),                 # exactly how long to wait
            "RateLimit-Limit": str(dec.limit), "RateLimit-Remaining": "0",
            "RateLimit-Reset": str(dec.reset),                    # standard headers → clients self-throttle
        })
    return await call_next(request)
# quota exceeded? → 429 with an "insufficient_quota" code: do NOT advise a short retry — advise an upgrade.

See It — The Rate-Limit Lab

Meter a service yourself and watch the two limits fight. Pick a tier, then drag the traffic and tokens-per-request — and find the ceiling:

**Meter a real AI service and find the ceiling.** Pick a **tier** (Free / Pro / Enterprise — which sets the **RPM** and **TPM** caps), then drag two sliders: **incoming traffic** (req/min) and **tokens per request**. The lab shows the two token-buckets side by side, how many requests get **served vs 429’d**, and — the key part — **which limit is binding**. The experiment that teaches the lesson: at the default (Pro, 2k tokens/req) **TPM** is already the bottleneck even though RPM has room; now drag tokens/request *down* and watch **RPM** take over, then *up* and watch TPM choke you at a fraction of the request cap. **With LLMs you must limit tokens, not just requests.**

The reveal: at a normal request size you're already TPM-bound while RPM sits half-empty — so raising your request limit would do nothing. Shrink the token size and RPM takes over; grow it and TPM chokes you at a fraction of the request cap. The binding limit moves with token size — which is exactly why request-only limiting silently fails for LLM apps.

🧪 Try It Yourself

Use the Rate-Limit Lab and what you've learned:

  1. On Pro with the default token size, which limit is binding — and why is that surprising if you only think in requests?
  2. Drag tokens/request down to the minimum. What becomes the binding limit, and why?
  3. Drag tokens/request up high. Why does served throughput collapse even though RPM has tons of headroom?
  4. Why must you pre-estimate tokens before admitting a request, not just count them after?
  5. A user hits the limit. Which HTTP status and which header do you return, and how does that differ if they're out of their monthly quota?

(1) TPM is binding while RPM is half-empty — surprising because the request count looks fine, but each request carries thousands of tokens, so the token budget runs out first. (2) RPM — tiny requests sip tokens, so the token budget is huge relative to the request cap, and the request limit binds. (3) Because each request now eats a big slice of the TPM budget, so only a handful fit per minute — TPM throttles you at a fraction of the RPM cap. (4) Because if you admit on RPM and only count tokens after, you can admit a queue that all fits RPM but busts TPM — the queue deadlocks (none can actually run). Estimate up front (tiktoken/count_tokens) and reconcile after. (5) 429 + Retry-After (plus RateLimit-*), advising a short wait. For an exhausted monthly quota it's still 429 but with an insufficient_quota-style code — don't advise a 2-second retry; advise an upgrade (the allowance won't reset for the period).

Mental-Model Corrections

  • “Limit requests per minute and you're done.” No — TPM usually binds first. Run dual buckets (RPM and TPM); a few big-context calls blow the token budget while RPM looks fine.
  • “Count tokens after the call.” Too late — you'll admit a queue that fits RPM but deadlocks on TPM. Pre-estimate tokens before admitting.
  • “Rate limit and quota are the same thing.” Rate limit = per-minute burst control ("slow down now"); quota = per-day/month budget ("out of allowance"). You need both.
  • “An in-process counter is fine.” Behind a load balancer it's wrong — use a shared atomic store (Redis) so the count is global.
  • “Just block over-limit requests.” Return 429 + Retry-After + RateLimit- headers* so clients back off precisely, and degrade gracefully (queue / cheaper model) instead of a brick wall.
  • “When a provider 429s me, retry fast.” Honor their Retry-After — retrying sooner counts against your limit and worsens the outage (L234).
  • “One global limit is enough.” Enforce global + per-tenant + per-user so a single actor (or leaked key) can't starve the pool.

Key Takeaways

  • Meter AI calls to protect cost, fairness, and against abuse (unbounded consumption, LLM10) — and to stay under your own provider caps.
  • The LLM twist — limit on two axes: RPM and TPM, with dual token-buckets, because tokens are the real cost/capacity unit and TPM usually binds first. Pre-estimate tokens before admitting, or the queue deadlocks.
  • Know the algorithms: token bucket (bursty, the default) · sliding window (accurate) · leaky bucket / fixed window — run them in a shared store (Redis) at the gateway (L219).
  • Rate limit ≠ quota: rate = per-minute "slow down now"; quota = per-day/month "out of allowance" ($/tokens). Tier both (free/pro/enterprise) and enforce per global / tenant / user.
  • Be a good citizen on a throttle: 429 + Retry-After + RateLimit-* headers, distinguish rate-vs-quota, and degrade gracefully; conversely honor a provider's Retry-After (L234). Next: CI/CD for Prompts & Evals.