The failure
The flow is login → search → search-by-id → prebook → book → cancel, and book
is the step that costs money. It goes out to the supplier, the supplier creates
a reservation, and somewhere in the seconds after that our client stops
waiting — a read timeout, a proxy that gave up, a pod rescheduled mid-request.
The reservation exists. The response does not.
The client then does the only reasonable thing available to it: it tries again. It still holds the composite offer ID it sent the first time — cross-call state rides inside that ID, so nothing was lost on our side — and sends the same request. The supplier receives a well-formed booking for an available rate and does its job. One traveller, one room, two confirmations, and two cancellation policies that are not necessarily both free.
Nobody wrote a bug. Our side timed out correctly, the client retried correctly, the supplier booked correctly. The defect lives in the gap between three correct behaviours.
Two answers that do not work
The answer offered first in most reviews is a transaction: wrap book, and
roll back if no response arrives. It does not survive the first sentence. The
reservation is not in our database — it is in someone else's system, put there
by a call we already made. A transaction is a promise our storage engine makes
about our storage engine, and the expensive half of book happened outside it.
Rolling back leaves clean local state and a live reservation nobody on our side
knows about — strictly worse, because now the duplicate is invisible to us
too.
The second answer is the verb — make the endpoint idempotent, the way PUT is
supposed to be. But the method describes what our handler does when called
twice. It says nothing about what the supplier does, and the supplier is where
the reservation lives. If the handler's job is to place an order in a foreign
system, two invocations are two orders unless something specific stops the
second.
Both answers make the same mistake. They treat the boundary of the process as the boundary of the effect.
What actually holds
Where the supplier accepts an idempotency key — some call it a client reference, some a transaction ID — we generate ours once per booking attempt and send that same value on every retry of it. Their deduplication does the work: the second call returns the first reservation instead of creating another. Worth asking for during certification, while a partner is still answering questions.
Where the supplier has no such field — and across roughly 90 integrations,
plenty were designed before anyone asked — the defence is local, and it has to
be written before the call goes out. A row for this attempt: our reference,
the offer, state in-flight, a timestamp. Then the supplier call. Then the row
moves to confirmed or failed with the supplier's answer attached. A retry
arriving while a row is still in-flight is not a new booking; it is a second
question about one already in progress, and the correct response is to find out
what became of the first rather than start a second.
The ordering carries the whole thing. Write the row after the call, and the timeout window — the exact window this bug lives in — is the one with no record in it.
The limit, stated plainly
Finding out is reconciliation, and reconciliation needs somewhere to ask: a
retrieve or a booking lookup on the supplier side, keyed by something we sent.
Not every supplier has one. On the flight side retrieve is part of the contract
we expect; elsewhere it is per supplier, and where it is absent an in-flight
row tells you a duplicate may exist and nothing more. What follows is a human
reading the provider logs — every call is stored with its request and response —
and cancelling one reservation by hand.
How often this fired in production, I do not know. Nothing counted timeout-then-retry attempts or duplicate reservations as a rate; the cases I know about arrived as support tickets, which is a sample of the ones somebody noticed. The missing figure is part of the point — the failure has no natural alarm.
The rule
The design-review checklist on this site's decisions page asks it
out loud: is book idempotent under client retry? It stays a standing
question rather than a one-time fix because the answer differs per supplier.
An operation whose side effect leaves your process is not made safe by anything inside your process. Not the transaction, not the verb, not a try/catch. Either the far side deduplicates, or you keep a record it cannot see and reconcile against it — and where you can do neither, say so before go-live rather than learn it from a traveller holding two confirmations.