Idempotent Handlers

Make POST endpoints and message consumers safe to repeat with idempotency keys, processed-message records and unique constraints.

SyntaxPOST /orders Idempotency-Key: 6f1c2a9e-8f3b-4d1e-9a51-0c4b7e2d9f10 // same key + same body -> replay stored 201; different body -> 422; in flight -> 409

Duplicates are normal, not exotic. A mobile client times out after 10 seconds and retries a POST the server already completed. A broker redelivers because the ack was lost. The outbox relay republishes after a crash. A user double-clicks Pay. An idempotent handler produces the same outcome however many times the same request arrives.

Idempotency keys for POST

GET, PUT and DELETE are idempotent by HTTP semantics; POST is not. The client generates a unique key per logical operation (a UUID) and sends it in an Idempotency-Key header, reusing it on every retry of that operation. The server stores, per key:

  • the scope: the authenticated client or tenant, so two customers cannot collide or read each other's responses;
  • a fingerprint of the request body, so the same key with a different body is rejected with 422 instead of returning an unrelated response;
  • a state: in progress or completed, and on completion the status code, headers that matter (Location) and body.
SituationResponse
New keyProcess, store the response, return it
Same key, same body, completedReplay the stored response; do not run the handler
Same key, still in progress409 Conflict; the client retries later
Same key, different body422 Unprocessable Content
Key missing on an endpoint that requires it400

Keep keys for at least as long as clients retry, typically 24 hours. Decide whether to store failures: replay 4xx (the request was wrong) but release the key on 5xx or an unhandled exception so a retry can succeed. Give the in-progress state a lease (locked_until): a pod killed mid-handler releases nothing, so a claim older than its lease is abandoned and the next retry reclaims it, instead of getting 409 for 24 hours.

De-duplicating messages

For a consumer, the key is the message id. Record it in a processed_messages table with a primary key on (consumer, message_id), inserted in the same transaction as the handler's changes. A duplicate then fails on the key and the whole transaction rolls back; catch the unique violation (PostgreSQL SQLSTATE 23505), treat it as already done and ack. Checking with a SELECT first is not enough on its own: two consumers can both see "not processed" and both proceed.

Unique constraints are the safety net

Application checks race; database constraints do not. Back every idempotency mechanism with one: a unique index on (client_id, idempotency_key), on orders(customer_id, client_reference), on payments(provider_charge_id). A Redis SET NX in front is a fine fast path but expires and can be lost, so it is never the only guard.

Idempotent by construction

  • Write state, not deltas: SET status = 'Shipped' repeats safely; stock = stock - 1 does not unless guarded.
  • Guard transitions: UPDATE ... WHERE id = @id AND status = 'Authorized'; zero rows affected means it already happened.
  • Pass your key downstream. Payment providers accept an idempotency key, so a retried capture does not charge twice.
  • Side effects outside the database (an email) cannot join the transaction; record them through the outbox so they are sent once per committed change.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Replay the stored 201 with the original order id when a mobile client retries POST /orders after a 10-second timeout.
  • Ack a redelivered OrderPlaced message without reserving stock twice, using a processed_messages row in the same transaction.
  • Ignore a payment provider webhook delivered three times by guarding the Authorized-to-Captured transition in the UPDATE.

More examples

De-duplicate messages with a processed-messages table

The marker row and the business change commit in one <code>SaveChangesAsync</code>, so they cannot disagree. A redelivered message violates the composite primary key, the whole transaction rolls back and the handler reports a duplicate, which the consumer acks. Clearing the change tracker stops the failed entities from being retried on the next save.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Npgsql;

public sealed class ProcessedMessage
{
    public string Consumer { get; set; } = "";
    public Guid MessageId { get; set; }
    public DateTimeOffset ProcessedAt { get; set; }
}

public sealed class StockReservation
{
    public int Id { get; set; }
    public Guid OrderId { get; set; }
    public string Sku { get; set; } = "";
    public int Quantity { get; set; }
}

public sealed class InventoryDb(DbContextOptions<InventoryDb> options) : DbContext(options)
{
    public DbSet<ProcessedMessage> ProcessedMessages => Set<ProcessedMessage>();
    public DbSet<StockReservation> Reservations => Set<StockReservation>();

    protected override void OnModelCreating(ModelBuilder b) =>
        b.Entity<ProcessedMessage>().HasKey(m => new { m.Consumer, m.MessageId }).HasName("pk_processed_messages");
}

