Skip to content

// Resilience & Messaging · 01

Idempotency

Same message twiceleads toSame result once

Doing it twice has the same effect as doing it once.

How it fits together

Idempotency structure«client»ClientPOST /paymentsIdempotency-Key: k1Payments servicecheck key firstsave key + responseIdempotency storekey, hash, responseunique index on key«external»Payment gatewaycharges once1 POST k12 repeat k13 lookup key4 charge once5 save result
uses / callscreates· numbered steps replay in order
  • Client uses / calls Payments service (1 POST k1)
  • Client uses / calls Payments service (2 repeat k1)
  • Payments service uses / calls Idempotency store (3 lookup key)
  • Payments service creates Payment gateway (4 charge once)
  • Payments service creates Idempotency store (5 save result)

Spot it

  • Duplicate orders after a consumer restart
  • Double charges when a user clicks twice
  • Client retries create extra rows

It costs

A lookup and a write per request, plus a retention window to manage.

In code

public sealed record PaymentRequest(string IdempotencyKey, string RequestHash, decimal Amount);​public sealed class PaymentService(IKeyStore keys, IPaymentGateway gateway, IDatabase db){    public async Task<PaymentResult> PayAsync(PaymentRequest request, CancellationToken ct)    {        var stored = await keys.FindAsync(request.IdempotencyKey, ct);        if (stored is not null)        {            return stored.RequestHash == request.RequestHash                ? stored.Result                : throw new IdempotencyKeyReusedException(request.IdempotencyKey);        }​        // The unique index on IdempotencyKey decides a race between two identical requests.        await using var tx = await db.BeginTransactionAsync(ct);        var charge = await gateway.ChargeAsync(request.Amount, request.IdempotencyKey, ct);        var result = new PaymentResult(charge.Id, charge.Status);        var record = new IdempotencyRecord(request.IdempotencyKey, request.RequestHash, result);        await keys.InsertAsync(record, ct);        await tx.CommitAsync(ct);        return result;    }}
Go deeperThe full explanation and what to ask for · 1 min readExpand

Networks and brokers deliver at least once, so duplicates are normal, not exotic. Exactly-once processing is built from at-least-once delivery plus an idempotent receiver.

The classic mistake is check-then-act: read "seen this key?", then write. Two concurrent copies both pass the check. Let a unique constraint decide, and record the key in the same transaction as the side effect, or a crash between the two breaks the guarantee.

Some operations are naturally idempotent (set status to Shipped); others are not (add 10 to the balance). Idempotency is what makes retries, outbox relays and redelivered queue messages safe at all.

Ask for it

  • idempotency key
  • at-least-once delivery
  • processed-messages (inbox) table
  • unique constraint
  • natural idempotency

Make POST /payments idempotent: require an Idempotency-Key header, store key, request hash and response for 24h under a unique index, and replay the stored response on a repeat. In the message consumer, record the message id in a processed-messages table in the same transaction.