Skip to content

Volume ITechnology

9.6.3 — Writes That Survive Reality: Idempotency, ETags, Versioning ​

A failed read is annoying. You try again and get your data. A failed write is a genuinely hard problem, because trying again might be the right move or might charge the customer twice, and from where the client is standing those two situations look identical.

Three specific things go wrong with writes, and each has a mechanism that fixes it.

The network times out after you sent a payment, and now nobody knows whether the charge happened. The fix is an idempotency key. Two people edit the same record at the same time, and the second save quietly erases the first. The fix is an ETag with a conditional request. And your API has to change while forty clients you cannot update are still calling it — 3.6.1's permanence problem again, this time with a version number attached. The fix is versioning discipline, most of which turns out to be restraint rather than mechanism.

All three are small. All three get asked about in backend interviews, because they are exactly the things that separate an API that demonstrates well from one that is allowed to move money.

1. The retry problem, and idempotency by design ​

Someone taps "Pay ₹499" on a phone. The app sends POST /payments. Thirty seconds pass and no response arrives.

Sit inside the client for a second, because this is the whole problem. Two completely different things could have happened. The request may never have reached the server, in which case nothing was charged and retrying is not just safe but necessary. Or the request arrived, the card was charged, and the response was lost coming back — in which case retrying charges the customer a second time. From the client's side these are indistinguishable. There is no observation it can make, no timeout it can tune, that tells them apart. 2.7 calls this the lost-reply problem, and it has no client-side solution.

So the client is stuck between two bad options: do not retry and possibly lose a real order, or retry and possibly double-charge someone. Every network hop in the world manufactures this dilemma. A phone on a train manufactures it several times an hour.

The server has to be the one that fixes it, and there are two ways.

The free way: design operations that do not care how many times they run. This is where 9.6.1's verb contract stops being pedantry and starts being money. PUT /carts/current/items/7 {"quantity": 3} says set the quantity to 3. Send that twice, five times, fifty times, and the cart still holds three. Now compare POST /carts/current/items/7/increment, which says add one. Send it twice on a flaky connection and the customer bought two.

The two endpoints do the same job from the user's point of view. Only one of them survives a retry. Whenever the domain lets you express an operation as "make it be X" instead of "change it by X", take it — you get retry safety for free and write no code at all. ⚑Idempotency keys. [EQ-968]

Where it doesn't (create/charge/send), add the machinery — the idempotency key:

http
POST /payments
Idempotency-Key: 3f2a8c1e-order-9142-attempt        # client-generated, per logical operation
Content-Type: application/json

{"orderId": "9142", "amount": 49900, "currency": "INR"}

Server-side mechanics, precisely — each step exists for a failure mode:

typescript
async function handlePayment(req: Request): Promise<Response> {
  const key = req.headers["idempotency-key"];
  if (!key) return Response.badRequest("Idempotency-Key required");   // (1)

  const prior = await idemStore.claim(key, requestHash(req.body));    // (2) ATOMIC
  if (prior.state === "completed") return prior.response;             // (3) replay
  if (prior.state === "in-flight") return Response.conflict(          // (4) concurrent dup
    { code: "REQUEST_IN_FLIGHT", retryAfter: 2 });
  if (prior.state === "mismatch") return Response.unprocessable(      // (5) same key,
    { code: "IDEMPOTENCY_KEY_REUSED" });                              //     different body

  const result = await chargeAndRecord(req.body);                     // (6) the real work
  await idemStore.complete(key, result);                              // (7) store response
  return result;
}

Walk the numbered lines, because each one exists because of a specific way this breaks.

(1) The key is required, not optional. An optional safety mechanism is one that the client which most needs it will forget.

(2) This claim has to be atomic — one indivisible operation that either succeeds or finds the key already taken. If you instead write the obvious two lines, "look up the key, and if it is absent create it", you have built 2.4's check-then-act race. Two retries arriving in the same millisecond both look, both find nothing, both proceed, both charge. The correct implementations are an insert against a unique constraint, where the database rejects the second one, or Redis SET NX, which sets the value only if it does not already exist. In both cases exactly one caller wins and the other is told so.

(3) If this key already completed, do not run anything — send back the response you stored the first time, byte for byte, including its 201 and its body. This is the line that dissolves the client's dilemma entirely. The client can now retry as often as it likes; the effect happens once and the client always sees the same answer as if it had happened cleanly the first time. That is the actual promise, and it is worth being precise about it: the operation may execute once but be observed many times, and every observation agrees.

