Fix error before M7

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 624a6402bf
commit 1e356a2578
11 changed files with 331 additions and 14 deletions
@@ -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.
+1
View File
@@ -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