Skip to content
Kien

`book` is not safe to retry

· 4 min read

1 click · 2 bookings

A booking call times out at the client and succeeds upstream. The client retries — reasonably, because from its side nothing came back — and the supplier now holds two reservations for one traveller. Why a transaction cannot undo it, why the HTTP verb has nothing to say about it, and what actually holds.

  • integrations
  • reliability
  • distributed systems
  • backend
On this page
  1. The failure
  2. Two answers that do not work
  3. What actually holds
  4. The limit, stated plainly
  5. The rule

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.