89 lines
4.1 KiB
Markdown
89 lines
4.1 KiB
Markdown
# 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.
|