Retry an HTTP call with exponential backoff
Problem statement
A deploy script calls an internal API that sometimes drops connections or returns 503 during a rollout. Write get_with_retry(client, path, ...) that calls client.get(path) and retries the failures that are worth retrying, waiting longer after each one.
Rules:
- Retry on
ConnectionError,TimeoutError, and HTTP status429,500,502,503,504. - Do not retry any other
4xx. A404or403will not fix itself; raiseHTTPErrorright away. - Any status below
400is success: return the response. - Wait
base * 2 ** (attempt - 1)seconds after a failed attempt (0.5, 1, 2, 4 ...), but never more thancap. - Do not sleep after the last attempt. After
max_attemptsfailures raiseRuntimeErrornaming the path, the number of attempts and the last reason. - Take
sleepas a parameter, so tests can pass a fake and run instantly.
There is no network in the exercise. A FakeClient replays a scripted list of outcomes, where each entry is either an exception to raise or a Response(status). Print one line per attempt. Run three scenarios:
ConnectionError("reset by peer"), then503, then502, then200.- A single
404. - Six
503s withmax_attempts=6andcap=3.0.
Examples
Example 1
Input: The three scenarios above, with `base=0.5` and a fake `sleep` that returns immediately.
Output: flaky then ok:
attempt 1: ConnectionError: reset by peer, sleeping 0.50s
attempt 2: HTTP 503, sleeping 1.00s
attempt 3: HTTP 502, sleeping 2.00s
attempt 4: 200 ok
permanent error:
attempt 1: 404, not retryable
raised HTTP 404
always down, max_attempts=6, cap=3:
attempt 1: HTTP 503, sleeping 0.50s
attempt 2: HTTP 503, sleeping 1.00s
attempt 3: HTTP 503, sleeping 2.00s
attempt 4: HTTP 503, sleeping 3.00s
attempt 5: HTTP 503, sleeping 3.00s
attempt 6: HTTP 503, giving up
raised /v1/deploys: failed after 6 attempts (HTTP 503)
Explanation: Attempt 4 in the last scenario would wait 4 s, but cap=3.0 limits it. There are five sleeps for six attempts.
Hints
Approach
Optimal
Loop over attempt from 1 to max_attempts. Each pass ends in exactly one of four ways:
- Success (
status < 400): return the response. - Permanent failure (a 4xx that is not 429): raise
HTTPErrorimmediately. Retrying a 404 five times only makes the script slower and adds load. - Retryable failure on the last attempt: raise
RuntimeErrorwith the path, the attempt count and the last reason, so the log says why it failed, not just that it failed. - Retryable failure otherwise: compute
min(cap, base * 2 ** (attempt - 1)), log it, and sleep.
The try/except/else keeps the exception path and the response path separate. Only ConnectionError and TimeoutError are caught. A bare except Exception would also retry bugs like a TypeError in your own code.
sleep is a parameter, so the demo passes a fake that does nothing and the whole run takes milliseconds. A test can also pass a function that records the delays and assert on them.
Jitter. If 200 hosts all fail at the same moment and all retry at exactly 0.5 s, 1 s, 2 s, they hit the recovering service in synchronized waves. Passing jitter=random.Random() switches to "full jitter": each delay becomes a random value between 0 and the computed delay, which spreads the retries out. The demo leaves it off so the output is predictable.
O(max_attempts)Space O(1)import time class Response: def __init__(self, status, body=None): self.status = status self.body = body class HTTPError(Exception): def __init__(self, status): super().__init__(f"HTTP {status}") self.status = status class FakeClient: # Replays a scripted list of outcomes: an exception to raise or a Response. def __init__(self, script): self.script = list(script) def get(self, path): outcome = self.script.pop(0) if isinstance(outcome, Exception): raise outcome return outcome RETRYABLE_STATUS = {429, 500, 502, 503, 504}RETRYABLE_EXC = (ConnectionError, TimeoutError) def get_with_retry(client, path, max_attempts=5, base=0.5, cap=8.0, sleep=time.sleep, jitter=None, log=print): for attempt in range(1, max_attempts + 1): try: resp = client.get(path) except RETRYABLE_EXC as e: reason = f"{type(e).__name__}: {e}" else: if resp.status < 400: log(f" attempt {attempt}: {resp.status} ok") return resp if resp.status not in RETRYABLE_STATUS: log(f" attempt {attempt}: {resp.status}, not retryable") raise HTTPError(resp.status) reason = f"HTTP {resp.status}" if attempt == max_attempts: log(f" attempt {attempt}: {reason}, giving up") raise RuntimeError(f"{path}: failed after {max_attempts} attempts ({reason})") delay = min(cap, base * 2 ** (attempt - 1)) if jitter is not None: # "full jitter": uniform in [0, delay] delay = jitter.uniform(0, delay) log(f" attempt {attempt}: {reason}, sleeping {delay:.2f}s") sleep(delay) def fake_sleep(seconds): pass # the demo does not really wait print("flaky then ok:")client = FakeClient([ConnectionError("reset by peer"), Response(503), Response(502), Response(200, "[]")])get_with_retry(client, "/v1/deploys", sleep=fake_sleep) print("permanent error:")try: get_with_retry(FakeClient([Response(404)]), "/v1/deploys/999", sleep=fake_sleep)except HTTPError as e: print(" raised", e) print("always down, max_attempts=6, cap=3:")try: get_with_retry(FakeClient([Response(503)] * 6), "/v1/deploys", max_attempts=6, cap=3.0, sleep=fake_sleep)except RuntimeError as e: print(" raised", e)Follow-up questions
- A 429 response includes a
Retry-Afterheader. Use it instead of your own delay. - Add an overall deadline (for example 30 s total) on top of
max_attempts. - Turn this into a decorator,
@retry(max_attempts=5), that works on any function.
Frequently asked questions
Every automation script that talks to a cloud API, a registry or an internal service will see transient errors. Retrying without backoff turns a brief outage into a flood of requests from every client at once, which can keep the service down longer. Interviewers look for the retryable/non-retryable split, the cap, and jitter.
Only if the operation is idempotent, or the API supports an idempotency key you send with every attempt. Otherwise a request that succeeded on the server but timed out on the way back gets applied twice, for example creating two load balancers. GETs, PUTs and DELETEs are normally safe to retry.
Without a cap, attempt 10 with base 0.5 waits 256 s. A cap keeps the worst case predictable, and together with max_attempts it bounds the total time the script can spend on one call.