Files
web_sport/docs/adr/0019-order-placement-is-idempotent-by-client-key.md
2026-08-13 23:20:23 +07:00

4.1 KiB

ADR-0019: Order placement is idempotent by client key

  • Status: Accepted
  • Date: 2026-08-12

Context

Placing an order is a single transaction: reserve stock, write the order and its lines, commit. That transaction is correct — it either happens completely or not at all.

It is also not enough, because the dangerous window opens after it succeeds. The commit lands, and then the response is lost to a dropped connection, a closed laptop, an impatient second click, or a mobile network handing over between cells. The shopper sees a failure for something that actually worked, presses the button again, and buys everything twice.

Nothing inside the transaction can prevent this. The second request is, from the database's point of view, a perfectly legitimate new order.

The same window swallows the step after the commit. Clearing the Redis cart was awaited and could throw, which would have reported failure for a committed order — turning a cosmetic Redis problem into a duplicate purchase.

Decision

POST /checkout/orders requires an Idempotency-Key header. Not optional: this endpoint creates an order and reserves stock, and a client that cannot be retried safely is a client that will eventually double-charge someone. Making it optional means the one caller that forgets is the one that does the damage.

The key is claimed atomically before any work begins, via SET NX on idempotency:checkout:<key> with a 24-hour TTL:

Claim Meaning Response
Succeeds First attempt Place the order, record its id against the key
Fails, id recorded Retry of something that already worked Return the same order
Fails, no id yet Original attempt still running 409 — refuse rather than race it

The order id is recorded before anything else can fail, so every step after the commit is survivable.

Clearing the cart can no longer fail the request. It is logged and swallowed: a bag that outlives its order is cosmetic; an order the customer was told failed is not.

On failure, the claim is released, so a genuine retry after a genuine error is not locked out for 24 hours.

The storefront generates one key per checkout attempt, held in component state. Pressing the button again reuses it; reloading the page mints a new one, which is a genuinely new attempt.

Consequences

A retried checkout returns the original order rather than creating a second. A double-click gets one order and one clear refusal. A committed order survives a Redis outage during cleanup.

The 24-hour TTL means an idempotency record outlives any plausible retry but does not accumulate forever. Redis losing it is safe in the direction that matters — ADR-0010 already establishes that Redis can be flushed at any time, and the worst case here degrades to the behaviour we had before this ADR rather than to something worse.

Every future client of this endpoint — a mobile app, a POS, an ERP integration — must generate keys. That is a real constraint and the correct one.

Payments (M9) will attach to the same key. A provider callback that arrives twice is the identical problem with a larger blast radius.

Alternatives considered

Derive the key from the cart contents. Requires no client cooperation and is wrong: a customer legitimately reordering the same items an hour later would be handed their old order.

A unique constraint on (email, total, minute). A guess dressed as a constraint. It blocks legitimate rapid reorders and misses duplicates that straddle a minute boundary.

Let the client detect it. The client is exactly the party that cannot know — it did not receive the response, which is the entire problem.

Make the header optional with a fallback. The fallback is either unsafe or one of the rejected options above, and an optional safety mechanism protects only the callers that did not need protecting.