Skip to content
intermediate Phase 17 · Serving Patterns for AI Applications

Rate Limiting and Retry Policy

Protect provider quotas with client-side limits, backoff, and circuit breakers.

50m
0 problems
Topic Progress 0%

The 429 Retry Storm

It is 2:07 PM. Your service retries a failed provider call. So does every other replica. All of them retry at the same instant, all of them fail again, all of them retry in lockstep. The provider, already struggling, now faces a synchronized wall of retries on top of normal traffic. By 2:15 the provider's recovery has failed, your quota is burned for the hour, and the incident review has a new term in it: retry storm.

The mechanics are simple. A provider degrades. Your clients receive 429s and 500s. Your code retries — correctly, because transient failures are real. But every retry is another request to the service that is already failing, and when thousands of clients retry on the same schedule, the retries themselves become the load.

No jitter (thundering herd):
t=0s   ████████████████████  all clients fail together
t=2s   ████████████████████  all clients retry together -> fail
t=4s   ████████████████████  all clients retry together -> fail

With full jitter:
t=0s   ████████████████████  all clients fail together
t=0-2s  █ ██ █ ███ █ ██ █ ██   retries spread across the window
t=2-4s   █ ███ █ ██ █ ██ █     provider recovers under low load

Two disciplines prevent this. Rate limiting caps how fast you send, so your own traffic never becomes the problem. Backoff with jitter staggers retries, so a recovering provider faces a trickle instead of a wall. A third mechanism — the circuit breaker — stops you calling a provider that has already failed. This topic builds all three.

Client-Side Token Bucket

Providers enforce quotas with rate limits: N requests per minute, sometimes per day, sometimes per token of throughput. Exceed the quota and every request fails with 429 until the window resets. The provider's limit is a fact about their infrastructure; your client-side limiter exists so you never discover it by tripping it.

The token bucket is the standard algorithm. A bucket holds tokens at a maximum capacity. Tokens refill at a fixed rate. Each request consumes one token; if the bucket is empty, the request waits or is rejected.

import threading
import time

class TokenBucket:
    def __init__(self, rate: float, capacity: int):
        self.rate = rate            # tokens refilled per second
        self.capacity = capacity   # maximum burst size
        self.tokens = capacity
        self.updated = time.monotonic()
        self.lock = threading.Lock()

    def acquire(self) -> bool:
        with self.lock:
            now = time.monotonic()
            elapsed = now - self.updated
            # refill from real elapsed time, not a fixed sleep — this keeps
            # the average rate exact even when requests arrive irregularly
            self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
            self.updated = now
            if self.tokens >= 1:
                self.tokens -= 1
                return True
            return False
bucket = TokenBucket(rate=2.0, capacity=4)   # 2 req/s sustained, burst of 4
[bucket.acquire() for _ in range(6)]
--> [True, True, True, True, False, False]

The burst capacity is the parameter people get wrong. Set it to 1 and you can never handle a user double-clicking. Set it to the provider's per-minute quota and one burst can consume the entire window. A burst of a few seconds' worth of traffic — 4 to 10 — absorbs normal spikes without threatening the quota.

The bucket also composes with queues. When acquire() returns False, you have a choice: reject the request with a 429 of your own, or enqueue it for later. For user-facing traffic, a short queue with a visible 'working on it' state beats an error page — the same trade as queue-based processing, applied at the client.

Exponential Backoff with Full Jitter

Rate limiting prevents you from tripping the provider's quota. Backoff handles the failures that happen anyway — and the detail that separates a polite client from a destructive one is the Retry-After header.

When a provider returns 429, it often tells you exactly when to try again: Retry-After: 12 means twelve seconds. Guessing an interval when the provider has volunteered one is how you end up retrying into a penalty window. The hierarchy is: honor Retry-After when present, fall back to exponential backoff with jitter when it is not.

import random
import time

class RateLimitExceeded(Exception):
    pass

def call_with_retry(request: dict, max_attempts: int = 4):
    for attempt in range(max_attempts):
        resp = client.post(URL, json=request)
        if resp.status_code == 429:
            retry_after = resp.headers.get('Retry-After')
            if retry_after:
                time.sleep(float(retry_after))   # provider told us exactly — obey
                continue
            time.sleep(full_jitter(attempt))    # no header: guess, but spread out
            continue
        if resp.status_code >= 500:
            time.sleep(full_jitter(attempt))
            continue
        return resp
    raise RateLimitExceeded()

def full_jitter(attempt: int) -> float:
    ceiling = min(2 ** attempt, 30)    # cap the window so attempt 20 is not a day long
    return random.uniform(0, ceiling)  # randomize the WHOLE sleep, not just a delta
attempt 1 -> 429, Retry-After: 2  -> sleep exactly 2s
attempt 2 -> 500, no header       -> sleep random in [0, 2)
attempt 3 -> 200                  -> return response

