Idempotent Handlers
Make POST endpoints and message consumers safe to repeat with idempotency keys, processed-message records and unique constraints.
POST /orders
Idempotency-Key: 6f1c2a9e-8f3b-4d1e-9a51-0c4b7e2d9f10
// same key + same body -> replay stored 201; different body -> 422; in flight -> 409Duplicates 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.
| Situation | Response |
|---|---|
| New key | Process, store the response, return it |
| Same key, same body, completed | Replay the stored response; do not run the handler |
| Same key, still in progress | 409 Conflict; the client retries later |
| Same key, different body | 422 Unprocessable Content |
| Key missing on an endpoint that requires it | 400 |
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 - 1does 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
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.
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 < 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.
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.
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