Debug a retry loop that never backs off
Problem statement
During an outage of the artifact store, every CI runner hammered it with retries at a fixed one-second rhythm, and some jobs then "succeeded" with an empty artifact. The retry helper below is the suspect. Review it, list every bug, and write a fixed version.
retry.py (broken)
import time def fetch_with_retry(fetch, retries=3, delay=1): for attempt in range(retries): try: return fetch() except Exception as e: print(f" attempt {attempt} failed: {e}") time.sleep(delay)To test it without waiting, a harness replaces time.sleep with a function that records each delay, and calls the helper with a fake fetch that replays a scripted list of outcomes:
- always down: four
ConnectionErrors,retries=4 - bad request: four
ValueError("400 bad token")s,retries=4(a permanent error) - recovers: one
TimeoutError, then"OK"
Symptom (actual output of the broken version):
attempt 0 failed: refused attempt 1 failed: refused attempt 2 failed: refused attempt 3 failed: refusedalways down: returned None, slept [1, 1, 1, 1] attempt 0 failed: 400 bad token attempt 1 failed: 400 bad token attempt 2 failed: 400 bad token attempt 3 failed: 400 bad tokenbad request: returned None, slept [1, 1, 1, 1] attempt 0 failed: slowrecovers: returned 'OK', slept [1]Examples
Example 1
Input: The same harness with the fixed `fetch_with_retry`
Output: attempt 1/4 failed: refused
attempt 2/4 failed: refused
attempt 3/4 failed: refused
attempt 4/4 failed: refused
always down: raised RetryError(gave up after 4 attempts: refused), slept [1, 2, 4]
bad request: raised ValueError(400 bad token), slept []
attempt 1/4 failed: slow
recovers: returned 'OK', slept [1]
Explanation: Delays double, there is no sleep after the final attempt, the permanent error is raised at once, and giving up is an exception instead of a silent None.
Hints
Approach
Optimal
The broken helper has five problems:
- No backoff.
delayis never changed, so every runner retries every second:slept [1, 1, 1, 1]. During an outage this keeps load on the failing service constant instead of backing off. Fix:delay *= 2after each sleep, capped bymax_delay. - Sleeps after the last attempt. The loop sleeps even when there is no next attempt, wasting time before failing. Fix:
breakwhenattempt == retries. - Silent failure. When all attempts fail, the loop ends and the function falls off the end, returning
None. The caller treatsNoneas a result, which is how jobs "succeeded" with an empty artifact. Fix: raise aRetryErrorthat names the attempt count and chains the last exception withfrom last, so the traceback shows the real cause. - Retries everything.
except Exceptionretries aValueErrorfor a bad token four times, and would also retry programming errors likeKeyError. Fix: catch only transient errors (ConnectionError,TimeoutError); anything else propagates immediately, asbad requestnow shows withslept []. - Misleading log. Attempts are numbered from 0 and there is no total, so
attempt 3 faileddoes not say whether more tries are coming. Fix: count from 1 and print3/4.
The harness is part of the answer: replacing time.sleep with slept.append lets the test assert on the exact delays without waiting for them, and the scripted flaky function makes every scenario reproducible.
Adding random jitter to each delay is the usual next step; it is left out here so the delays in the output are exact.
O(retries)Space O(1)import time class RetryError(Exception): pass TRANSIENT = (ConnectionError, TimeoutError) def fetch_with_retry(fetch, retries=3, delay=1, max_delay=30): last = None for attempt in range(1, retries + 1): try: return fetch() except TRANSIENT as e: # only retry errors that can go away last = e print(f" attempt {attempt}/{retries} failed: {e}") if attempt == retries: break # no pointless sleep after the last try time.sleep(min(delay, max_delay)) delay *= 2 # the missing backoff raise RetryError(f"gave up after {retries} attempts: {last}") from last # ---- test harness: fake sleep and a scripted flaky call ----import time slept = []time.sleep = slept.append # record delays instead of waiting def flaky(outcomes): outcomes = list(outcomes) def call(): o = outcomes.pop(0) if isinstance(o, Exception): raise o return o return call def run(name, fn): slept.clear() try: result = fn() print(f"{name}: returned {result!r}, slept {slept}") except Exception as e: print(f"{name}: raised {type(e).__name__}({e}), slept {slept}") run("always down", lambda: fetch_with_retry(flaky([ConnectionError("refused")] * 4), retries=4))run("bad request", lambda: fetch_with_retry(flaky([ValueError("400 bad token")] * 4), retries=4))run("recovers", lambda: fetch_with_retry(flaky([TimeoutError("slow"), "OK"]), retries=4))Follow-up questions
- Add full jitter and explain why it helps when hundreds of runners fail at once.
- Make the set of retryable exceptions a parameter, and retry on HTTP 503 but not 404.
- Add an overall deadline so the helper never runs longer than 60 seconds in total.
Frequently asked questions
Retry helpers are copy-pasted all over automation code, and bad ones make outages worse: synchronized fixed-interval retries from many clients keep a recovering service overloaded. Reviewers are expected to spot the missing backoff, the swallowed failure and the over-broad except.
The caller cannot tell "gave up" from "got an empty result". Code like data = fetch_with_retry(get_artifact) then writes None or an empty file and reports success. Failing loudly is always better than returning a value that looks valid.
It keeps the original exception as __cause__, so the traceback shows both the RetryError and the underlying ConnectionError with its own stack. Without it, you lose the details of what actually failed.