◐ ai-teacher

🐍 Backend with Python · Chapter 14

Rate limiting, timeouts and backpressure

Every service has a maximum speed; the only choice is whether it says "no" early and politely, or says nothing at all while it falls over.

Study this lesson free in the app →

The basics

Three tools keep a service alive when demand outruns capacity. They all do the same thing: refuse or abandon work early, so the work you do accept finishes.

The problem it solves: without limits, one noisy client, one slow partner or one traffic spike fills every worker, connection and queue — and then every request fails, not just the excess.

The mental model: a service is a pipe with a fixed width. You can't widen it at 9 PM; you can only control what you let in, how long you wait, and how much you let pile up.

Explained

The Tirupati darshan queue

The temple can let roughly a fixed number of pilgrims past the sanctum each hour. That's capacity, and no amount of shouting changes it.

The temple works because it says no at the gate, not inside the sanctum.

Which tool goes where

| Tool | Protects | Where it lives | On trigger | | --- | --- | --- | --- | | Rate limit | you, from one client | edge: gateway or middleware, keyed by API key / user / IP | 429 + Retry-After | | Timeout | you, from a slow dependency | every outbound call: DB, HTTP, Redis | fail fast, free the worker | | Concurrency limit | a dependency, from you | semaphore around the call | wait briefly, or shed | | Bounded queue | your memory and latency | between accept and process | block the producer, or 503 |

Algorithms:

In-process limiters only see one pod. With 20 pods behind a load balancer, put the counter in Redis or at the gateway.

A token bucket, refill and spend

Run: 10 allowed, 2 rejected, then True False at t = 0.2, and after a 10 s idle exactly 10 allowed out of 20 — the cap stops idle time from banking unlimited burst.

429 with Retry-After, per API key

@app.middleware("http")
async def rate_limit(request: Request, call_next):
    key = request.headers.get("x-api-key", "anon")
    b = buckets.setdefault(key, TokenBucket(rate=2, capacity=3, clock=clock))
    if not b.allow():
        wait = math.ceil(b.retry_after())
        return JSONResponse({"detail": "rate limit exceeded"}, status_code=429,
                            headers={"Retry-After": str(wait)})
    return await call_next(request)

With TestClient and a frozen clock:

[200, 200, 200, 429, 429]                       # partner-a bursts 5
429 1 {'detail': 'rate limit exceeded'}         # Retry-After: 1 (seconds, whole number)
partner-b: 200                                  # separate bucket per key
after 1 s: 200                                  # 2 tokens refilled

The limiter runs before the handler, so a rejected call costs microseconds, not a DB query. Retry-After is in whole seconds — round up, or clients come back too early and get another 429.

The sliding window's edge

lim = SlidingWindowLog(limit=3, window=60, clock=clock)   # 3 OTPs per 60 s
# allow(): drop timestamps <= now - 60, then allow if fewer than 3 remain

for at in [0, 10, 59, 59.5, 60, 70, 71]:
    t[0] = at
    print(at, lim.allow("9876543210"))

Timeouts on everything that leaves the process

# top-level await: run in `python -m asyncio`, Jupyter, or inside an async def
# a local "partner" that accepts the connection and then never answers
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, read=0.3)) as client:
    await client.get(f"http://127.0.0.1:{port}/rate")
# → httpx: ReadTimeout after 0.4s

await asyncio.wait_for(asyncio.sleep(10), timeout=0.5)
# → wait_for: TimeoutError after 0.5s   (the inner coroutine is cancelled)

httpx.Timeout(5.0, read=0.3) sets connect / write / pool to 5 s and read to 0.3 s — four separate clocks. httpx's default is 5 s; timeout=None turns it off, which you should never do for a partner call.

asyncio.wait_for caps any awaitable — a DB call, a lock, a queue get. On Python 3.11+ it raises the built-in TimeoutError.

Pick the number from the budget, not from hope. If your endpoint promises p99 < 800 ms and calls a partner whose p99 is 250 ms, a 300 ms read timeout with one retry fits; a 30 s timeout means a hung partner holds your worker 37× longer than the whole request was allowed to take. Better still, pass a deadline down: each hop gets what's left, not a fresh 30 s.

Backpressure: bounded queues and semaphores

q = asyncio.Queue(maxsize=100)
for i in range(250):                     # a burst of 250 webhook events
    try:
        q.put_nowait(i)
    except asyncio.QueueFull:
        shed += 1                        # answer 503 now, not 30 s later
# accepted 100 shed 150 queue size 100

Or make the producer wait — await q.put(x) on a maxsize=2 queue paced a fast producer to a slow consumer:

['put 0', 'put 1', 'got 0', 'put 2', 'got 1', 'put 3', 'got 2', 'put 4', 'got 3', 'got 4']

Protect a dependency with a semaphore:

partner_limit = asyncio.Semaphore(10)    # partner allows 10 concurrent calls
async def call_partner(i):
    async with partner_limit:
        ...
# 100 calls, peak concurrency 10 took 0.21s

