AsyncIntermediate

The client retried POST /payment. Did we charge twice?

“A mobile client calls `POST /payments`, the gateway times out after 30 s, and the client retries. How do you guarantee the customer is charged once?”

What this tests

  • Whether the candidate sees that a timeout is ambiguous: the first request may or may not have succeeded
  • Idempotency keys as a concrete mechanism: who generates them, where they are stored, for how long
  • Understanding that the provider call must be covered too, not only the local database write
  • Knowing the difference between natural idempotency (PUT) and engineered idempotency (POST + key)

Answers by level

Read the beginner answer first and notice what is missing.

A timeout tells the client nothing about whether the server finished; the charge may have gone through after the gateway gave up. So the client generates an idempotency key (a UUID per payment attempt, not per click) and sends it in a header. The server stores the key with the request fingerprint and, once done, the response. A retry with the same key returns the stored response instead of charging again.

The "check then charge" version has a race: two concurrent retries both see no payment and both charge. The key must be reserved atomically — insert the key row first (unique constraint), then do the work. A second insert fails and waits for or reads the first result.

The payment provider call needs the same treatment: pass the key to the provider (Stripe-style Idempotency-Key) so a retry between our commit and their response does not double-charge on their side either.

Green flags · Red flags

Strong green flag · Walks the crash windows one by one (before insert, between insert and provider, after provider before commit) and has an answer for each.
Green flags
  • Says immediately that the timeout is ambiguous, not "the request failed"
  • Client-generated key per attempt, stored server-side with the response
  • Atomic reservation of the key (unique constraint) rather than check-then-act
  • Covers the provider call with its own idempotency key
  • Mentions TTL and per-user scoping
Red flags
  • "We just check if the payment exists before charging." (a race, not a guarantee)
  • "Kafka gives you exactly-once, so we don't need idempotency."
  • Proposes disabling client retries as the whole solution
  • Keys generated on the server per request, which makes a retry a new key

Follow-up questions

F1
Two retries with the same key arrive 5 ms apart. What does the second one see?
F2
Why scope the key per user?
F3
What if the provider has no idempotency support?

Scenario

Support sees 41 tickets in one morning for double charges. Logs show a payment-provider slowdown from 09:10 to 09:25 where calls took 35–50 s; the API gateway timeout is 30 s and the app retries POST /payments up to 3 times. The payment table has no idempotency column. Explain the mechanism and design the fix.

Learn this topic