AI Infra Interviews logo
Coding for Infra / 04
easyNewOpenAIBaseten

Implement retry with exponential backoff and jitter. Why is the jitter the part that matters, and what must never be retried?

Exponential backoff spaces one client's retries and does nothing for a thousand clients that failed together, which is the case that matters. The measured difference jitter makes to the peak arrival rate, the two budgets that bound the damage, and the classification of what is safe to retry at all.

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: Exponential backoff doubles the wait after each failure, which spaces out one client's attempts and does nothing about the case that actually causes outages: a thousand clients whose requests all failed at the same moment, whose deterministic schedules bring them all back at the same moment. Jitter fixes that by randomizing each delay. In a direct measurement of a thousand clients reaching their fourth retry, deterministic backoff put all one thousand into a single ten-millisecond window, and full jitter reduced the peak to twenty, a fiftyfold reduction in the instantaneous load a recovering service sees. Two budgets bound the rest: a per-request attempt cap and total deadline, and a client-wide budget limiting retries to a small fraction of total requests, so a broad failure cannot multiply the load on an already struggling backend. And none of it is safe without classifying errors: a timeout is retryable only if the operation is idempotent, since the first attempt may have succeeded.

How to approach it

Give the schedule, then immediately make the point that jitter rather than backoff is what prevents the failure mode people care about, with the measurement. Then the two budgets, since unbounded retries turn a partial failure into a total one. Then the classification, because retrying a non-idempotent operation is a correctness bug rather than a performance one. Close with the tests.

A strong answer

A typical situation: a backend has a thirty-second outage. Every client retries with textbook exponential backoff. When the backend recovers, every client's fourth retry lands within the same few milliseconds, the backend is overwhelmed by a load spike far above its steady state, and it fails again, which synchronizes the clients even more tightly for the next round.

The schedule, and what jitter changes:

import random

def backoff_delays(attempts, base=0.1, cap=10.0, rng=None):
    """Full jitter: uniform in [0, min(cap, base * 2**i)]."""
    rng = rng or random.Random()
    return [rng.uniform(0, min(cap, base * (2 ** i))) for i in range(attempts)]
deterministic schedule, base 0.1 s, cap 10 s:
  [0.1, 0.2, 0.4, 0.8, 1.6, 3.2, 6.4, 10.0]   total 22.70 s

full jitter, same parameters, one sample:
  [0.064, 0.005, 0.110, 0.179, 1.178, 2.165, 5.710, 0.869]   total 10.28 s

the measurement that matters, 1,000 clients all reaching their fourth retry:
  peak arrivals in any 10 ms window
    no jitter:   1,000     every client waits exactly 0.8 s and arrives together
    full jitter:    20     spread uniformly over the 0 to 0.8 s window
  a 50x reduction in the instantaneous load the recovering backend sees
sanity: the expected total wait halves under full jitter, since a uniform draw averages half
        the ceiling, so jitter also makes the client faster on average. It is not a trade

Retry, Backoff and Idempotency covers the variants; full jitter is the one to default to, and the alternatives (equal jitter, decorrelated jitter) trade a little spread for a tighter lower bound on the wait, which matters when the first retry should not be immediate.

The two budgets:

import time

class RetryBudget:
    """Client-wide: retries may not exceed a fraction of total requests."""
    def __init__(self, ratio=0.1, min_per_sec=1.0):
        self.ratio, self.min_per_sec = ratio, min_per_sec
        self._requests = self._retries = 0

    def record_request(self):
        self._requests += 1

    def allow_retry(self) -> bool:
        allowed = self._requests * self.ratio + self.min_per_sec
        if self._retries < allowed:
            self._retries += 1
            return True
        return False


def call_with_retry(fn, budget, max_attempts=5, deadline_s=30.0,
                    base=0.1, cap=10.0, rng=None, idempotency_key=None):
    rng = rng or random.Random()
    started = time.monotonic()
    budget.record_request()
    last = None
    for attempt in range(max_attempts):
        try:
            return fn(idempotency_key=idempotency_key)
        except Exception as exc:
            last = exc
            if not is_retryable(exc):
                raise
            if attempt == max_attempts - 1:
                break
            if not budget.allow_retry():
                raise RetriesExhausted("client retry budget spent") from exc
            delay = rng.uniform(0, min(cap, base * (2 ** attempt)))
            if time.monotonic() - started + delay > deadline_s:
                break
            time.sleep(delay)
    raise last
