Give a Rate-Limited Request a Finite Retry Budget

Drafted and reviewed using AI assistance.

On this page

“Retry when rate limited” leaves the coding agent to invent how long the user waits, how many requests it sends and what the screen says when the budget runs out. Give it a finite schedule before asking it to add a retry library.

RFC 6585, section 4 defines HTTP 429 and allows a Retry-After response header; it does not prescribe a universal quota or identify how a server counts requests. MDN's header reference describes both a non-negative integer delay and an HTTP date. Neither form promises that the next request will succeed.

Set a small read-only contract

The fictional Palette List app reads GET /palettes?collection=coast. The operation retrieves a list; it does not create a render job, charge an account or change a saved selection. All responses, headers and timings here are original teaching fixtures. No network request or retry SDK was used.

Choose these application rules:

Budget element Decision for this exercise
Total action budget 20 elapsed seconds from the first attempt's start
Maximum attempts 3, including the first attempt
Per-attempt cap 4 seconds; reserve the full cap before starting any attempt
Retryable response Only 429 in this example
Valid delay Wait at least the server's delay and at least 1 second locally
Missing/invalid header Local fallback: 2 seconds after attempt 1, 4 after attempt 2
User leaves Stop this action's waits and future attempts; no detached retry
Other response or transport failure Stop under its own visible outcome; do not invent another retry policy

These numbers are illustrative product choices, not HTTP requirements. An actual client also needs transport timeouts and cancellation behavior that enforce its deadline; schedule arithmetic alone cannot provide them.

Use a monotonic elapsed clock for the budget. Our fictional wall clock starts at Mon, 01 Jan 2024 12:00:00 GMT and advances by exactly the same elapsed seconds. That fixed correspondence is supplied data, not a measurement of real clock accuracy.

At a 429 response received at elapsed t, calculate:

remaining = 20 - t
wait = max(1, parsed server delay)       if a valid header is usable
wait = 2 ** completed_attempts          for missing/invalid header
earliest_next_start = t + wait

Start another attempt only if:
- the action is still owned by this screen,
- fewer than 3 attempts have already started, and
- earliest_next_start + 4 <= 20.

If the gate fails, stop now. Do not shorten a valid server wait to squeeze in an earlier request. Reserving four seconds can stop the action even when the wait alone fits; that is deliberate.

Schedule A: the header contains seconds

The complete first schedule uses Retry-After: 3, then Retry-After: 4.

Step Elapsed interval Observed fixture Decision
Attempt 1 0–2 429; delay 3 Next start 5; full request cap fits by 9
Wait 2–5 Three seconds No request in flight
Attempt 2 5–7 429; delay 4 Next start 11; full cap fits by 15
Wait 7–11 Four seconds Keep waiting within the same action
Attempt 3 11–14 200; list returned Finish with received list

Actual fixture request durations total 2 + 2 + 3 = 7 seconds. Waits total 3 + 4 = 7. Elapsed time is 7 + 7 = 14, not 14 plus another seven seconds for requests. The four-second cap was reserved, not automatically consumed or counted a second time.

The integer delay starts when its response is received. Measuring the first three-second wait from t=0 would incorrectly send the next request at 3 rather than 5.

Schedule B: the header contains a date

For an HTTP date, RFC 9110 section 10.2.3 defines a date alternative to delay-seconds. Here, subtract the response-receipt UTC time from the supplied date, clamp a past date to zero, then apply the one-second local floor. Convert that result into a monotonic wait once.

Step Elapsed interval / receipt clock Header or response Calculation
Attempt 1 0–2 / 12:00:02 GMT 429; Mon, 01 Jan 2024 12:00:06 GMT Date minus receipt = 4; next start 6
Wait 2–6 Four seconds Full next cap fits by 10
Attempt 2 6–8 / 12:00:08 GMT 429; Mon, 01 Jan 2024 12:00:12 GMT Date minus receipt = 4; next start 12
Wait 8–12 Four seconds Full next cap fits by 16
Attempt 3 12–14 / 12:00:14 GMT 200; list returned Finish

Requests consume 6 seconds, waits 8, total 14. A later wall-clock adjustment must not reset the elapsed budget. If the clock needed to interpret a date is untrusted, this example stops with clock_untrusted rather than guessing a shorter delay. The teaching parser accepts the shown IMF-fixdate form; it is not a complete implementation of every HTTP-date form.

