AI Infra Interviews logo
Coding for Infra / 02
easy★ EssentialNewOpenAIAnthropic

Implement a token bucket rate limiter. Make it thread-safe, and explain what the two parameters actually control.

Two parameters, one lazy refill and one lock. What capacity and rate each control and why conflating them is the usual bug, the clock choice that avoids a whole class of failure, and the retry-after value that turns a rejection into something a client can act on.

Updated Sep 2026 · Grounded in real AI infrastructure interview loops and written to a senior-engineer editorial bar, with every number worked and every diagram hand-built.

TL;DR: A bucket holds up to capacity tokens and refills at rate tokens per second. A request takes a token if one is available and is rejected otherwise. The two parameters control different things and conflating them is the common bug: rate is the sustained throughput allowed over time, and capacity is how much of an unused allowance can be spent at once, which is the burst. Refill lazily, computing elapsed time on each call rather than running a background thread, because a thread per bucket does not scale to thousands of tenants and adds a scheduling dependency for no benefit. Use a monotonic clock rather than wall time, since a clock adjustment with wall time either freezes the bucket or grants unlimited tokens. Take the lock around refill and deduction together, because doing them separately is a race that over-issues under concurrency. And return a retry-after value alongside the rejection, computed as the deficit divided by the rate, so a client backs off by the right amount instead of guessing.

How to approach it

State what the two parameters mean before writing, since the interview is partly about whether you know that capacity is burst and rate is throughput. Write the lazy-refill version with the lock in the right place. Then the clock choice and the retry-after value, both of which are the difference between a toy and something usable. Close with the tests, particularly the concurrency one, which is the only way to know the lock is correct.

A strong answer

A typical situation: a limiter is configured at 100 requests per second with a capacity of 100, and a client that has been idle for an hour sends 100 requests instantly and is allowed. The team calls it a bug and reduces capacity to 10, which breaks every client that legitimately batches. Capacity and rate were doing different jobs and only one of them was the problem.

What the two parameters control:

rate      tokens added per second. This is the sustained throughput the client may use.
          Over a long window, a client cannot exceed rate x window regardless of capacity.
capacity  the maximum tokens the bucket holds. This is how much unused allowance can be
          spent at once, which is the burst.

worked
  rate 2/s, capacity 10, client idle for a minute
  bucket is full at 10, so 10 requests are allowed instantly, then 2 per second thereafter
  over 60 seconds the client can do 10 + 120 = 130 requests, not 120

what each is for
  rate protects the backend's sustained capacity
  capacity decides how forgiving you are to a client that batches, and it is the parameter
  to tune when clients complain about bursts being rejected
setting them: rate from the backend's sustained capacity divided by the number of tenants
  plus headroom; capacity from how long a client may reasonably idle and then burst, often
  one to a few seconds of rate
sanity: with capacity equal to rate x 5 seconds, a client that idles for any length of time
        can burst at most 5 seconds' worth, so the backend's worst case is 5 seconds of every
        tenant arriving at once. That product, capacity times tenant count, is the number to
        check against what the backend can absorb

The implementation:

import threading, time

class TokenBucket:
    """Thread-safe token bucket. capacity = burst size, rate = tokens per second."""

    def __init__(self, capacity: float, rate: float, now=None):
        self.capacity, self.rate = float(capacity), float(rate)
        self._tokens = float(capacity)          # start full
        self._clock = now or time.monotonic     # injectable for tests
        self._last = self._clock()
        self._lock = threading.Lock()

    def _refill(self):                          # caller holds the lock
        now = self._clock()
        elapsed = now - self._last
        if elapsed > 0:
            self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
            self._last = now

    def take(self, n: float = 1.0) -> bool:
        with self._lock:                        # refill and deduct must be atomic together
            self._refill()
            if self._tokens >= n:
                self._tokens -= n
                return True
            return False

    def retry_after(self, n: float = 1.0) -> float:
        """Seconds until n tokens will be available. 0 if they are available now."""
        with self._lock:
            self._refill()
            if self._tokens >= n:
                return 0.0
            return (n - self._tokens) / self.rate