per-request budget    max_attempts and a total deadline
                      bounds one request's cost and stops a slow failure from consuming a
                      caller's whole timeout budget
client-wide budget    retries as a fraction of total requests, typically 10%
                      this is the one that prevents amplification: during a total outage,
                      every request fails, and without this budget every request also retries
                      max_attempts times, so the load on the recovering backend is 5x normal
                      at exactly the moment it can least absorb it
                      with a 10% budget, retries are capped at a tenth of traffic no matter
                      how bad the failure is
the amplification arithmetic, which is what the client-wide budget is for:

  during a total outage every request fails, so with max_attempts = 5 and no budget
    attempts per request  = 1 initial + 4 retries = 5
    load on the recovering backend = 5 x normal traffic
  with a 10% client-wide budget
    retries allowed       = 0.1 x requests
    total attempts        = requests x (1 + 0.1) = 1.1 x normal
    reduction             = 5 / 1.1 = 4.5x less load

  and the expected wait, deterministic against full jitter over 8 attempts
    deterministic sum     = 0.1 + 0.2 + 0.4 + 0.8 + 1.6 + 3.2 + 6.4 + 10.0 = 22.70 s
    full jitter expected  = half of each ceiling = 22.70 / 2 = 11.35 s
    one observed sample   = 10.28 s
sanity: the budget turns a 5x load spike into a 1.1x one at exactly the moment the backend can
        least absorb it, which is the difference between a backend recovering and a backend
        held down by its own clients. It is the piece most implementations lack

The classification, which is a correctness question:

ErrorRetry?Why
Connection refused, DNS failureYesThe request never reached the server, so no side effect occurred
503, 502, 429Yes, respecting any retry-after headerThe server is explicitly saying to come back
TimeoutOnly if the operation is idempotentThe request may have succeeded and the response been lost
500Only if idempotent, and cautiouslyThe server may have partially applied the operation
400, 422, malformed requestNoIt will fail identically every time
401, 403NoRetrying does not acquire permission
404NoUnless the resource is expected to appear, in which case this is polling and not retry
the timeout row is the important one
  a timeout means you do not know whether it succeeded
  retrying a non-idempotent operation after a timeout can duplicate it: a second charge, a
    second job submission, a second message
  the fix is idempotency keys: the client generates one per logical operation and sends it
    with every attempt, and the server deduplicates
  with a key, a timeout is safely retryable. Without one, it is not, and no amount of backoff
    tuning changes that

The tests:

def test_delays_within_ceiling():
    d = backoff_delays(8, base=0.1, cap=10.0, rng=random.Random(0))
    for i, x in enumerate(d):
        assert 0 <= x <= min(10.0, 0.1 * 2 ** i)

def test_non_retryable_raises_immediately(fake_clock):
    calls = []
    def fn(**kw):
        calls.append(1); raise BadRequest()
    with pytest.raises(BadRequest):
        call_with_retry(fn, RetryBudget())
    assert len(calls) == 1                      # no retries at all

def test_budget_caps_retries():
    b = RetryBudget(ratio=0.1, min_per_sec=0)
    for _ in range(100):
        b.record_request()
    allowed = sum(b.allow_retry() for _ in range(50))
    assert allowed == 10                        # 10% of 100

def test_deadline_respected(fake_clock):
    # a call with a 1 s deadline must not sleep past it
    ...

def test_idempotency_key_is_stable_across_attempts():
    seen = []
    def fn(idempotency_key=None):
        seen.append(idempotency_key); raise Timeout()
    with pytest.raises(Timeout):
        call_with_retry(fn, RetryBudget(), max_attempts=3, idempotency_key="abc")
    assert seen == ["abc", "abc", "abc"]        # same key, so the server can deduplicate

The last test is the one that catches the real bug: an implementation that generates a fresh key per attempt has an idempotency mechanism that does nothing, and every retry after a timeout creates a duplicate. The key identifies the logical operation, not the attempt.

RETRY STORM vs JITTER (toggle)
0s
1s
2s
3s
4s
5s
6s
7s
8s
9s
10s
11s
12s
13s
14s
15s
16 clients all fail at once and retry with exponential backoff. With jitter, each picks a random moment inside its backoff window, so the load spreads out.

