Capped Exponential Backoff Schedule
Problem Statement
A model-serving client that retries immediately on failure turns a blip into an outage: every caller hammers the recovering service at once. The fix is capped exponential backoff. Simulate the policy and report exactly how long a caller waits.
Background
The client makes attempts against a sequence of responses. After a retryable failure, and only when another attempt is left, it sleeps before trying again:
delay_i = min(base_delay * factor ** i, max_delay) # i = 0-based attempt index
The cap matters — without max_delay, doubling from 1 second reaches over an hour by the 12th retry.
Which statuses are retryable:
- 2xx -> success, stop.
- 429 (rate limited) or 5xx -> retryable; back off and try again.
- Any other 4xx -> a client bug, so retrying will never help: stop immediately as "failed", with no delay.
Stop after max_attempts attempts with outcome "exhausted". There is no sleep after the final attempt — waiting when you will never retry is pure added latency, and it is the classic off-by-one here.
If the caller makes more attempts than statuses has entries, the last status repeats.
Your Task
Implement:
def retry_schedule(statuses, base_delay, factor, max_delay, max_attempts):
Return a dict with keys "attempts" (int), "delays" (list of the sleeps actually taken, each rounded to 3 decimals), "total_wait" (their sum, rounded to 3 decimals, as a float) and "outcome" ("success", "failed" or "exhausted"), in that order.
Input Format
- statuses: non-empty list of HTTP status ints, in the order they are returned.
- base_delay, factor, max_delay: numbers.
- max_attempts: positive int.
Output Format
- The dict described above.
Sample
print(retry_schedule([503, 503, 200], 0.5, 2.0, 10.0, 5))
Output:
{'attempts': 3, 'delays': [0.5, 1.0], 'total_wait': 1.5, 'outcome': 'success'}
Two 503s cost 0.5 s and 1.0 s of backoff; the third attempt succeeds, so no further delay is paid.
Example:
print(retry_schedule([503, 503, 200], 0.5, 2.0, 10.0, 5))
{'attempts': 3, 'delays': [0.5, 1.0], 'total_wait': 1.5, 'outcome': 'success'}Attempt 0 gets a 503, a retryable status, and one attempt remains, so the client sleeps min(0.5 * 20, 10) = 0.5 s. Attempt 1 gets another 503 and sleeps min(0.5 * 21, 10) = 1.0 s. Attempt 2 returns 200, so the loop stops with outcome success and no trailing sleep — 3 attempts and 1.5 s of total waiting.
Constraints:
statusesis non-empty; if attempts outrun it, the last status repeats- 1 <= max_attempts <= 20
- Retryable: status 429 or 500-599. 2xx is success. Any other status fails immediately
- Delay before retry i (0-based attempt index) is
min(base_delay * factor ** i, max_delay) - No delay is recorded after the final attempt, nor after a success or a non-retryable failure
delaysentries andtotal_waitare floats rounded to 3 decimals
1. Background Knowledge
Exponential Backoff is a standard algorithm used in distributed systems to manage retry logic when a service is unavailable. Instead of retrying immediately, the client waits for an increasing amount of time between attempts. This prevents "thundering herd" scenarios where multiple clients overwhelm a recovering service. The delay typically grows exponentially: delayi=base×factori. However, unbounded growth is dangerous; therefore, a cap (max_delay) is applied so the delay never exceeds a reasonable threshold.
In HTTP protocols, status codes dictate behavior. 2xx codes indicate success. 429 (Too Many Requests) and 5xx (Server Errors) are generally transient and retryable. Crucially, other 4xx codes (like 400 Bad Request or 404 Not Found) indicate client-side errors. Retrying these is futile and wastes resources, so the process should terminate immediately with a "failed" outcome.
The problem requires simulating this loop. Key constraints include:
- No sleep after the final attempt: If you succeed or fail on the last allowed attempt, you do not wait.
- Status Reuse: If the statuses list is shorter than max_attempts, the last status repeats.
- Rounding: Delays and total wait times must be rounded to 3 decimal places.
2. Algorithm Approach
The solution follows a simulation pattern. You will iterate through attempts from 0 to max_attempts - 1. In each iteration, you determine the current HTTP status, decide whether to stop or continue, and if continuing, calculate the delay for the next attempt.
The core logic revolves around a while or for loop that tracks:
- Current attempt index.
- Accumulated delays.
- Current outcome state.
You must carefully distinguish between the action taken after an attempt (checking status) and the action taken before the next attempt (sleeping). The delay is associated with the transition from attempt i to i+1.
3. Step-by-Step Strategy
- Initialize Variables: Create a list for delays, a counter for attempts, and a variable for total_wait. Set outcome to None.
- Loop Through Attempts: Iterate i from 0 to max_attempts - 1.
- Get Status: Retrieve the status code. If i is within the bounds of statuses, use statuses[i]. Otherwise, use the last element of statuses.
- Check Outcome:
- If status is 2xx: Set outcome to "success", increment attempts, and break the loop. No delay is added after success.
- If status is 4xx (but not 429): Set outcome to "failed", increment attempts, and break. No delay is added after a client error.
- If status is 429 or 5xx: This is a retryable error.
- Increment attempts.
- Check if more attempts are allowed: If i < max_attempts - 1, calculate the delay for the next retry.
- Calculate raw delay: raw=base_delay×factori.
- Apply cap: delay=min(raw,max_delay).
- Round to 3 decimals and append to delays.
- Add to total_wait.
- If i == max_attempts - 1 (this is the last attempt), do not add a delay. After this iteration, the loop ends, and the outcome becomes "exhausted".
- Handle Exhaustion: If the loop completes without breaking (i.e., all max_attempts were used and the last one was retryable), set outcome to "exhausted".
- Finalize: Round total_wait to 3 decimals. Return the dictionary with attempts, delays, total_wait, and outcome.
4. Common Pitfalls
- Off-by-One in Delays: The most common error is adding a delay after the final attempt. Remember: you only sleep if you are going to try again. If you succeed or fail on the last attempt, the process ends immediately.
- Indexing for Delay Calculation: The formula uses i (0-based attempt index). Ensure you use the index of the current failed attempt to calculate the delay before the next attempt. For example, after the 1st attempt (i=0) fails, the delay is base×factor0.
- Status Code Classification: Ensure you correctly identify 429 as retryable. Many developers mistakenly treat all 4xx as non-retryable, but 429 is an exception.
- Rounding Errors: Floating-point arithmetic can introduce small errors. Apply round(value, 3) to each delay individually before summing, and round the final sum again, as specified.
- Empty Statuses List: The problem states statuses is non-empty, but ensure your logic handles the case where len(statuses) < max_attempts by repeating the last status.
5. Time & Space Complexity
- Time Complexity: O(N), where N is max_attempts. The loop runs at most max_attempts times. Each iteration performs constant-time operations (math, list access, comparisons).
- Space Complexity: O(N), where N is max_attempts. In the worst case (all attempts fail and are retryable), the delays list will store up to max_attempts - 1 elements. The output dictionary also stores this list.