Course Content
Live Coding Interview Prep
7 sections · 50 lessons
Add rate limiting and throttling to API calls.
What you need to know
LLM providers limit you on several axes at once: requests per minute (RPM), input and output tokens per minute (TPM), and sometimes concurrent requests. Going over returns 429 errors. Client-side throttling means slowing yourself down before the provider has to reject you.
The token bucket algorithm (the "tokens" here are permission units, not LLM tokens):
- The bucket starts full, with
capacityunits. - It refills continuously at
rateunits per second, never abovecapacity. - A request needing
nunits takes them if available; otherwise it waits until the refill covers them.
It stores only two numbers — the current level and the time of the last update — so it is O(1). A sliding-window log, which stores a timestamp for every request, is exact but O(requests in the window).
1import threading, time2from collections.abc import Callable34class TokenBucket:5 """Allow bursts up to `capacity`; refill at `rate` units per second."""67 def __init__(self, rate: float, capacity: float,8 clock: Callable[[], float] = time.monotonic,9 sleep: Callable[[float], None] = time.sleep) -> None:10 if rate <= 0 or capacity <= 0:11 raise ValueError("rate and capacity must be positive")12 self.rate, self.capacity, self.clock, self.sleep = float(rate), float(capacity), clock, sleep13 self.level, self.updated = float(capacity), clock()14 self.lock = threading.Lock()1516 def acquire(self, amount: float = 1, timeout: float | None = None) -> bool:17 """Block until `amount` units are available. False if that would exceed timeout."""18 if amount > self.capacity:19 raise ValueError("request is larger than the bucket; it can never be served")20 deadline = None if timeout is None else self.clock() + timeout21 while True:22 with self.lock:23 now = self.clock()24 self.level = min(self.capacity, self.level + (now - self.updated) * self.rate)25 self.updated = now26 if self.level >= amount:27 self.level -= amount28 return True29 wait = (amount - self.level) / self.rate30 if deadline is not None and self.clock() + wait > deadline:31 return False32 self.sleep(wait)3334class ThrottledClient:35 """Requests/min, tokens/min and concurrency limits in front of a model call."""3637 def __init__(self, call_fn: Callable[[str], str], rpm: int = 50, tpm: int = 40_000,38 concurrency: int = 8) -> None:39 self.call_fn = call_fn40 self.requests = TokenBucket(rate=rpm / 60, capacity=rpm)41 self.tokens = TokenBucket(rate=tpm / 60, capacity=tpm)42 self.slots = threading.Semaphore(concurrency)4344 def call(self, prompt: str, estimated_tokens: int) -> str:45 self.requests.acquire(1)46 self.tokens.acquire(estimated_tokens)47 with self.slots:48 return self.call_fn(prompt)The tricky parts:
- Refill is computed lazily from elapsed time inside
acquire; no background thread is needed. - The sleep happens outside the lock, so other threads can still take units that are available for smaller requests.
amount > capacityraises. Without the check, a request for 50,000 tokens against a 40,000 bucket waits forever.- Estimate tokens as input plus
max_tokens, because output counts toward TPM. After the call, compare with the realusageand adjust future estimates.
Complexity: each acquire does O(1) work per loop iteration and usually returns after at most one sleep. State is two floats and a lock: O(1) space.
A real-life example
A bucket of capacity 3 refilling at 1 unit per second, with a fake clock so the waits are exact:
1now = [0.0]2waits: list[float] = []3def fake_sleep(s):4 waits.append(round(s, 3))5 now[0] += s67bucket = TokenBucket(rate=1, capacity=3, clock=lambda: now[0], sleep=fake_sleep)8for i in range(5):9 bucket.acquire(1)10 print(f"request {i + 1} sent at t={now[0]:.1f}s")11print(waits)12# request 1 sent at t=0.0s13# request 2 sent at t=0.0s14# request 3 sent at t=0.0s15# request 4 sent at t=1.0s16# request 5 sent at t=2.0s17# [1.0, 1.0]| request | level before | action | level after |
|---|---|---|---|
| 1 | 3.0 | take 1 | 2.0 |
| 2 | 2.0 | take 1 | 1.0 |
| 3 | 1.0 | take 1 | 0.0 |
| 4 | 0.0 | wait (1 − 0) / 1 = 1.0 s, refill to 1.0, take 1 | 0.0 |
| 5 | 0.0 | wait 1.0 s, take 1 | 0.0 |
The first three go out as a burst; after that the bucket enforces exactly one per second.
A company summarising 10,000 customer reviews overnight runs every call through a throttled client like this, set to about 90% of its account limits, and sees almost no 429s.
Follow-up questions to expect
- "You run 10 worker processes — does this still work?" — No; each has its own bucket, so together they send 10 times the limit. Move the bucket into Redis with an atomic Lua script, or give each worker a tenth of the limit.
- "How do you rate-limit your own users?" — The same bucket, keyed by user or API key and stored in Redis, returning 429 with
Retry-Afterinstead of sleeping. - "Is this fair between threads?" — Not strictly; a thread can be overtaken repeatedly. Put requests in a FIFO queue in front of the bucket if order matters.