Skip to content
echogeco
The harness in code · Code exercises

A retry policy

Implement the policy that retries only what is safe for the operation, jitters its waits from injected draws, and stops at the budget a run can afford.

codemediumExercise · 4 min · 46 of 75
On this page

A retry is a second copy of a request, and whether that is safe depends on what the first copy may already have done. The deterministic schedule over a status table is llm-dev's retry-with-backoff policy; this one adds the operation, the jitter and a budget, and it is the last mechanism on the path in why we write these five by hand. Its budget is time for one call; the fleet-wide retry budget, a share of all calls in a minute, sits a level above it, as rate limits, retries and backoff sets out.

The situation

The 40-person wholesaler this module uses in place of the agency supplies 900 cafés. Its connector makes three kinds of call to the accounting API: reads such as get_invoice; send_invoice, which accepts an idempotency key; and record_payment, an older endpoint that does not. Every call ran with one setting: retry on failure, three times, 1 second apart.

In March a record_payment for a $4,200 invoice timed out after the payment had been recorded. The retry recorded it again, and the café showed $4,200 in credit until month end. Untangling it took the bookkeeper 3 hours and you 1, at an assumed $30 and $100 an hour: $190. The same setting retried a 400 caused by a malformed date three times, and on Friday it sent 429s back into an empty bucket in lockstep.

The run that makes these calls stops after 5 minutes, so each call may spend at most 120 seconds waiting between attempts.

The question

Implement the policy so the examples below hold, with policy = {"base": 2, "cap": 60, "budget": 120}. The random draws are an argument, so the schedule is a pure function a test can fix.

def solve(outcomes, op, policy, draws):
    """outcomes: what each attempt returns, in order: "ok", "timeout", an HTTP status
    int, or (429, retry_after_s). op: "read", "keyed_write", or "write" (a write with
    no idempotency key). policy: {"base": s, "cap": s, "budget": s}, integer seconds.
    draws: the caller's random numbers as whole percentages 0..99, one per retry.
    Retry k waits c // 2 + (c - c // 2) * draws[k - 1] // 100, where
    c = min(cap, base * 2 ** (k - 1)); that wait, not c, is raised to retry_after_s
    when the server sent it.
    Returns (waits, verdict). waits: seconds before each retry, in order. verdict:
    "ok"; "failed" (a 4xx other than 429); "unknown" (a timeout or 5xx on a "write",
    never resent); or "gave_up" (the next wait would take the total past the budget,
    or the outcomes ran out while the call was still failing)."""
P = {"base": 2, "cap": 60, "budget": 120}
solve([500, 500, "ok"], "keyed_write", P, [50, 50])   -> ([1, 3], "ok")
solve(["timeout", "ok"], "write", P, [50])            -> ([], "unknown")
solve([(429, 30), "ok"], "write", P, [0])             -> ([30], "ok")
solve([503] * 10, "read", P, [99] * 9)                -> ([1, 3, 7, 15, 31, 59], "gave_up")

A strong answer · Rubric · Where answers go wrong · Solution

Write your answer first, then open