An unbounded queue is not a buffer, it's a delay line: when input outruns output it grows until every item in it is older than the client's timeout, and you spend 100% of your capacity on answers nobody is waiting for.

Thursday 14 March 2024, 14:00 — the retry storm

A payments partner slows to 4 s at 14:00. Our checkout calls an order service, which calls a wallet service, which calls the partner. Each layer retries 3 times, no backoff.

1 layers x 3 retries -> 4 calls at the bottom per user request
2 layers x 3 retries -> 16 calls at the bottom per user request
3 layers x 3 retries -> 64 calls at the bottom per user request

So at 300 checkouts/s the partner sees up to 19,200 calls/s. It recovers at 14:05, takes the flood, and falls over again at 14:06. Every retry fired after the same fixed 1 s, so the waves were synchronised.

The fixes we shipped:

  1. Retry at one layer only — the one closest to the failing call.
  2. Exponential backoff with full jitter: sleep = random() × min(cap, base × 2^attempt) → [0.013, 0.169, 0.306, 0.204, 0.793, 1.438] s — spread out, not in lockstep.
  3. A retry budget: retries ≤ 10% of requests. With the partner fully down, 1,000 failing requests got 100 retries, not 3,000.
  4. Only retry what's retryable — timeouts, 503, 429 (after Retry-After). Never a 400, and never a non-idempotent POST without an idempotency key (be-010).

Words to know

Token bucketRate limiter where tokens refill at a fixed rate up to a capacity and each request spends one; permits bursts up to the capacity.
Retry-AfterHTTP response header (seconds or a date) telling the client when to try again, sent with 429 or 503.
BackpressureA signal from a full consumer back to its producer — blocking or rejecting — so work doesn't pile up unbounded.
Load sheddingDeliberately rejecting some requests quickly when over capacity so the accepted ones still succeed.
DeadlineAn absolute time budget for a whole request, passed to each downstream call so it uses only what remains.
Retry stormRetries from many clients or layers multiplying load on a struggling dependency, keeping it down.
Retry budgetA cap on retries as a fraction of requests, so retries can't multiply load during an outage.

In two minutes

Test yourself

1. What does a token bucket with rate 5/s and capacity 10 do with 12 requests arriving at the same instant on a full bucket?

  1. Allows 5, rejects 7
  2. Allows 10, rejects 2
  3. Allows all 12, then blocks for 2 s
  4. Rejects all 12 — bursts are never allowed
Show the answer

Allows 10, rejects 2 — Capacity is the burst allowance; the rate only governs refill. Our run: 10 allowed, 2 rejected, retry after 0.2 s.

2. Why should a 429 response carry a Retry-After header?

  1. Browsers refuse 429 without it
  2. It resets the client's token bucket
  3. It is required for HTTPS
  4. It tells well-behaved clients exactly when capacity returns, so they wait instead of hammering you with more rejected calls
Show the answer

It tells well-behaved clients exactly when capacity returns, so they wait instead of hammering you with more rejected calls — Without it, clients guess — usually immediately and in a loop. Round it up to whole seconds or they come back early and get another 429.

3. Why do retries need exponential backoff with jitter?

  1. Without them, clients retry in synchronised waves that hit the recovering dependency all at once
  2. Jitter makes each retry faster
  3. It's required by HTTP/2
  4. Backoff guarantees the retry succeeds
Show the answer

Without them, clients retry in synchronised waves that hit the recovering dependency all at once — Fixed delays line every failed client up for the same instant. Full jitter spread our delays to 0.013–1.438 s. Backoff doesn't guarantee success; it limits the damage.

4. How does a bounded asyncio.Queue create backpressure?

  1. It drops the oldest item silently
  2. It speeds up the consumer
  3. When full, `await put()` suspends the producer until there's room, and `put_nowait()` raises QueueFull so you can shed
  4. It writes overflow to disk
Show the answer

When full, `await put()` suspends the producer until there's room, and `put_nowait()` raises QueueFull so you can shed — Our maxsize=100 queue accepted 100 of 250 and shed 150; a maxsize=2 queue paced the producer to the consumer. asyncio.Queue never drops items on its own.

5. Token bucket vs sliding-window log — which statement is right?

  1. Both store a timestamp per request
  2. The token bucket allows bursts up to capacity with O(1) state; the sliding log enforces an exact count in any window but stores one timestamp per request
  3. The sliding log allows 2× the limit at window edges
  4. The token bucket is exact; the sliding log approximates
Show the answer

The token bucket allows bursts up to capacity with O(1) state; the sliding log enforces an exact count in any window but stores one timestamp per request — The 2× edge problem belongs to the fixed window, not the sliding log. Pick the bucket by default, the log when the limit must be exact (OTPs, logins).

6. What happens when three layers of services each retry a failing call 3 times?

  1. 3 calls reach the bottom
  2. 9 calls reach the bottom
  3. 12 calls reach the bottom
  4. Up to 64 calls reach the bottom for one user request
Show the answer

Up to 64 calls reach the bottom for one user request — Each layer makes 1 + 3 = 4 attempts, and each attempt triggers the layer below: 4³ = 64. Retry at one layer only.

There are 10 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →