We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Failure by Design step 11 of 18
Retry, backoff, and retryable vs terminal
A retry loop with no budget, no backoff and no distinction between retryable and terminal failures is the single most common self-inflicted outage in production software. It has a name — the retry storm. A dependency wobbles, every client retries immediately, the retries are themselves load, the dependency goes from wobbling to down, and now the retries never stop because the thing they are waiting for is being kept down by the retries.
Four properties separate a retry loop that helps from one that hurts:
- A budget. A bounded number of attempts, decided by the caller, so the worst case is knowable.
- Backoff. Wait longer after each failure. Exponential is the default because it un-synchronises clients within a few rounds.
-
Jitter. Randomise the delay so a thousand clients that failed together
do not retry together. This problem deliberately omits jitter, because a
test cannot assert on a random sequence — but in production, backoff without
jitter merely converts one thundering herd into a slightly later thundering
herd. Add
random.uniform(0, delay)and mean it. -
A retryable/terminal split. Retrying a
404is not resilience, it is four times the latency for the same failure. Only errors that might resolve themselves are worth another attempt.
And when the budget runs out, raise a distinct error chained to the last
failure. RetryExhaustedError tells the caller “the policy gave up”;
__cause__ tells them what it gave up on. Losing the last failure is losing the
only diagnostic information the whole loop produced.
What to build
def fetch_with_retry(
fetch: Callable[[str], bytes],
url: str,
attempts: int,
sleep: Callable[[float], None],
) -> bytes:
-
attempts < 1raisesValueErrorbefore callingfetcheven once. -
Attempt
nfrom1toattempts: callfetch(url)and return its result. -
On
TransientError: if this was the last attempt, raiseRetryExhaustedError(f"giving up on {url} after {attempts} attempts")from the caught error. Otherwise callsleep(BASE_DELAY * 2 ** (n - 1))and go round again. -
Any other exception —
TerminalErrorincluded — propagates immediately, with no sleep and no further attempts.
Note the ordering: there is no sleep after the final failure. Sleeping and
then giving up wastes the caller’s time for nothing. With BASE_DELAY = 0.5
and four attempts the sleeps are 0.5, 1.0, 2.0 — one fewer than the attempts.
The probe
def solve(url: str, script: Sequence[str], attempts: int) -> dict[str, object]:
Build a fetch that consumes script one entry per call — "transient"
raises TransientError(f"connection reset on call {n}"), "terminal" raises
TerminalError(f"404 on call {n}") where n is the 1-based call number, and
anything else returns f"payload for {url}".encode(). If the script runs out,
repeat its last entry. Build a sleep that records its argument instead of
sleeping.
Return exactly:
| key | value |
|---|---|
"outcome" |
"ok" or "raised" |
"body" |
the returned bytes on success, else None |
"calls" |
how many times fetch was called |
"sleeps" |
the recorded delays, in order |
"type" |
the escaping exception’s class name, else None |
"message" |
str(exc), else None |
"cause_type" |
type(exc.__cause__).__name__, else None |
Why sleep is a parameter
This is the dependency-injection lesson arriving early, and it is not
ceremony. sleep: Callable[[float], None] is why a test for a policy with a
sixteen-second worst case runs in microseconds, and why "sleeps" can be
asserted exactly instead of approximately. A retry helper that reaches for
time.sleep directly is a retry helper whose backoff schedule is untested,
which in practice means wrong.
The same trick generalises: a function that takes its clock, its sleeper and its randomness as parameters is a function you can test. One that imports them is one you can only observe.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.