The jitter choice matters more than the base. Fixed backoff (sleep(2 ** attempt)) keeps every client synchronized — the same lockstep that caused the storm in the previous chapter. 'Equal jitter' (sleep(2**attempt/2 + random(0, 2**attempt/2)) helps but still correlates. Full jitter — sleeping a random fraction of the whole window — maximally desynchronizes clients while preserving the exponential average, and AWS's research on backoff found it minimizes total completion time under contention.

One more rule: never retry non-transient errors. A 400 (bad request), 401 (bad key), or 404 will fail identically on every attempt. Retry only 429 and 5xx — the failures that can plausibly succeed later.

Circuit Breakers and Shedding Load

Rate limiting and backoff assume the provider eventually answers. A circuit breaker assumes it might not — and stops you from finding out the expensive way.

Without a breaker, every request during an outage waits for a timeout or a 429, ties up a worker, and burns quota on retries. With a breaker, consecutive failures trip the circuit open and subsequent requests fail fast — no provider call, no timeout, no retry budget spent. The service sheds load instead of drowning in it.

State Behavior Transition
Closed requests flow normally 5 consecutive failures -> Open
Open fail fast, no provider calls after 30s cooldown -> Half-Open
Half-Open allow exactly one probe request success -> Closed; failure -> Open

The half-open state is the clever part. An open circuit that never closes means a provider recovering at minute 31 stays unused forever. The probe tests the water with a single request: success closes the circuit and traffic resumes; failure re-opens it for another cooldown.

import time

class CircuitBreaker:
    def __init__(self, failures_to_open: int = 5, cooldown: float = 30.0):
        self.failures_to_open = failures_to_open
        self.cooldown = cooldown
        self.failures = 0
        self.opened_at = None

    def call(self, fn):
        if self.opened_at:
            if time.monotonic() - self.opened_at < self.cooldown:
                raise ProviderDown()          # fail fast: no timeout, no retry budget
            self.opened_at = None             # cooldown elapsed -> half-open probe
        try:
            result = fn()
            self.failures = 0                 # any success heals the circuit
            return result
        except ProviderError:
            self.failures += 1
            if self.failures >= self.failures_to_open:
                self.opened_at = time.monotonic()
            raise

The breaker composes with everything else in this topic: the token bucket caps your rate, backoff with jitter spaces out retries, and the breaker stops the retries from mattering when the provider is genuinely down. Together they are the client-side half of resilience; the server-side half — what your service does when every provider is down — is model fallback and degradation.

Quiz

1. What causes a retry storm after a provider outage?

Question 1 options

2. Why honor the Retry-After header instead of using your own backoff schedule?

Question 2 options

3. What does full jitter do that fixed exponential backoff does not?

Question 3 options

4. What is the purpose of the half-open state in a circuit breaker?

Question 4 options

Flashcards

Question

What problem does a client-side token bucket solve?

Answer

It caps your request rate before traffic reaches the provider, so your own spikes never trip the provider's quota and burn your rate-limit window.

Question

Why refill the token bucket from elapsed time rather than on a timer?

Answer

Refilling from real elapsed time keeps the average rate exact under irregular request patterns; a fixed sleep over- or under-counts tokens when arrivals are bursty.

Question

What is the Retry-After hierarchy for handling 429s?

Answer

Honor the provider's Retry-After header when present — it knows exactly when the window resets. Fall back to exponential backoff with jitter only when the header is absent.

Question

Why full jitter instead of fixed backoff?

Answer

Fixed backoff keeps all clients synchronized, so they retry in lockstep forever. Full jitter randomizes the whole sleep within the exponential window, desynchronizing clients and protecting a recovering provider.

Question

What does an open circuit breaker do?

Answer

Fails fast without calling the provider — no timeout wait, no retry budget spent. After a cooldown it sends one half-open probe: success closes the circuit, failure re-opens it.

Revision Notes

Key Takeaways

  • 1. A client-side token bucket caps your rate before traffic reaches the provider, so your own spikes never trip their quota
  • 2. Honor Retry-After when the provider sends it; fall back to exponential backoff with full jitter only when they do not
  • 3. Full jitter — randomizing the entire sleep — desynchronizes clients that failed together and prevents retry storms
  • 4. A circuit breaker fails fast when the provider is genuinely down, and the half-open probe lets a recovered provider back in automatically

Interview Tips

  • • Tell the retry storm story: synchronized retries turning a provider degradation into a full outage — then present jitter as the fix with the mechanism, not just the name
  • • Expect 'when should you NOT retry?' — 4xx errors fail identically every time; only 429 and 5xx are worth retrying
  • • Connect the three mechanisms: bucket caps rate, backoff spaces retries, breaker stops calling a dead provider — and point to fallback as the next layer when every provider is down

Cheat Sheet

Rate Limiting and Retry Policy Cheat Sheet

Token bucket:

def acquire(self):
    now = time.monotonic()
    self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
    self.updated = now
    if self.tokens >= 1:
        self.tokens -= 1
        return True
    return False

Retry hierarchy: 429 with Retry-After -> sleep exactly that · 429/5xx without -> full jitter · 4xx -> never retry

def full_jitter(attempt):
    return random.uniform(0, min(2 ** attempt, 30))

Circuit breaker states:

State Behavior Transition
Closed requests flow 5 consecutive failures -> Open
Open fail fast 30s cooldown -> Half-Open
Half-Open one probe request success -> Closed, failure -> Open

Rules:

  • burst capacity: seconds worth of traffic (4-10), never the whole quota window
  • retry only 429 and 5xx — 400/401/404 fail identically every time
  • when the bucket is empty: reject with your own 429, or enqueue — do not block the caller silently

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading