Idempotency and request ids
How GAVEL can repeat a money call after a timeout or a crash without charging twice.
Pressing a lift button twice does not call two lifts. The second press carries the same intent as the first, and the system treats them as one. PayPal offers the same thing for money calls: a header named PayPal-Request-Id. If GAVEL sends the same id twice, PayPal does the work once and answers the repeat with the first result.
That only helps if GAVEL sends the same id on every try of one call, and a different id for a genuinely different call. This page shows how the id is made and where it is reused.
Request ids: one call, one id, however many tries
Scroll the figure sideways, or open it full size.
4. Only a changed call changes the id. A reauthorization uses the next generation number; the capture, void and authorize ids stay at generation 0.
packages/payments/src/requestId.ts, transport.ts, saga/capture.ts, saga/reauth.ts and fake/idempotency.ts. The sandbox nightly run 42200708552 recorded spec R5: a second capture send answered 200 with the same capture. How long PayPal remembers a request id has not been measured here. The crash variants with a forgotten id table are the simulated scenario S6.Text version of this diagram
Derivation: auction id, operation (order, authorize, capture, void or reauth), hold id, amount in cents and generation are put in a JSON array, hashed with SHA-256, written in base32, cut to 40 characters and prefixed gv1-. The example gv1-N6WFTAIWCCILC45XMRS4ZE3E535MCVQCA5WCJ4JG comes from made-up parts.
- Intent saved: the capture intent, with the id, is saved in the journal before the PayPal call.
- Retry: the transport makes up to 4 attempts with a 10 second timeout each and full-jitter backoff from 250 ms to 4 s, all with the same PayPal-Request-Id.
- Restart: the saved intent is reused, so the id repeats; the truth is read first and a capture already there is adopted.
- A different call: another amount, hold or operation gives another id; a reauthorization uses generation + 1.
PayPal sandbox: a second capture send answered 200 with the same capture (R5). Fake PayPal: the same operation, id and request id with equal parameters replays the stored result; different parameters conflict, which the saga treats as an invariant fault and sends to review. If the id table is forgotten the retry would run again, so the saga reads the truth first.
How the id is made
The id is a fingerprint of the call, not a random number. Five parts go into a JSON array, which is hashed with SHA-256 and written in base32. The first 40 characters get the prefix gv1-, so every id is 44 characters long.
| Part | Example | Why it is in the fingerprint |
|---|---|---|
auctionId | auc1 | A call in one auction never matches another auction |
op | capture | An authorize and a capture of one hold get different ids |
holdId | hold-1 | Each bidder's hold has its own calls |
amountCents | 35000 | A call for another amount is another call |
generation | 0 | The only part that changes on purpose, see below |
Hashing a JSON array, not a joined string, means no choice of ids can produce the same input as another tuple.
Where each operation uses it
Prop
Type
Same id on every try
Save the intent, then call
The capture saves its intent in the journal first: operation, hold, amount and request id. If the process dies after that, the intent survives.
Retry inside one request
The HTTP transport retries network errors and the statuses 429, 500, 502, 503 and 504, up to four attempts of ten seconds each. The wait between attempts is random between zero and the smaller of four seconds and 250 ms times two to the power of the retry number. A Retry-After header is honored up to 30 seconds. Every attempt carries the same PayPal-Request-Id.
Restart and resume
A resume finds the saved intent and sends it again, with the same id. Before it sends anything it reads the truth of the hold at PayPal: a capture that is already there is adopted and no second call is made.
A new id only for a new call
If the parameters change, the id changes. A reauthorization raises the generation by one. The generation never changes on a retry or a resume.
Why the id is not the only guard
An id only helps while PayPal remembers it
If PayPal had forgotten the id, a repeated capture could run again. GAVEL therefore reads the truth before every capture and adopts what is already there. The simulation scenario S6 forgets the fake's id table at each crash point and still ends with one charge. How long the real PayPal remembers a request id has not been measured in this project.
If the same id is ever sent with different parameters, the fake PayPal answers with a conflict. The saga treats that as an invariant fault, a bug or a configuration problem, and stops in FAILED_NEEDS_REVIEW instead of trying another bidder.
What is proven
More on how failures are sorted: retries and unknown outcomes and crash recovery.
Where this lives in the code
- packages/payments/src/requestId.tsbuildRequestId and the five parts
- packages/payments/src/transport.tsretries, backoff, the same header on every attempt
- packages/payments/src/saga/capture.tswrite-ahead intent, reuse on resume
- packages/payments/src/saga/reauth.tsgeneration + 1
- packages/payments/src/holds/service.tsorder, authorize and void ids
- packages/payments/src/fake/idempotency.tsreplay and conflict in the fake
- packages/payments/test/saga/crash.test.tscrash boundaries
Last updated on