Idempotency keys, and why storing the key is not enough
A client times out waiting for your POST /orders. It retries.
Now you have two orders and one very confused customer. The standard fix is a
client-generated Idempotency-Key header, a unique row keyed on it,
and a short-circuit when the key is seen twice.
That is the version in every blog post, and it is incomplete in three ways that only show up under load.
1. Store the response, not just the key
If you insert the key and then execute the handler, a second request arriving
mid-flight finds the key and has nothing to return. It either 500s or, worse,
returns an empty 200. The row needs somewhere to keep the finished
status code and body, and the replay path returns exactly that.
2. Concurrent retries need a lock, not a check
Two retries landing in the same millisecond both read "key missing" before either
writes. The unique constraint saves you from a duplicate row, but one request now
gets a constraint error. Take a row lock with SELECT … FOR UPDATE, or
write a locked_until timestamp and return 409 for the
in-flight request. The client retries; the second attempt gets the stored response.
3. Scope the key
A key is only meaningful per account and per endpoint. Two customers using
"1" as a test key should not collide, and the same key on
/orders and /refunds should not replay the wrong body.
Hash the request body as well: replaying a stored order for a request that changed
its amount is a silent data bug, not a safe retry.
# replay path, in full row = repo.get(key, account_id) if row is None: # first time — claim it atomically row = repo.insert_locked(key, account_id, body_hash) if row.request_hash != body_hash: raise Conflict("key reused with a different body") if row.status_code is None: raise InFlight("retry in a moment") # 409 return Response(row.response, status=row.status_code)
Expire keys after 24 hours, not forever — you are protecting against retries, not building an audit log. Those are two different tables with two different retention rules.