Fill the stop cases before coding the timer

All cases use the same 20-second, three-attempt, four-second-cap policy. Full response arrays and expected totals are in B62-16-schedules.json.

Case Fixture and arithmetic Required outcome
Missing header Attempt 1: 0–2, 429; fallback wait 2; attempt 2: 4–6, 200 Success at 6; 4 request seconds + 2 wait seconds
Invalid 1.5 Same timing as missing; decimal fraction is not delay-seconds Record invalid header; use local fallback; success at 6
Also invalid -1, tomorrow, or NaN Same first-retry fallback, not an unlimited loop
Wait exceeds budget At t=2, 429 asks for 30 seconds; next start would be 32 Stop at 2; no shortened wait and no second attempt
Wait fits, full attempt does not Attempt 1: 0–4, delay 6; wait 4–10; attempt 2: 10–14, delay 4; next start 18 needs cap through 22 Stop at 14; requests 8 + waits 6; do not spend the final six seconds automatically
Attempt cap 429 after each 2-second request; headers all 0; one-second waits give starts 0, 3, 6 Stop at 8 after attempt 3, despite twelve budget seconds remaining
User leaves while waiting First 429 at 2 asks for 8 seconds; user leaves at 5 One attempt, 2 request seconds + 3 waited; stop at 5; no attempt at 10
Date, untrusted clock First 429 at 2 carries a date but usable clock evidence is absent Stop at 2 as clock_untrusted; do not reinterpret the date as zero
Late scheduler wake-up A permitted timer wakes too late to reserve four seconds before deadline Stop; recompute against current elapsed time, never reuse the earlier gate

Each terminal presentation should preserve the last response separately from the action outcome. For example:

Stopped: retry budget cannot fit another request.
Last response: 429. Attempts: 1 of 3. Elapsed: 2 seconds.
The service requested a 30-second delay. No retry was sent.

The service has not been declared recovered or permanently failed. A new user action may be offered, but it starts a new explicit budget; the old action must not quietly continue in the background. Leaving during an in-flight request also does not prove the server stopped processing it. If a result later arrives, it must still belong to the current screen/action before it can update the view.

Keep read retries separate from write recovery

HTTP defines GET as a safe method, meaning its intended semantics are read-only; that is not a guarantee that every server implementation has no incidental effects. See RFC 9110 section 9.2.1. Confirm that the actual endpoint is a read operation before using this model.

The companion guide Tell a Coding Agent Whether Retry Means the Same Job or Another One uses a different contract for creation: retries retain the same logical operation ID and inputs; deliberately requesting another creation uses a new ID. A write response that is lost may hide an already-committed effect. A waiting budget cannot solve that identity and recovery problem. Do not reuse this read scheduler as a generic retry wrapper around job creation.

Copy the finite-budget request

Model the fictional Palette List GET operation using B62-16-schedules.json.
No network client, retry SDK, account access or dependency installation.

Policy: 20 elapsed seconds from the first start, at most 3 attempts total,
4-second per-attempt cap reserved before every start. Retry only 429.
Parse a non-negative integer delay or the fixture's IMF-fixdate date.
For a usable date, compare with response-receipt UTC under the fixed clock
mapping, then convert once to a monotonic wait. Untrusted clock: stop.
Apply a local minimum wait of 1 second. Missing/invalid header falls back
to 2 seconds after attempt 1 or 4 after attempt 2.

Never shorten a valid server delay to fit the deadline. Count both elapsed
request time and time actually waited, once each. Reserve is not elapsed time.
Recheck deadline, attempt count and screen ownership when a timer wakes.
User departure cancels future attempts; no detached retry or silent new budget.

Reproduce both full schedules and every stop-table branch. Return each start,
response receipt, wait, remaining budget, terminal reason and visible state.
Report last HTTP response separately from success/budget/attempt/user stop.
Reject fixture durations beyond the per-attempt cap; real timeout enforcement
is not implemented by an arithmetic model.

Do not generalize this to writes. First establish the creation-operation
identity, unchanged retry inputs and lost-response recovery contract from
the companion request-identity guide; no write retries are authorized here.
Separate model execution from any future transport/browser implementation.

The resulting schedule gives the agent a concrete answer to “wait, try again or stop.” The next implementation decision is whether the real transport and UI can enforce those same boundaries, rather than whether a library exposes a retry option.