Topic: Web platform
A retry is not a second attempt: the real boundaries of API idempotency keys
A timeout is not proof of failure. Use operation keys, parameter binding, atomic records, bounded retries, and an outbox to prevent duplicate side effects.
A customer presses Pay, waits, and eventually sees a timeout. The dangerous assumption is that no response means the server did nothing, so the client sends a new payment request.
Animated meme (expand/collapse)
A timeout only leaves the result unknown. The request may never have arrived, or it may have completed while the response was lost. API idempotency is how we close that gap without repeating the intended effect.
Idempotent does not mean identical responses
MDN defines idempotency in terms of intended effect. Sending the same request once or several times should leave the server in the same intended state. The responses do not have to match. A first DELETE might return 200 and the second 404, while the resource remains absent in both cases.
HTTP method semantics provide a useful starting point:
| Method | Baseline protocol semantics | Typical retry decision |
|---|---|---|
GET |
Safe and idempotent | Usually retryable, with limits on attempts and load |
PUT, DELETE |
Idempotent | The intended effect of the same request should be stable |
POST, PATCH |
Not guaranteed to be idempotent | The API must define an additional contract |
This table does not prove that every GET implementation is free of side effects or that every PUT is correct. It describes how the methods are meant to behave. A POST that creates a payment, reserves a seat, or enqueues work needs more than its method name.
A key represents one user intent
For a payment creation request, the client should generate an operation key before the first attempt and retain it until the outcome is known:
const operationKey = crypto.randomUUID();
async function submitPayment(payload: PaymentInput) {
return fetch("/api/payments", {
method: "POST",
headers: {
"content-type": "application/json",
"idempotency-key": operationKey,
},
body: JSON.stringify(payload),
});
}
If the first attempt times out, a retry of that operation must reuse the key. A later, deliberate second payment gets a new key. Generating a key only after the timeout gives the server no way to connect the retry to the first request that may already have arrived.
The server must also bind the key to normalized operation parameters:
- Same key and same parameters: replay the stored result or report that processing continues.
- Same key and different parameters: reject the request as a conflict instead of quietly creating another operation.
- Different key: treat it as a new user intent, even when the payload happens to match.
Inferring intent from a payload hash is unreliable. A customer may deliberately place two identical orders. Conversely, harmless representation differences can change a hash for the same intended operation. The AWS article on making retries safe favors a caller-provided request identifier because it states that intent explicitly.
The server must claim the key atomically
An in-memory Set in one process is not enough. Multiple instances, restarts, and concurrent requests can all bypass it. A minimal persistent record might look like this:
CREATE TABLE idempotency_operations (
tenant_id text NOT NULL,
operation text NOT NULL,
operation_key uuid NOT NULL,
request_hash text NOT NULL,
status text NOT NULL CHECK (status IN ('processing', 'completed')),
response_status integer,
response_body jsonb,
expires_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, operation, operation_key)
);
The table name is incidental. The unique constraint and the business mutation must sit in the right atomic boundary. When two copies of a request arrive together, only one may claim the key and perform the side effect. After completion, retain enough state to reconstruct a semantically equivalent API response.
The tenant_id and operation scope matters too. Two tenants or endpoints must not expose results merely because they happen to use the same UUID. An idempotency key is also not authorization. The server still authenticates the caller, verifies permission, and prevents one user from retrieving another user’s response.
Retryability depends on whether execution began
Stripe’s idempotent request contract is a concrete example. Once an endpoint begins executing, Stripe saves the first status code and response body, including a 500 response, and replays it for the same key. That is Stripe’s documented API behavior, not a property that HTTP automatically gives every POST.
There is another important boundary. If parameters fail validation, or a concurrent request conflicts before endpoint execution begins, Stripe does not save an idempotent result and a later retry may execute. Seeing the same key is not proof that a business mutation occurred. The caller must follow the API’s definition of when execution starts.
Keys also need a retention policy. Stripe documents that keys may be removed after at least 24 hours. Other systems should define a duration based on their retry window, regulatory needs, and storage cost. If an expired key can be accepted as new, a client cannot safely replay an old operation forever.
Idempotency does not justify unlimited retries
Idempotency reduces the risk of duplicate effects. It does not make unlimited attempts free. The AWS guidance on timeouts, retries, backoff, and jitter warns that retries at every layer multiply load. Clients that all retry on a fixed schedule can also overwhelm a service while it is recovering.
A conservative policy can distinguish failure classes:
- Network failures, timeouts, and selected 5xx responses: use bounded exponential backoff with jitter at one layer.
429 Too Many Requests: honor service guidance such asRetry-After.- Validation errors in the 400 range: fix the input instead of resending it unchanged.
- An unknown result with a status endpoint: query by the same operation identifier before deciding to retry.
Animated meme (expand/collapse)
An outbox records a local promise, not an external exactly-once guarantee
A database transaction can write both the order and an event to be delivered:
BEGIN;
INSERT INTO orders (id, customer_id, total)
VALUES ($1, $2, $3);
INSERT INTO outbox (event_id, topic, payload)
VALUES ($4, 'order.created', $5);
COMMIT;
This avoids permanently losing the event when an order commits and the process crashes before publishing. The AWS transactional outbox pattern also notes that the relay can publish the same event more than once. Consumers therefore need a stable event_id or downstream idempotency key, plus retry and reconciliation paths. The outbox makes local data and delivery intent atomic. It does not produce exactly-once behavior across a database, queue, and third-party API.
Protect operations with real side effects first
If time is limited, there is no need to build a large framework for every endpoint. Prioritize by the cost of duplicate execution:
| Priority | Operation | Possible cost of duplication |
|---|---|---|
| High | Payment, refund, order creation, reservation, inventory decrement | Duplicate charges, overselling, duplicate commitments |
| High | Job creation and webhook consumption | Repeated work or notifications |
| Medium | Overwritable settings update | The final state may match, but races still matter |
| Low | Static GET and pure transformations |
Usually no durable side effect; cost and load are the main concerns |
Once the mechanism exists, observe whether it works. Useful signals include result replays, payload conflicts, concurrent claims, expired-key reuse, processing timeouts, downstream duplicates, and terminal failures. They separate normal customer retries from a client stuck in a loop and expose paths that bypass deduplication.
A practical review checklist
- Does the client generate the key before its first attempt and reuse it for every retry of that operation?
- Does each new operation receive a new key instead of deriving intent from payload equality?
- Does the API reject a changed payload under an existing key?
- Are key scope, retention, and expiry behavior documented?
- Can key claiming, the business mutation, and result storage survive concurrency and process restarts?
- Are authentication and authorization enforced independently?
- Does the contract say which failures are retryable, at which layer, and how many times?
- Can the outbox relay and downstream consumer handle duplicate delivery?
- Can operators query by operation ID and reconcile an uncertain outcome?
What I learned
- I do not treat a client timeout as a server rollback. An unknown outcome should be queried or retried under the same operation identifier.
- I treat idempotent
POSTbehavior as an explicit API contract, not an automatic HTTP guarantee. - I generate the key before the first attempt, bind the same key to the same payload, and use a new key for a new user intent.
- I treat an outbox as a local atomic promise. Downstream delivery still needs deduplication, and authorization remains a separate check.