public sealed class ReserveStockHandler(InventoryDb db, TimeProvider time)
{
    public async Task<bool> HandleAsync(Guid messageId, Guid orderId, string sku, int quantity, CancellationToken ct)
    {
        db.ProcessedMessages.Add(new ProcessedMessage { Consumer = "reserve-stock", MessageId = messageId, ProcessedAt = time.GetUtcNow() });
        db.Reservations.Add(new StockReservation { OrderId = orderId, Sku = sku, Quantity = quantity });
        try
        {
            await db.SaveChangesAsync(ct); // one transaction: marker and reservation together
            return true;
        }
        // Only the marker's key means "duplicate"; any other unique violation is a real failure.
        catch (DbUpdateException ex) when (ex.InnerException is PostgresException
            { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "pk_processed_messages" })
        {
            db.ChangeTracker.Clear();
            return false; // already processed: ack and move on
        }
    }
}

The unique constraints that back every guard

<code>INSERT ... ON CONFLICT DO NOTHING RETURNING</code> claims a key atomically: one row back means you own it, none means look up the existing row and replay or answer 409. The conditional <code>UPDATE ... WHERE locked_until &lt; now()</code> lets a retry take over a claim whose holder died, so a crash never blocks a key for 24 hours. Natural unique keys such as a provider charge id catch the duplicates no key covers, such as a webhook delivered twice.

Example · sql
CREATE TABLE idempotency_keys (
    client_id        text        NOT NULL,
    idempotency_key  text        NOT NULL,
    request_hash     char(64)    NOT NULL,
    status_code      integer     NULL,      -- NULL while in progress
    response_body    jsonb       NULL,
    locked_until     timestamptz NULL,      -- lease on the in-progress state
    created_at       timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (client_id, idempotency_key)
);

-- Claim the key. Zero rows returned means another request already holds it.
INSERT INTO idempotency_keys (client_id, idempotency_key, request_hash, locked_until)
VALUES ('mobile-app', '6f1c2a9e-8f3b-4d1e-9a51-0c4b7e2d9f10', 'A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4B5C6D7E8F90',
        now() + interval '30 seconds')
ON CONFLICT (client_id, idempotency_key) DO NOTHING
RETURNING created_at;

-- Reclaim an abandoned claim: still in progress, lease expired (the holder crashed).
UPDATE idempotency_keys
SET locked_until = now() + interval '30 seconds'
WHERE client_id = 'mobile-app' AND idempotency_key = '6f1c2a9e-8f3b-4d1e-9a51-0c4b7e2d9f10'
  AND status_code IS NULL AND locked_until < now()
RETURNING created_at;

-- On a 5xx or unhandled exception, release the claim so a retry can run.
DELETE FROM idempotency_keys
WHERE client_id = 'mobile-app' AND idempotency_key = '6f1c2a9e-8f3b-4d1e-9a51-0c4b7e2d9f10'
  AND status_code IS NULL;

-- Natural keys stop duplicates even when a client sends no header.
CREATE UNIQUE INDEX ux_orders_client_reference ON orders (customer_id, client_reference);
CREATE UNIQUE INDEX ux_payments_provider_charge ON payments (provider_charge_id);

-- Expire old keys daily.
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';

Guard a state transition so a repeat is a no-op

The status condition lives in the <code>WHERE</code> clause, so the database decides atomically: the first delivery updates one row, every repeat updates zero. No read-modify-write window exists for two consumers to race through, and the handler can tell a repeat from a missing payment.

Example · csharp
using Microsoft.EntityFrameworkCore;

public sealed class Payment
{
    public Guid Id { get; set; }
    public string Status { get; set; } = "Authorized";
    public DateTimeOffset? CapturedAt { get; set; }
}

public sealed class PaymentsDb(DbContextOptions<PaymentsDb> options) : DbContext(options)
{
    public DbSet<Payment> Payments => Set<Payment>();
}

public enum CaptureResult { Captured, AlreadyCaptured, NotFound }

public sealed class CapturePaymentHandler(PaymentsDb db, TimeProvider time)
{
    public async Task<CaptureResult> HandleAsync(Guid paymentId, CancellationToken ct)
    {
        var now = time.GetUtcNow();
        var updated = await db.Payments
            .Where(p => p.Id == paymentId && p.Status == "Authorized")
            .ExecuteUpdateAsync(s => s
                .SetProperty(p => p.Status, "Captured")
                .SetProperty(p => p.CapturedAt, now), ct);

        if (updated == 1) return CaptureResult.Captured;
        return await db.Payments.AnyAsync(p => p.Id == paymentId, ct)
            ? CaptureResult.AlreadyCaptured
            : CaptureResult.NotFound;
    }
}

Discussion

  • Be the first to comment on this lesson.