Retrying a failed request is the most common resilience tactic and the easiest one to get wrong. Retry too eagerly and you amplify an outage; retry a request that was never going to succeed and you waste time; retry on a fixed schedule and every client in your fleet retries in lockstep, hammering the recovering service in synchronized waves.
Correct retrying needs three things: exponential backoff (wait longer after each failure), jitter (randomize the wait so clients desynchronize), and knowing what not to retry.
You are given three injected collaborators:
upstream.send(request) # returns {"status": int, ...}, possibly with "retry_after_ms"
clock.sleep(ms) # sleeps
rng.uniform(a, b) # random float in [a, b]
Implement RetryingClient:
__init__(self, upstream, clock, rng, max_attempts=3, base_delay_ms=100, max_delay_ms=2000)call(self, request: dict) -> dict — send the request, retrying failures per the rules below. Return {"status": <final status>, "attempts": <number of sends>}.Rules:
200 → success. Return immediately.429, 500, 502, 503, 504. Anything else (e.g. 400, 404) is a client error — return immediately without retrying.delay = min(max_delay_ms, base_delay_ms * 2 ** (attempt - 1)), then sleep rng.uniform(0, delay).retry_after_ms, sleep exactly that instead — the server told you when to come back, so jitter and backoff do not apply.Example (base_delay_ms=100, failures on attempts 1 and 2):
attempt 1 → 503, delay window = 100, sleep
uniform(0, 100)attempt 2 → 503, delay window = 200, sleepuniform(0, 200)attempt 3 → 200 → return{"status": 200, "attempts": 3}
Constraints:
max_attempts >= 1 (1 means no retrying at all)clock and rng — never time.sleep or the random module directlyOfficial solution locked
Give the problem a real attempt before peeking — revealing it affects your credit for this problem for 4 hours.