Running it, with an injected clock so the test is deterministic:

capacity 10, rate 2/s, clock at t = 0
  burst at t=0: took 10 of 15 attempts (capacity 10)
  retry_after now: 0.50s (rate 2/s, so one token in half a second)
advance the clock to t = 3.0
  after 3 s at 2/s: took 6 more (expected 6)

concurrency check, 8 threads racing on a bucket of 1,000 tokens with rate 0
  8 threads x 500 attempts = 4,000 attempts against 1,000 tokens
  issued 1,000, which is exactly the bucket and not one more

The three decisions worth defending:

lazy refill rather than a background thread
  a thread per bucket does not scale: 10,000 tenants is 10,000 threads
  a single sweeper thread refilling all buckets adds a scheduling dependency and a period
    below which the limiter is inaccurate
  lazy refill computes exactly the tokens elapsed time has earned, at the moment of use, with
    no background work at all

monotonic clock rather than wall time
  wall time can jump: NTP corrections, daylight saving in a naive implementation, manual
    changes
  a backward jump makes elapsed negative, which the guard catches but which freezes the
    bucket until wall time catches up
  a forward jump grants tokens for time that did not pass, which is a free burst
  monotonic time never goes backward and is not adjusted, which removes the whole class

lock around refill and deduction together
  the race if they are separate: two threads both refill, both see enough tokens, both
    deduct, and the bucket goes negative or over-issues
  the lock is held for a few microseconds of arithmetic, so contention is not a concern until
    very high rates, where the answer is sharding buckets rather than removing the lock

Rate-Limiting Algorithms compares this against the alternatives, notably the leaky bucket and sliding-window counters, and Rate Limiting for an LLM API covers what changes when the unit is tokens rather than requests.

The tests:

def test_burst_then_sustained():
    t = [0.0]
    b = TokenBucket(capacity=10, rate=2, now=lambda: t[0])
    assert sum(b.take() for _ in range(15)) == 10        # burst limited by capacity
    t[0] = 3.0
    assert sum(b.take() for _ in range(10)) == 6         # 3 s x 2/s

def test_never_exceeds_capacity():
    t = [0.0]
    b = TokenBucket(capacity=10, rate=2, now=lambda: t[0])
    t[0] = 10_000.0                                       # idle for a long time
    assert sum(b.take() for _ in range(20)) == 10        # capped at capacity, not 20,000

def test_retry_after():
    t = [0.0]
    b = TokenBucket(capacity=1, rate=2, now=lambda: t[0])
    assert b.take() is True
    assert abs(b.retry_after() - 0.5) < 1e-9

def test_concurrent_never_over_issues():
    b = TokenBucket(capacity=1000, rate=0, now=lambda: 0.0)
    counts = []
    def w():
        counts.append(sum(b.take() for _ in range(500)))
    ts = [threading.Thread(target=w) for _ in range(8)]
    [t.start() for t in ts]; [t.join() for t in ts]
    assert sum(counts) == 1000

Retry, Backoff and Idempotency covers what a client should do with the retry-after value, which is the other half of a working limiter. The concurrency test is the one that matters and the one candidates skip. It works because the rate is zero, so the bucket cannot refill during the test and the total issued must equal the initial capacity exactly. Any race shows up as a number other than 1,000, deterministically enough to catch in a few runs.

TOKEN BUCKET (send requests)
10
recent results appear here
The bucket holds up to 10 tokens and refills at 2/sec. Each request spends one; an empty bucket means rejection. This is why a token bucket allows short bursts (spend the whole bucket) while capping the long-run rate at the refill speed.

The reversal condition: a single-process bucket is correct for one server and wrong for a fleet, because each server has its own bucket and a tenant's effective limit is the configured rate times the number of servers. Distributing it means either a shared store on the request path, which adds latency and a dependency, or local buckets with periodic synchronization, which is approximate with a bounded error. The API rate-limiting design works through that trade; the point here is to notice that the correct single-node implementation is not the whole answer and to say so.

