Retries and unknown outcomes
What the saga does when a capture is refused, rate limited, times out or never gets an answer.
A capture call can end in four very different ways, and charging the wrong person depends on telling them apart.
Taken
PayPal answers COMPLETED. Money moved. The settlement goes on.
Refused
PayPal says no and says why (declined, expired). Nothing moved. Safe to try the runner-up, once the truth confirms it.
Not run
HTTP 429. PayPal refused to run the request. Nothing moved and nothing is in doubt. Wait and repeat the same call.
Unknown
A timeout, a network failure or a 5xx. The capture may or may not have happened. Never charge the runner-up on a maybe.
Pick an answer in the diagram to follow its path.
A capture call fails: what the saga does next
Scroll the figure sideways, or open it full size.
Pick an answer, or click a row, to follow its path. Pick it again to show all rows.
packages/payments/src/saga/capture.ts (afterFailure), errorMap.ts, transport.ts and saga/unknown.ts; PR #1938 and PR #1947 are on main. The behaviour is tested against the fake PayPal (test/saga/rateLimited.test.ts) and in the simulated storm lots of S8. PayPal's real rate limits are UNVERIFIED (G1 measurement), and so is the exact honor-period issue name (G3).Text version of this diagram
The transport retries network errors and the statuses 429, 500, 502, 503 and 504, up to 4 attempts of 10 seconds each, with the same request id; a Retry-After wait is honored up to 30 seconds. What it returns or throws is sorted by the capture phase:
- 2xx COMPLETED: 2xx, status COMPLETED, amount as asked leads to CAPTURED. capture_ok is saved, then the void phase releases the others.
- Kill switch: kill switch engaged nothing is sent leads to PAUSED. The unknown clock stands still while the switch is on. A capture already at PayPal is still adopted.
- No token: no OAuth token (401 after refresh, auth unavailable): nothing sent leads to STALLED: auth_unavailable. No clock starts; the intent stays, nothing was sent. One operator alert after 10 minutes of stall.
- 429: HTTP 429, after the transport tried 4 times: PayPal refused it leads to STALLED: rate_limited (PR #1938). No unknown clock starts, so a restart cannot age it into review. Next pass reads the truth first, then repeats the same request id.
- Timeout or 5xx: timeout, network error, 5xx, 408, 409, 425: outcome unknown leads to UNKNOWN CLOCK: 10 minutes. Next pass: truth first, then the same request id again. After 10 minutes: FAILED_NEEDS_REVIEW. Never the next bidder.
- Pending at PayPal: PayPal took the capture but lists it PENDING leads to WAITS FOR PAYPAL: up to 3 days. COMPLETED: adopted. Declined: the runner-up. Alert after 10 minutes; review only after 3 days.
- 400 or id conflict: 400, request id reused with other parameters, ceiling out of range leads to FAILED_NEEDS_REVIEW (invariant). A bug or a configuration fault: the next bidder would hit it too.
- Declined or honor period: refusal that names this hold: declined, expired, voided, already captured, 404, or 422 past the honor period (PR #1947) leads to TRUTH FIRST, THEN THE RUNNER-UP. A capture already there is adopted; hold gone or still open: definite fail, then the runner-up at its own price. Truth unreadable: waits. A different capture: review.
- 403 or unknown refusal: any other refusal: 403, other 4xx, a 422 issue the code does not know leads to UNKNOWN CLOCK: 10 minutes. May repeat for every bidder, so never the next bidder: unknown clock starts, review after 10 minutes.
The order of the checks
The transport retries first
Network errors and the statuses 429, 500, 502, 503 and 504 are retried inside one request: at most four attempts of ten seconds each, full-jitter backoff from 250 ms up to four seconds, and a Retry-After header is honored up to 30 seconds. Every attempt has the same request id (see idempotency). When the attempts run out the last failure is raised as a typed error, never a raw network error.
The error map labels the answer
Each status gets a kind and an outcome, either definite (PayPal said so) or unknown (the effect cannot be known from here).
The capture phase decides
In this order: kill switch, no token, 429, other unknown outcomes, invariant faults, then refusals that name the hold, and finally every other refusal.
How statuses are labelled
| Answer | Kind | Outcome | Why |
|---|---|---|---|
| 401 | auth expired or auth failed | definite | The request was not accepted at all |
| 404 | not found | definite | PayPal does not know the resource |
| 400 | invalid request | definite | A bug or a bad configuration |
| 429 | rate limited | unknown in the map, handled as not run by the saga | See below |
| 5xx | server error | unknown | The capture may have been taken |
| 408, 409, 425 | rejected | unknown | A timeout or an in-flight conflict proves nothing |
| 422 with a known issue | declined, expired, already voided, already captured | definite | Names this hold |
| 422 with an unknown issue, other 4xx | rejected | definite | Not known to name this hold |
| network failure, timeout | network | unknown | No answer exists |
The 422 issue names other than PREVIOUSLY_CAPTURED are UNVERIFIED on the sandbox (gate G1). The saga is built so a wrong name is harmless: it always reads the truth before it falls back.
What changed on 7 Nov 2026
Before this change a capture that kept getting 429 was treated like a timeout: the ten minute unknown clock started, and a restart after a long outage could push the settlement into review. A 429 means PayPal declined to run the request, so now it starts no clock. The capture waits as rate_limited, keeps its intent, and the next pass reads the truth first and repeats the same request id. A 429 that follows an earlier ambiguous attempt leaves that attempt's clock as it was.
Issue: https://github.com/harshbhx/gavel-auction/issues/1937. Fix: https://github.com/harshbhx/gavel-auction/pull/1938.
A capture that PayPal refuses because the authorization is older than its honor period names this hold, not the merchant. After a 429 stall such a refusal, and a declined or expired one, is classified like a first refusal: the truth is read and the settlement falls back to the runner-up at once, instead of waiting for the ten minute deadline. The honor-period issue name AUTHORIZATION_NOT_HONORED_AFTER_HONOR_PERIOD is UNVERIFIED (gate G3).
Issue: https://github.com/harshbhx/gavel-auction/issues/1946. Fix: https://github.com/harshbhx/gavel-auction/pull/1947.
The soak test now fails if any settlement reaches review because of a 429. https://github.com/harshbhx/gavel-auction/pull/1945
The clocks
Prop
Type
While the kill switch is engaged the unknown clock stands still; the time it held the clock is not counted afterwards.
Why never the next bidder on a maybe
If the first capture did happen and the runner-up is charged as well, two people paid for one lot. That is the one mistake the saga must not make, so every maybe waits or goes to a person.
What is proven
Where this lives in the code
- packages/payments/src/transport.tsretry statuses, backoff, Retry-After, timeouts
- packages/payments/src/errorMap.tsstatus to kind and outcome
- packages/payments/src/saga/capture.tsafterFailure(): the order of the checks
- packages/payments/src/saga/unknown.tsthe 10 minute and 3 day deadlines
- packages/payments/src/saga/fallback.tsrunner-up fallback facts
- packages/payments/test/saga/rateLimited.test.tsthe 429 and honor-period cases
Last updated on