(4) If the key is claimed but not yet finished, the first attempt is still running right now. Do not start a second one. Tell the client to come back in a moment.

(5) The stored hash of the request body catches a different bug: the same key sent with different content. That is never a legitimate retry — it means the client is reusing keys, perhaps generating one per app session instead of one per payment. Reject it loudly, because the alternative is replaying the wrong stored response and telling the client a payment succeeded that never existed.

Three operational details separate a working implementation from a demo.

Scope keys per caller. Store them keyed by (apiKey, idempotencyKey), not the key alone. Two different customers can generate the same key, and if they share a namespace one customer's payment can return the other customer's response.

Give keys an expiry. Twenty-four to forty-eight hours covers any realistic retry, including a client that queued the request offline overnight. Keeping them forever means a table that only grows, and eventually the lookup that guards every payment is the slowest query you have.

Complete the key in the same transaction as the effect. Look at the gap between line (6) and line (7): the charge is recorded, then the key is marked complete. If the process dies between those two lines, the money moved and no record says so, and the next retry charges again. Doing both in one transaction closes that window.

When the effect lives in another company's system — an actual payment provider — you cannot put their work in your transaction. What you do instead is pass an idempotency key to them as well, and store it, so their deduplication protects the hop you do not control. That is the important idea to carry forward: retry safety only works if every hop in the chain participates. Part 10.4 treats this as a distributed-systems problem in its own right.

2. ETags and conditional requests: versions per resource ​

An ETag (entity tag) is an opaque version identifier the server attaches to a resource's current state: ⚑ETags. [EQ-969]

http
GET /articles/42
→ 200, ETag: "v17-8c1d"          # hash of content, or a version counter — server's choice

One mechanism, two superpowers, chosen by which conditional header the client sends: ⚑Conditional requests. [EQ-970]

Cache validation — If-None-Match (the read side): "give me the body only if it changed":

http
GET /articles/42
If-None-Match: "v17-8c1d"
→ 304 Not Modified               # empty body — bandwidth ≈ 0, client reuses its copy

This is the revalidation half of HTTP caching ([5.6] owns the full story with Cache-Control); for APIs it means list-heavy mobile clients skip re-downloading unchanged data.

Optimistic concurrency — If-Match (the write side): this is the cure for the lost update problem, which deserves to be spelled out slowly because it is invisible when it happens.

Two admins open the same product page at 10:00. Both get ETag: "v17". One fixes the description; the other corrects the price. At 10:05 the first saves, and the product becomes v18 with the new description. At 10:06 the second saves — and their form still contains the old description, because they loaded it before the change. Their save overwrites the whole record. The first admin's work is gone.

Notice what did not happen. No error was raised. Nothing was logged as a failure. Both admins saw a success message. The bug surfaces days later as "I definitely fixed that description, it must not have saved". With preconditions, none of this happens: ⚑Optimistic concurrency. [EQ-971]

http
PUT /products/9
If-Match: "v17-8c1d"             # "apply ONLY if it's still the version I edited"
→ 200, ETag: "v18-2ab0"          # admin A wins; version advances

PUT /products/9                   # admin B, still holding v17
If-Match: "v17-8c1d"
→ 412 Precondition Failed         # [!code highlight] — conflict SURFACED, not swallowed

The second admin now gets a 412 instead of a false success, and their client can do the useful thing: fetch the current version, show what changed while they were editing, and let them merge or confirm. The collision is still a collision — you have not prevented two people from editing at once — but it has turned into a moment in the interface instead of a hole in the data.

On the server this is one SQL statement:

sql
UPDATE products
SET description = $3, version = version + 1
WHERE id = $1 AND version = $2;   -- $2 is the version from the ETag

If that reports one row updated, the caller's assumption held and you return 200 with the new ETag. If it reports zero rows updated, someone else changed the row first, and you return 412. The check and the write happen in the same statement, so no other transaction can slip between them — the same conditional-write trick that fixed the oversold-inventory problem in 9.5.2. At the database layer this technique is called optimistic locking ([7.4]); the ETag is simply its face on the HTTP side.

If a resource is genuinely contested and you want to force clients to participate, answer 428 Precondition Required when a write arrives with no If-Match at all. Otherwise the careless client keeps its silent-overwrite behaviour and only the careful one is protected.