The reversal condition: retry is the wrong tool when the failure is not transient. Retrying a request that fails because the backend is overloaded adds load to an overloaded system, which is why the client-wide budget exists and why a server should shed load explicitly with a retry-after rather than timing out silently. At the extreme, a circuit breaker replaces retry entirely: after enough consecutive failures, stop calling for a period, which protects both sides better than any backoff schedule. Backoff handles transient failures and a breaker handles sustained ones, and a client facing a sustained failure with only backoff will keep hammering at a reduced rate for as long as the failure lasts.

What interviewers probe next

  • "Why full jitter rather than adding a small random amount?" A small perturbation on a deterministic schedule still clusters. Full jitter spreads uniformly across the whole window, which is what breaks the synchronization.
  • "Where does the retry-after header fit?" It overrides the computed delay, since the server knows more than the client. Respect it, and cap it so a hostile or buggy value cannot stall the client indefinitely.
  • "How do you pick the budget ratio?" From what the backend can absorb above its steady state. Ten percent is a common default; the arithmetic is that a full outage then costs the backend 1.1 times its normal load rather than 5 times.
  • "Should the server also do something?" Yes: shed load with an explicit rejection and a retry-after rather than timing out, since a rejection is cheap and a timeout costs a held connection on both sides. Capacity and Backpressure covers that server-side half.

Common mistakes

  • Exponential backoff without jitter, which synchronizes every client that failed together.
  • No client-wide budget, so a total outage multiplies the load on the recovering backend by the attempt count.
  • Retrying timeouts on non-idempotent operations, which duplicates work.
  • Generating a fresh idempotency key per attempt, which makes the mechanism decorative.

Key takeaways

  • Jitter, not backoff, prevents the thundering herd: 1,000 clients on the fourth retry put 1,000 arrivals in one 10 ms window deterministically and 20 with full jitter.
  • Full jitter also halves the expected wait, so it is not a trade against latency.
  • Two budgets: per-request attempts and deadline, plus a client-wide cap of roughly 10% of requests that bounds amplification during a full outage.
  • Retry timeouts only with a stable idempotency key reused across every attempt of the same logical operation.
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
Retry, Backoff and IdempotencyA retry is a second request that the system did not budget for, and a thousand clients retrying at the same moment is a second outage that the first one caused. The craft is small and specific: retry only what is safe to retry, wait an exponentially growing random interval so the retries spread out, cap the total retries with a budget, and make every retried operation idempotent so a duplicate does not double-charge or double-train. This page derives why synchronized retries double the load, works the jitter arithmetic, implements the client correctly, and covers idempotency keys for the operations an AI platform exposes.
Foundational
📐 AI Systems Design
Control Plane and API Design for GPU PlatformsEvery GPU platform has a control plane, and its API is what the rest of the organization experiences as the platform. Three semantics decide whether it survives contact with a network: idempotent creation so a retried request does not launch a second job on sixty-four GPUs, cancellation modelled as intent because only the node agent can stop a running process, and cursor pagination that does not skip rows when work is created during a listing.
Advanced
💻 Coding for Infra🔒 Premium
Batching Queues and BackpressureWrite a request batcher is the coding round's version of the serving engine's scheduler: requests arrive one at a time, the GPU wants them in groups, and the batcher decides when a group is full enough to send without holding anyone too long or accepting more than it can hold. The two knobs are the maximum batch size and the maximum wait, the invariant is a bounded queue, and the follow-ups (priorities, cost-aware batching, cancellation, bounded in-flight batches) are the ideas the real engines carry. This page implements the batcher in asyncio, derives what each knob buys, and walks the follow-ups.
Advanced
💻 Coding for Infra🔒 Premium
Interval Merging and Utilization LogsGiven busy intervals per GPU, when was the whole cluster idle? What was the utilization per hour from a log of start and stop events? Which jobs overlapped? These are the interval problems of the infrastructure coding screen, and they share one tool: sort the endpoints and sweep. The sweep line turns every variant into a single pass with a counter, the sort is the only thing that costs more than linear time, and the edge cases (touching intervals, zero-length events, an unterminated start) are where candidates lose the round. This page works the standard problem and its relatives with code, tests and the complexity derivation.
UP NEXT ON YOUR JOURNEY
FEDITOR'S NOTE

Scored on jitter as the thundering-herd fix rather than a refinement, on retry budgets bounding amplification, and on classifying errors as retryable or not with idempotency as the precondition.

DISCUSSION · 0

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