DEV Community

Fiaz Miah
Fiaz Miah

Posted on

An Idempotency Key Is More Than a Duplicate-Request Flag

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)
);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Try to claim the (account, operation, key) tuple using the database's unique constraint.
  2. 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.
  3. If this request owns it, make the business change and save the response in the same transaction.
  4. 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)