Networks are allowed to be uncertain. A client can send POST /orders, receive no response, and have no idea whether the server committed the order. Retrying is reasonable; creating a second order is not.
An idempotency key gives the client a way to say, “This is the same intent as before.” The server still has to make that claim meaningful. A simple seen_keys set does not do enough: it can reject a retry, but it cannot tell the client what happened the first time.
Keep a receipt for the request
For a database-backed endpoint, I would store a receipt scoped to the caller and operation:
CREATE TABLE request_receipts (
account_id bigint NOT NULL,
operation text NOT NULL,
idempotency_key text NOT NULL,
request_hash bytea NOT NULL,
response_status integer,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, operation, idempotency_key)
);
The scope matters. A key that is unique only across the whole service can let one account collide with another. A key unique only per URL may become ambiguous when the same route serves different operations. Choose a scope that matches the business action, and document it.
request_hash is just as important as the key. If a client reuses a key with a different body, replaying the old result would hide a bug; executing the new body would defeat idempotency. Compare a stable fingerprint of the relevant request fields and return a conflict for a mismatch. Be explicit about normalization: two JSON objects with different key order may express the same request, while a changed amount does not.
Commit the business change and receipt together
For an operation whose side effects live in the same database, the useful unit of work is one transaction:
- Try to claim the
(account, operation, key)tuple using the database's unique constraint. - If another request owns it, wait for that transaction to finish, then compare the hash and replay its stored result. Put a bound on the wait; a retryable “still processing” response is better than hanging forever.
- If this request owns it, make the business change and save the response in the same transaction.
- Commit before sending the HTTP response.
That ordering handles a common failure: the database commit succeeds, but the connection drops before the response reaches the client. On retry, the receipt can return the original result. A SELECT followed by an INSERT is not a substitute for the unique constraint; two workers can both pass the select.
The receipt should contain enough to replay the outcome that matters: usually the status code, stable response body, and any durable resource identifier. Decide separately whether transient failures are cached. Caching every 500 forever can turn a brief outage into a permanent failure for that key.
External calls need another boundary
A local transaction cannot atomically commit both your database row and an external payment, email, or fulfillment call. Holding a database lock while calling a remote service does not solve that gap.
One workable design is to commit an order plus an outbox event locally, then have a worker perform the external action. The worker carries a stable idempotency key to the provider when the provider supports one, records the provider's result, and retries safely. If the provider has no such protection, the system needs reconciliation or a way to query whether the action already happened. The API receipt prevents duplicate requests; it does not magically guarantee exactly-once effects in another system.
Test the awkward moments
The happy-path test is the least interesting one. I would also test two concurrent requests with the same key, the same key with a changed amount, a crash after commit but before response, a slow external provider, and a retry after receipt expiry. The retention period should outlast the client's realistic retry window; otherwise an old key can become “new” again.
The goal is not to make retries disappear. It is to make an uncertain retry produce a predictable answer, even when the first answer was lost.
Top comments (0)