Why is it called optimistic? Because nothing is locked while a human thinks. The pessimistic alternative takes a lock when the admin opens the edit form and holds it until they save — which works right up until someone opens the form and goes to lunch, and the record stays locked for an hour ([7.4] covers when that trade is still worth making). The optimistic version assumes collisions are rare, lets everyone work freely, and detects the collision at the moment of writing. When conflicts really are rare, redoing the occasional lost edit costs far less than managing locks across a fleet of servers.

One detail on tags themselves: an ETag written as W/"v17" is a weak tag, meaning "this is equivalent enough for caching purposes" — the content may differ in ways that do not matter, like whitespace. That is fine for If-None-Match. It is not fine for concurrency, where you need to know the state is exactly what you read. Preconditions want strong tags.

3. Versioning: evolving under consumers you can't update ​

Before choosing how to spell a version, learn the discipline that means you rarely need one. It is called additive evolution, and it is just this: only ever add.

These changes do not break a client that was written sensibly. Adding a new field to a response. Adding a new optional field to a request, with the server filling in a default when it is absent. Adding a whole new endpoint. Accepting input you previously rejected. And adding a new value to an enum — but only if you said on day one that the enum was open, meaning clients must handle values they have never seen. Write that sentence into your documentation before you have any users. It costs nothing then and is impossible to add later, because by then clients exist whose code crashes on an unknown status.

These changes break clients, every time: removing a field, renaming a field, changing a field's type or format, rejecting input you used to accept, changing the status code or error code for a situation clients already handle, and adding a required request field.

The discipline has two halves, and it only works when both sides hold. The server only adds. The client reads tolerantly — it ignores response fields it does not recognise instead of throwing, it does not assume it has seen every enum value, and it never depends on the order of keys. Put both halves in the documentation. Together they push the versioning question years into the future, and either side alone is not enough: a strict client parser that throws on unknown fields makes even a safe addition a breaking change. ⚑API versioning strategies. [EQ-966]

When a break is unavoidable, the strategies, honestly compared:

StrategyLooks likeVisible in logsBest for
In the URI/v2/ordersYesMost APIs
In a headerApi-Version: 2026-07-01Only if you log itLarge partner APIs
Query parameter?version=2YesInternal tools

Version in the URI unless you have a reason not to. It shows up in every log line, every bookmark, and every support ticket where someone pastes a curl command. It routes trivially, so /v1 and /v2 can be entirely separate deployments during a migration. Purists object that the same underlying thing now has two addresses, which is philosophically true and has never once cost anyone a weekend.

Version in a header when you want the URLs to stay clean, and accept the cost: the version is now invisible unless someone thinks to look, and any cache in front of you must be told to vary its stored copies by that header or it will serve v1 responses to v2 clients.

There is a stronger variant of the header approach worth knowing by name. Instead of v2, the version is a date, and each customer is pinned to the date they signed up. Their integration keeps behaving exactly as it did on the day they built it, forever, and they upgrade when they choose to. This is the best experience a partner can have, and it is expensive: you are committing to a layer inside your server that transforms today's responses back into the shape each old date expects, and to keeping those transforms correct for years. Choose it when you have thousands of integrations and a team to maintain it.

Three rules apply no matter which spelling you pick.

Version the contract, not the code. /v1 and /v2 must be two thin translation layers over one set of business logic. The moment you fork the logic, every bug is fixed twice and eventually only once. This is the adapter shape from 9.6.1's modernization answer.

Make deprecation an operation, not an announcement. Send Deprecation and Sunset headers with real dates on old responses. Measure who is still calling, per customer, so you know exactly whom to contact. Then before shutting anything off, run a brown-out: make v1 fail deliberately for fifteen minutes at a scheduled time. The teams who ignored six emails will discover their dependency, and they will discover it during business hours rather than at your shutdown deadline.

Version coarsely. One version for the whole API, or one date. Versioning each endpoint separately sounds tidy and produces a matrix nobody can reason about, where a client is on v3 of orders and v1 of payments and no one can say which combinations were ever tested.

4. The expert lens ​

All three mechanisms do the same thing: they turn a silent assumption into something the server can check.

Look at what each header is really saying. An idempotency key says "I believe this operation has not run yet." If-Match says "I believe the state I read is still current." A version says "I believe the contract I wrote my code against is still in force." In every case the client was already assuming that. The assumption was simply invisible, so the server had no way to notice when it was false.

