A client timeout does not tell you whether the server rejected a write or completed it and lost the response. Retrying a POST without an idempotency key can create the order, charge, reservation, or message twice.
Create one unique key for each logical operation and reuse that key for retries. A new operation gets a new key. The server should scope the key to the authorization boundary, usually tenant plus caller where relevant, and the operation. Bind it to a request fingerprint and define which outcomes are stored for replay.
The exact store is application code, but the boundary should look like this:
app.MapPost("/orders", async (
HttpRequest request,
CreateOrder command,
IOrderService orders,
IIdempotencyStore idempotency,
CancellationToken cancellationToken) =>
{
var key = request.Headers["Idempotency-Key"].ToString();
if (string.IsNullOrWhiteSpace(key))
{
return Results.BadRequest("Idempotency-Key is required.");
}
var response = await idempotency.ExecuteOnceAsync(
scope: request.HttpContext.User.GetTenantId(),
operation: "create-order",
key: key,
requestFingerprint: RequestFingerprint.Create(command),
execute: cancel => orders.CreateAsync(command, cancel),
cancellationToken: cancellationToken);
return response.ToHttpResult();
});
RequestFingerprint.Create(command) should use a canonical representation of the meaningful request fields, not raw request text. Property order, defaults, or insignificant formatting should not turn the same operation into a different request.
ExecuteOnceAsync must do more than check a cache. Enforce a unique constraint for the authorization scope, operation, and key. Atomically claim the operation, then make concurrent requests wait for the original result, replay the stored result, or return a deterministic in-progress or conflict response. Do not let them execute the side effect twice.
The idempotency record and the business write must be committed atomically, or the business write must carry the idempotency key as a unique operation identifier. Otherwise, a crash between the side effect and storing the replayable result can still create duplicates. A simple Get(key) followed by Create() is racy because two requests can pass the check before either one records a result.
Define which outcomes are stored. Many systems store the result after execution begins, including failures, but do not store pre-execution validation or authorization failures. The exact policy depends on which responses are safe and meaningful to replay.
Reject a reused key when the request fingerprint differs. Otherwise a client can accidentally replay the result of one operation for a different payload. Retain the record for at least the period in which clients and intermediaries may retry, then clean it up deliberately.
An idempotency key only protects the boundary where you can record and replay the operation. If the write calls a payment provider, message broker, or another service, propagate that provider’s idempotency key or use an outbox and deduplication strategy there too. Idempotency does not replace authorization, validation, transactions, or a bounded retry policy.
Use idempotency keys for writes where a timeout or retry could duplicate a meaningful side effect. Do not generate a new key for each retry, and do not treat a key as permission to repeat a different request.