Idempotency Is Easy Until the Second Request Is Different | Dochia CLI Blog
Pangram verdict · v3.3
We believe that this document is a mix of AI-generated, and human-written content
AI likelihood · overall
MixedArticle text · 1,555 words · 6 segments analyzed
People talk about idempotency like it is a solved problem: Put an Idempotency-Key on the request. Store the response. Replay it on retry. And yes, that is doable. For the happy path, it is even fairly small.The client sends:POST /payments Idempotency-Key: abc-123 Content-Type: application/json{ "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice-7781" }The server checks whether it has seen abc-123. If not, it creates the payment. If yes, it returns the previous response.That version survives the demo.The part I contest is that this is the hard part. It is not. The hard part starts with the second request, because the second request is not always a clean replay of the first one.Maybe it is a completed replay. Fine. Return the stored result.Maybe it arrives while the first request is still running. Now your idempotency layer is part of your concurrency control.Maybe the first request created a local payment but crashed before publishing an event. Now the local row and the external side effects are out of step.Maybe the first request called a payment provider, the provider accepted it, and your process died before recording the result. Now your database cannot infer whether money moved.Or maybe the second request has the same key and different content:{ "accountId": "acc_1", "amount": "100.00", "currency": "EUR", "merchantReference": "invoice-7781" }Same key. Different amount.This is the case that makes idempotency interesting. Is it a retry? Is it a client bug? Is it a new operation? Should the server replay the old response, reject the request, or treat (key + content) as a new identity?You can pick any of those policies if you document it clearly. But the server should have an opinion. Not necessarily my opinion, but a clear one.My bias for side-effecting APIs is: same scoped key plus different canonical command should be a hard error.
It catches client bugs early. A client that believes it is safely retrying a 10 EUR payment should not have the server silently interpret the second request as something else.The cases that matter are the ones a replay cache does not explain: completed replay concurrent retry partial local success downstream unknown state same key with a different canonical command duplicate operation without a key retry after expiry retry after deploy, schema change, service hop, or region failover If your design only handles completed same-command retries, it is a replay cache. That might be enough for some endpoints. But it is not the whole problem.Idempotency is about the effectAn operation is idempotent if applying it once or many times has the same intended effect.That definition is simple. The word doing all the work is “effect”.HTTP gives you method-level semantics. A PUT /users/123/email can be idempotent if sending the same representation repeatedly leaves the resource in the same state. A DELETE /sessions/456 can be idempotent if deleting an already-deleted session still means “session does not exist”. Repeating the DELETE might return 404; the effect can still be idempotent.But your handler can still produce repeated side effects the business cares about: duplicate audit records, duplicate domain events, duplicate emails, duplicate provider calls, or duplicate metrics that affect billing or fraud logic.
POST is usually not idempotent by default, but it can be made idempotent if the server stores and enforces the right behavior. The key identifies a claimed operation. It does not define request equivalence, replay policy, or downstream deduplication.A uniqueness constraint can prevent one class of duplicate. It does not, by itself, give the client a correct retry result.For example, unique(account_id, merchant_reference) might prevent two payment rows, but if the retry gets a generic 500, the client still does not know whether the payment succeeded. If the row exists but the response is different, or the event is published twice, or the ledger entry is duplicated, the operation is not idempotent in the way the caller cares about.What you need to rememberFor POST /payments, the durable idempotency record needs to answer three questions: Who owns this key? What did the first command mean? What outcome can be replayed? In PostgreSQL-ish SQL, a minimal table might look like this:create table idempotency_requests ( tenant_id text not null, operation_name text not null, idempotency_key text not null, request_hash text not null, status text not null, response_status int, response_body jsonb, resource_type text, resource_id text, error_code text, created_at timestamptz not null, updated_at timestamptz not null, expires_at timestamptz not null, locked_until timestamptz, primary key (tenant_id, operation_name, idempotency_key) );The key is not globally unique unless you deliberately make it global. Usually it should not be. A broken client generating abc-123 should only collide with itself, not with another tenant.Scope might be tenant, user, account, merchant, API client, or some combination. Pick it deliberately.The operation name prevents accidental reuse across different operations. A key used for create_payment should not automatically mean the same thing for create_refund.The request_hash is the server’s memory of the first command. Without it, same key plus different body becomes ambiguous.
You either replay the first response for a different command, or you execute a new operation under an old key. Both are bad if the client thinks it is retrying.IN_PROGRESS is not an internal detail. A retry can arrive while the first request still owns execution.The behavior needs to be explicit:Existing recordSame canonical command?Suggested behaviornoneyesinsert IN_PROGRESS and executeCOMPLETEDyesreplay stored response or documented equivalentany existing recordnoreject with idempotency conflictIN_PROGRESS, freshyeswait, return 202, or return 409 + Retry-AfterIN_PROGRESS, staleyesrecover ownership; do not blindly execute againFAILED_REPLAYABLEyesreplay stored failureFAILED_RETRYABLEyesallow retry according to policyUNKNOWN_REQUIRES_RECOVERYyestrigger reconciliation or return pending/recovery statusexpired/deletedunknownfollow documented expiry behaviorThe response fields exist because idempotency is not just about preventing duplicate writes. The client needs an answer.You can store the full response body, or store a reference to the created resource and reconstruct the response. Both choices are annoying in different ways.Storing full responses gives faithful replay. It can also retain PII, signed URLs, one-time tokens, cardholder-related data, or fields you never intended to keep in a retry table.Reconstructing from a resource reference saves space, but it can return a different representation if the resource changed after creation.This is a contract decision. “Replay the creation response” and “return the current payment” are both valid API designs.
They are not the same design.Same key, different commandThis is the bug the idempotency layer should catch loudly.First request:{ "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice-7781" }Second request:{ "accountId": "acc_1", "amount": "100.00", "currency": "EUR", "merchantReference": "invoice-7781" }Same Idempotency-Key: abc-123. Different amount.Returning the original response anyway is simple. It also hides a serious client bug. The client asked for a 100 EUR payment and got back a 10 EUR payment. If the caller does not compare the response carefully, it may believe the 100 EUR payment succeeded.That is not idempotency. That is reinterpretation.For side-effecting APIs, a scoped key reused with a different canonical command should be a hard error, regardless of whether the first operation completed, failed, or is still running.HTTP/1.1 409 Conflict Content-Type: application/json{ "errorCode": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST", "message": "This idempotency key was already used with a different request." }409 Conflict is a defensible default because the request conflicts with the server’s remembered meaning for that scoped key. Some APIs use 400 or 422; the important part is a stable machine-readable error and no silent replay for a different command.A common client bug looks like this:bad: idempotencyKey = cartId
POST /payments amount=10.00 key=cart_123 POST /payments amount=15.00 key=cart_123
better: idempotencyKey = paymentAttemptIdThe server should not guess which payment the cart key was supposed to represent.You can design an API where (key + content hash) defines the operation identity. That is a valid policy. But then the key is no longer an idempotency key in the usual retry sense. It is part of a composite operation identifier.
That needs to be obvious to the client.The dangerous version is the middle ground, where the client thinks it is safely retrying one operation and the server silently interprets the second request as another.Hash the command, not the bytesRaw byte comparison is usually too strict for JSON APIs. These two bodies should normally be equivalent:{ "amount": "10.00", "currency": "EUR" }{ "currency": "EUR", "amount": "10.00" }Field order and whitespace should not matter.Defaults are less obvious:{ "accountId": "acc_1", "amount": "10.00", "currency": "EUR" }versus:{ "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "channel": "web" }If channel: "web" is the server default, are these the same logical command? Maybe. Decide before hashing.Unknown fields are another trap. Suppose your API ignores unknown JSON fields. If the first request includes "foo": "bar" and the second does not, do you consider them the same? If unknown fields are truly ignored, perhaps yes. If they might become meaningful after a deploy, perhaps no.The practical rule is: hash the validated command, not the raw HTTP body.A reasonable flow is: Parse the request into a versioned request DTO or command. Normalize values your API treats as equivalent: amounts, enum casing, default fields, timestamp precision. Exclude transport-only metadata. Include path parameters and operation name. Include semantic headers if they affect the operation, such as API version. If a header only affects response shape, such as Prefer: return=minimal, decide whether it belongs in the command hash, the replay contract, or neither. Exclude Authorization and the idempotency key itself. Serialize canonically. Hash with a stable algorithm. For the payment example, the fingerprint might include:operation: create_payment accountId: acc_1 amount: 10.00 currency: EUR merchantReference: invoice-7781 channel: web apiVersion: 2026-05-01Be careful with amounts, timestamps, generated defaults, locale-sensitive formatting, and fields added during deploys.