That is the shift worth taking away. Without these headers the server is guessing what the client meant; with them the server is verifying what the client declared. And every failure this page fixes — the double charge, the erased edit, the integration that broke on a Tuesday — is the same kind of bug: a real assumption nobody wrote down. When a class of API bug keeps recurring, that is the question to ask. Which unstated assumption needs to become a header?

Retry safety belongs to the whole chain, not to one endpoint. You can make POST /payments perfectly idempotent and still double-charge people, if the payment provider you call behind it is not. All you did was move the duplicate one hop further away, where it is harder to see. An adapter around a downstream service (9.4.7) must pass the guarantee along, not quietly absorb it.

So trace any path that touches money from end to end and check every hop: the client's key, your deduplication store, the key you send to the payment provider, the reference the bank uses. Safety exists only where every link participates, and a single link without it is the only link that matters. Part 10.4 treats this as a distributed-systems primitive. In an interview, drawing that chain before anyone asks you to is one of the strongest signals you can give.

Versioning is mostly restraint and only slightly mechanism. The recurring team failure is spending three weeks arguing about URI versus header, and then breaking every client on a Tuesday by renaming a field because it read better.

Rank the three things by what they actually save you. Additive evolution plus tolerant clients prevents most versions from ever being needed. A real deprecation practice — headers, dates, per-consumer telemetry, brown-outs — makes the versions you cannot avoid survivable. The choice of where the version string lives matters least of all; pick the URI and move on unless you are running a partner platform large enough to staff date-pinning. The underlying law does not bend: an interface with users you cannot update cannot be un-shipped. It can only be managed.

What the interviewer will push on ​

This page is the densest interview surface in the folder, because these three mechanisms are exactly what senior interviewers use to find out whether you have run something in production.

"Your payment endpoint times out. What does the client do?" They are checking whether you notice that the client genuinely cannot decide alone. Say that first: request-lost and response-lost are indistinguishable from outside, so the client has no correct policy — the server must make retrying safe. Then describe the key. The weak answer is "retry with exponential backoff", which is the right transport behaviour attached to the wrong layer of thinking, and it double-charges people.

"Where do you store idempotency keys, and what breaks if you get it wrong?" They want the atomicity. Say unique-constrained insert or SET NX, and say why: a read-then-write check loses to two simultaneous retries, which is the exact scenario the whole mechanism exists for. Then name the second trap — the gap between doing the work and recording that you did it — and close it with one transaction. Mentioning per-customer scoping and a TTL is what makes it sound like something you have operated rather than designed on a whiteboard.

"How long do you keep the keys, and what does a client get if it retries after they expire?" This one catches people who memorised the happy path. The honest answer names the trade: keys are kept long enough to cover realistic retries, typically a day or two, and after that the same key looks brand new, so the operation would run again. That is acceptable precisely because no legitimate client retries a payment two days later — and if yours might, that is a business decision about the window, not a detail to leave undecided.

"Two users edit the same record. What happens?" They are looking for you to name the lost update and to notice it is silent. The full answer walks the ETag, the If-Match, the 412, and the UPDATE ... WHERE version = ? underneath, and finishes by describing what the losing user sees — because the goal was never to prevent the conflict, it was to turn it into something a person can resolve. Mentioning 428 shows you have thought about clients that simply do not send the header.

"Optimistic or pessimistic locking here?" They want a decision driven by conflict rate and by how long the work takes. Optimistic when collisions are rare and redoing work is cheap, which covers nearly all web editing. Pessimistic when collisions are common and losing the work is expensive, or when a human is inside the transaction and cannot be asked to redo their input. The tell of experience is mentioning the lock held by someone who went to lunch.

"How do you add a required field to an existing request?" A small question with a trap in it: you cannot, not to an existing version. Add it optional with a sensible default, watch adoption in your telemetry, and only make it required in a new version once the old clients are gone. Anyone who answers "just add it, we'll tell the clients" has never watched a partner integration break at midnight.

Volunteer this one, because nobody asks: say that you would run a scheduled brown-out before retiring an old version — deliberately failing it for a short, announced window while people are awake. Every team sends deprecation emails; almost nobody verifies that anyone read them, and the alternative to a brown-out is finding out on shutdown day, at the worst possible hour, from a customer.

