Fix error before M7
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# 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.
|
||||
@@ -27,6 +27,7 @@ An ADR is immutable once accepted. If a decision changes, add a new ADR that sup
|
||||
| [0016](./0016-option-values-are-retained-when-variants-reference-them.md) | Option values are retained when variants reference them | Accepted |
|
||||
| [0017](./0017-shadcn-for-infrastructure-hand-built-for-brand.md) | shadcn/ui for infrastructure, hand-built for brand | Accepted |
|
||||
| [0018](./0018-orders-snapshot-everything-they-display.md) | Orders snapshot everything they display | Accepted |
|
||||
| [0019](./0019-order-placement-is-idempotent-by-client-key.md) | Order placement is idempotent by client key | Accepted |
|
||||
|
||||
## Decisions deliberately NOT recorded yet
|
||||
|
||||
|
||||
Reference in New Issue
Block a user