What interviewers probe next

  • "How would you handle variable costs per request?" take(n) already does: an expensive request takes more tokens. That is exactly how a token-based API limiter charges by prompt size.
  • "What if a request needs more tokens than the capacity?" It can never succeed, so validate at configuration time and reject the request with a clear error rather than looping forever.
  • "Why start the bucket full?" A new client should not be penalized for having no history. Starting empty is defensible for abuse prevention and should be a deliberate choice.
  • "How do you shard this at high rates?" Partition tenants across shards, each with its own bucket, or split one tenant's rate across N buckets and route by a hash, accepting the granularity loss.

Common mistakes

  • A background refill thread, which does not scale past a few hundred buckets.
  • Wall-clock time, which grants a free burst or freezes the bucket on a clock adjustment.
  • Refilling and deducting under separate lock acquisitions, which over-issues under concurrency.
  • Rejecting without a retry-after value, so clients guess and retry storms follow.

Key takeaways

  • Rate is sustained throughput, capacity is burst; an idle client with capacity 10 and rate 2 does 130 requests in a minute rather than 120.
  • Refill lazily from elapsed time, never from a background thread.
  • Use a monotonic clock; wall time either freezes the bucket or grants free tokens on adjustment.
  • Hold one lock across refill and deduction, and test it with threads racing a zero-rate bucket where the total must equal the capacity exactly.
That one was free — and so are 10 answers per topic without an account. Signing in doubles that to 20, opens the Plus lessons in the courses, and remembers which topics you keep getting wrong.no card · Google sign-in · nothing to cancel
HOW DID IT GO?
0
READING SIGNED OUT

Signing in doubles your free answers, from 10 to 20 per topic, and the site starts remembering you: mastery per topic, bookmarks, and a next-focus recommendation. Free, no card.

Sign in free

The concepts behind this question

Ranked by how closely each one overlaps this question's topic, so the first card is the thing to read if the answer above moved too fast.

Core
💻 Coding for InfraSign in
Rate-Limiting AlgorithmsA rate limiter answers one question, 'may this request proceed now?', and the three classic algorithms answer it with different shapes of fairness and memory: the token bucket allows bursts up to a capacity and refills at a rate, the leaky bucket smooths output to a fixed rate, and sliding windows count recent requests exactly or approximately. AI platforms limit in tokens as well as requests, per tenant, across many gateways, which adds two twists: a request's cost is unknown until it finishes, and the counters must be shared. This page derives each algorithm, implements the token bucket correctly, and covers both twists.
Foundational
💻 Coding for Infra
The Practical Coding Screen PlaybookThe AI infrastructure coding screen is 45 to 60 minutes of building a small, realistic piece of systems code (a scheduler, a rate limiter, a batcher, a log parser, a cache) in the language you choose, with an interviewer who extends the problem twice and watches how you handle it. It is not a puzzle round: the score comes from working code early, tests that name the invariants, complexity said out loud, and calm follow-ups. Some companies allow an AI assistant and some ban it, and each policy changes what is measured. This page gives the minute-by-minute plan, the habits that score, and the mistakes that end the screen.
Foundational
🖧 Hardware & Cluster Build-Out
Burn-In and Acceptance TestingNew hardware fails early or it fails late, and burn-in exists to move the early failures before the cluster is handed over rather than after. A proper acceptance test runs every layer under sustained load for days, compares every node against its siblings rather than against a specification, and produces a signed number the buyer and the vendor both agree on. The comparison is the important part: identical hardware running identical work should produce identical numbers, and the outliers are the finding.
Foundational
🧮 Napkin Math & Capacity
KV Cache SizingThe KV cache is the memory that decides how many users a serving replica can hold and how long their context can be. Its size per token comes from four numbers in the model's config file (layers, KV heads, head dimension, bytes per element) and one formula; multiplied by context and concurrency it is the number every capacity plan is built on. This page derives it, works it for four models including an MLA one, and shows the two places candidates get it wrong by a factor of eight.
UP NEXT ON YOUR JOURNEY
FEDITOR'S NOTE

Scored on lazy refill rather than a background thread, on the monotonic clock, on capacity versus rate controlling different things, and on returning a retry-after value.

DISCUSSION · 0

No comments yet — be the first to share your approach.