Next: 9.6.4 — turning the contract into a real artifact: OpenAPI, generated types and mocks, and the checklist that closes the folder.

Recall

  • The retry dilemma: after a timeout the client can't know if the write landed — so make writes retry-safe. First: naturally idempotent semantics (set-to-N over increment, PUT over POST where honest). Then: idempotency keys — client-generated per logical op; server atomically claims (unique insert/SET NX), replays stored responses for completed keys, 409s in-flight duplicates, 422s body-mismatch reuse; keys scoped per consumer, TTL'd, completed in the same transaction as the effect.
  • ETag = opaque per-resource version. If-None-Match → 304 cache revalidation (read side). If-Match → optimistic concurrency: stale write → 412 (lost update becomes a UI conflict, not silent loss); compiles to compare-and-set WHERE version = ?; strong tags for preconditions; 428 to mandate them.
  • Versioning: additive evolution + tolerant readers defer it for years (adding fields/endpoints/documented-open enums = safe; rename/retype/tighten = break). When forced: URI /v2 default (visible, routable) · date-pinned headers for Stripe-shaped B2B · version the contract over one domain core; deprecate operationally (Sunset headers, telemetry, brown-outs).
  • Lens: all three = client assumptions made explicit (hasn't-run / state-I-read / contract-I-coded); retry-safety must compose down the whole call chain; restraint beats mechanism in versioning.

Self-test: Why can't the client resolve the timeout dilemma alone? Walk the five idempotency-store states and why the claim is atomic. Show the lost update and its 412 cure end to end. Which changes are additive — and what must clients promise for that to work? When is date-pinning worth its machinery?

Quiz Bank ​

FoundationalWhat problem do idempotency keys solve, and how does a production implementation actually work?

The retry problem. After a timeout, the client cannot tell which of two things happened. Maybe the request was lost, and retrying is safe. Or maybe only the response was lost, and retrying will do the work twice. So for writes that are not naturally safe to repeat, such as charges, creates and sends, the server has to make retries safe.

How it works. The client sends one key per real action, in a header: Idempotency-Key: <uuid>. The server claims that key in one atomic step, for example an insert into a column with a unique constraint, or Redis SET NX. It must be atomic. If it were "check, then act", two retries arriving at the same moment could both pass the check and both run (2.4).

The claim has four possible outcomes:

  • Already completed: send back the stored original response. The retry sees the first result, so the effect happens once.
  • Still running: return 409 and ask the client to retry later. The work never runs twice at the same time.
  • Same key, different body: return 422. The client has a bug, and this shows it.
  • New key: do the work, then store the response under the key. Ideally, store it in the same transaction as the work itself. Otherwise a crash between the two leaves the door open again.

In production, keep keys separate per caller, delete them after 24 to 48 hours, and pass a key along to anything you call. For example, your call to the payment provider carries its own key. Safety only works if every step in the chain has it.

FoundationalExplain the lost update problem and how ETag + If-Match cures it — including what the server executes.

Two clients read the same resource, and both receive ETag: "v17". Both edit it at the same time, and both save. With plain PUTs, the second save silently overwrites the first. There is no error anywhere. One admin's work simply vanishes. That is the nastiest kind of bug: invisible when it happens, discovered later as "my changes disappeared".

The cure is optimistic concurrency, using preconditions:

  1. Each write carries If-Match: "v17", meaning "apply this only if it is still the version I edited".
  2. The server turns that into one atomic compare-and-set: UPDATE products SET …, version = version + 1 WHERE id = $1 AND version = $2.
  3. If one row changed, the server returns 200 and the new ETag.
  4. If no row changed, someone else got there first. The server returns 412 Precondition Failed. The losing client fetches the latest version, shows a merge or confirm screen, and submits again.

The conflict becomes a moment you designed, instead of lost data.

Some supporting pieces:

  • 428 Precondition Required rejects writes without If-Match to resources people fight over, so nobody can skip the check.
  • Use strong ETags for preconditions, not weak ones that start with W/.
  • This is the same conditional write that stopped overselling in 9.5.2. HTTP's ETag is the API's version of optimistic locking (7.4).
AppliedList the additive-vs-breaking change catalog, and the two-sided contract that makes additive evolution safe.

Safe additions, as long as clients follow the contract below:

  • New fields in responses.
  • New optional fields in requests, with defaults on the server.
  • New endpoints or verbs on new paths.
  • New enum values, but only if the enum was documented as open from the start: "clients must handle values they do not recognise". Say this on day one. It costs nothing then, and you can never add it later.
  • Relaxed validation, meaning you accept more than before.

Breaking changes:

  • Removing or renaming anything.
  • Changing a type or format, such as a string becoming a number, or a different date format.
  • Tightening validation, so something that used to be accepted is now rejected.
  • Changing the status code or error code for a situation that already exists.
  • Changing the default or the meaning of an existing field.
  • Adding a required request field.

The two-sided contract: servers promise additive-only evolution within a version; clients promise tolerant reading — ignore unknown response fields, don't exhaustively switch over open enums (or route unknowns to a default arm), never depend on field order. Both sides written into the API docs make most "versioning" unnecessary for years; either side defecting (client strict: true deserialization that throws on new fields; server renaming a field "just this once") collapses it. This restraint layer outranks the version-spelling debate — most broken integrations are additive-discipline failures, not missing /v2s.

InterviewCompare URI, header, and date-pinned versioning — and say what should be versioned regardless of spelling.

URI (/v2/orders): explicit in every log/curl/bookmark; trivially routable (v1/v2 as separate deployments — useful during migrations); caches split naturally. Objection: "same resource, two URIs" offends REST purity — the industry shipped it anyway; the pragmatic default.

Header (Api-Version: or media-type): clean URIs; but versions vanish from casual observation (curl, logs, browser), caches need Vary, and docs/debugging carry the header everywhere. Date-pinned (Stripe's model): each account pins the version current at signup (2026-07-01); every request runs under the pinned date; upgrades are per-account opt-in. Gold standard for huge B2B surfaces — nobody breaks, ever — but it's machinery: server-side transform layers mapping requests/responses across dozens of dated versions, tested combinatorially; adopt only with that staffing reality.

Whichever way you write the version:

  • Version the contract, not the business logic. v1 and v2 are thin adapters over one shared core (9.6.1). If you copy the core for each version, the copies drift apart and you end up maintaining two products.
  • Retire old versions in practice, not just on paper. Send Deprecation and Sunset headers, track who still uses each version, contact them, and switch the old version off briefly a few times before switching it off for good.
  • Version the whole API at once. Never give each endpoint its own version.
StaffA payments API you review: POST /charges with client retries enabled, no idempotency keys ('the PSP dedupes for us'), unconditional PUTs on merchant config, and a planned /v2 that renames amount to amountMinor 'for clarity'. Deliver the review.

Three findings, each a section-mapped incident-in-waiting. (1) "The payment provider removes duplicates for us" is only half true, and it fails exactly when it matters. The provider spots duplicates using its own idea of a request. Suppose your service crashes after calling the provider, but before saving the charge. Your retry builds a new request to the provider, with a new reference, so the provider sees a new payment and charges again. And duplicates that reach your service, for example from a mobile app retrying again and again, each become a separate call to the provider.

Protection must cover every step, or it does not work (section 4):

  • Require an Idempotency-Key at your own front door.
  • Claim it atomically, and replay the saved response for repeats (the states in section 1).
  • Record completion in the same transaction as your charge row.
  • Send your call to the provider with a key built from yours.

Then the effect happens once from end to end. You can sketch it as client → your edge → your ledger → the provider, with a key on every arrow.

(2) Unconditional config PUTs = lost updates on money-routing settings: two ops editing payout schedules will eventually silently erase each other (section 2's exact scenario, with compliance stakes); mandate ETags + If-Match (CAS on a version column), 428 on unconditional writes, and a conflict UI in the dashboard.

(3) The rename breaks every client and buys nothing. Renaming a field "for clarity" is the classic way to break the rule that a public API can only grow. Instead, add amountMinor next to amount, which is safe (section 3). Document the new one as preferred, and retire amount slowly while you watch who still uses it. A /v2 whose only reason is renames costs every client a migration and gives them nothing. Save /v2 for breaking changes that buy something, and bundle them together.

Close the review with three policies:

  1. Every money route that is not naturally safe to repeat must require an idempotency key. Check the route table automatically.
  2. Shared settings that people edit must require a precondition (If-Match) on every change.
  3. Before any version bump, fill in a checklist: who breaks, what they gain, and why an addition would not do. This is the review gate from 9.6.4.

Flashcards ​

FlashThe retry dilemma

Timeout ⇒ client can't distinguish request-lost from response-lost. Cure order: naturally idempotent semantics first; idempotency keys where creation/charging makes that impossible.

FlashIdempotency store states

Fresh → execute + store (same tx as effect) · completed → replay stored response · in-flight → 409 retry-later · body-hash mismatch → 422. Claim atomically; scope per consumer; TTL.

FlashETag's two faces

If-None-Match → 304 (cache revalidation). If-Match → 412 on stale write (optimistic concurrency = CAS WHERE version = ?). Strong tags for preconditions; 428 to mandate.

FlashAdditive vs breaking

Add fields/endpoints/open-enum values = safe (with tolerant readers). Rename/retype/tighten/re-code = break. Contract: server adds only; client ignores unknowns.

FlashVersioning spellings

URI /v2 = visible, routable, default · header = clean but invisible (+Vary) · date-pinned = per-account, B2B gold, heavy machinery. Version the contract over ONE domain core.

FlashThe unifying lens

Idempotency key / If-Match / version = client assumptions (hasn't-run / state-held / contract) made explicit and server-checkable. Retry-safety composes down the chain or doesn't exist.

Scenario Drill ​

DrillDesign the write-path contract for a ticket-booking API: POST /bookings (seat + payment, mobile clients on flaky networks), PATCH /bookings/{id} (change seat before event), admin bulk price updates, and a partner reseller integration pinned to your API for 3 years. Specify every header, status, and store — and the one place you'd deliberately relax the machinery.

POST /bookings gets the full stack from section 1, because it involves money, creates something, and is called over flaky mobile networks:

  1. Idempotency-Key is required. Without it, the request is rejected with 400 IDEMPOTENCY_KEY_REQUIRED.
  2. The key is claimed atomically, per API key: (apiKey, key).
  3. One transaction holds four things: the booking row, the seat count going down, the payment record, and marking the key as done. The seat count only goes down WHERE seats_left > 0, the conditional write from 9.5.4. So overselling is impossible, whatever happens with retries.
  4. A retry gets the stored response back: 201 with the same booking. Someone who retried three times while their train was in a tunnel ends up with one seat and one charge.
  5. A duplicate that arrives while the first is still running gets 409 REQUEST_IN_FLIGHT with retryAfter: 2, so mobile apps retry politely.
  6. The call to the payment provider carries a key derived from this one (section 4).

Keys are kept for 48 hours.

PATCH /bookings/{id} uses optimistic concurrency, because seat changes can collide. The user might edit from two devices at once, or the user and an admin might both edit it.

  • Every booking response carries an ETag.
  • PATCH requires If-Match. Without it, the server returns 428 Precondition Required, because this resource is edited by more than one party.
  • If the If-Match value is out of date, the server returns 412, with the current booking in the error body. The app can then show "this booking changed, please reload".
  • On the server, the update only succeeds if the version column still matches (compare-and-set).

Changing a seat also reruns the conditional seat-availability write. So there are two mechanisms for two different races. One stops a lost update. The other stops overselling.

Bulk price updates by admins: relaxing the rule on purpose. Requiring If-Match on every row of a 5,000-row update is ceremony that admins will just script around. Instead, the bulk endpoint takes a snapshot token, the cursor or timestamp of the export they edited. The server then applies a conflict rule. Either it skips rows that changed since the snapshot and reports them, or it overwrites them, but only with an explicit force: true and an entry in the audit log (9.4.15). The relaxation is chosen and recorded, not the default. Leaving the safety out is itself a written decision.

The partner who needs three years of stability: versioning as a promise to hold back.

  • Keep them on /v1, and write both sides of the promise into the partnership agreement. Your side: the API only grows by adding. Their side: their client ignores fields it does not know, and handles enum values it does not recognise. This is the two-sided promise from section 3, written down.
  • Send Sunset and Deprecation headers from day one, even before anything is deprecated, so their tools learn to watch for them.
  • Track which version each partner calls.
  • Bundle any future breaking change into a dated /v2, and run both versions side by side for 12 months.

The envelope everywhere: Problem-Details errors with machine codes (SEAT_UNAVAILABLE vs PRECONDITION_FAILED vs IDEMPOTENCY_KEY_REUSED — clients branch, support reads traceId). Summary line for the design doc: every write names its assumption — hasn't-run (key), state-held (ETag), contract-held (version) — and the server verifies instead of guessing; the one relaxation is explicit, audited, and admin-only.