Idempotency
Mutating routes are vulnerable to duplicate execution under client retries: the network drops the response, the client retries, the handler runs twice, the bank books two transfers.IdempotencyLayer solves this by atomically
reserving (principal, key) before the handler runs and replaying the
captured response for any subsequent request with the same key.
Wiring
Pick the backend that fits your deployment. The trait is the same; the layer doesn’t care which store backs it.Postgres (cratestack-sqlx)
Redis (cratestack-redis)
- you already operate a Redis cluster for sessions / caching and don’t want a second durable store
- you want eviction handled by the data store — Redis drops keys on TTL,
no
garbage_collect()sweep needed - your idempotency window is short (minutes to hours) so durability vs Postgres matters less than the operational simplicity
- you already run Postgres for the application data and want one transactional system of record
- you need the idempotency rows for forensic reconstruction beyond their TTL, or want them to participate in logical replication
- you care about transactional consistency between the idempotency row and other writes the handler does
Request shape
Clients opt in by sendingIdempotency-Key:
State machine
For each request the store atomically returns one of:- Reserved — fresh claim. The handler runs and the response is persisted on completion.
- Replay — a prior execution under the same key + request hash has completed. The cached response is returned with
Idempotency-Replayed: true. - InFlight — another caller still holds the reservation. The layer returns
409 ConflictwithRetry-After: 1. - Conflict — the same key arrived with a different request body. The layer returns
422withidempotency_key_conflict, per the IETF draft.
POST /transfer?dry_run=true and POST /transfer?dry_run=false therefore hash differently — replays don’t cross
query-string-encoded operation modes.
Replay fidelity
Replays reproduce the original response’s:- status code
- body bytes
- every end-to-end response header the handler set (
Location,ETag,Cache-Control,Set-Cookie,Content-Type, …)
Connection, Transfer-Encoding) and framework-computed
headers (Content-Length, Date) are filtered at capture time and not
restored. A replay carries an additional Idempotency-Replayed: true
header so downstream observers can distinguish it from a live execution.
Reservation tokens
Every reservation carries a UUIDreservation_id. complete and release
require a matching token, so a handler that runs past the TTL and has its
row reclaimed by a retry cannot poison the newer reservation. The classical
TTL-overrun scenario degrades to a silent no-op rather than a wrong-response
write. This guarantee holds in both the Postgres and Redis stores.
The Redis store additionally guards release on status == 'in_flight'
(matching the Postgres store’s AND response_body IS NULL clause), so a
spurious release arriving after complete is also a no-op rather than
wiping the captured response.
Principal scoping
The layer derives a principal fingerprint from the request. Two callers sharing a key under different principals do not collide. The default fingerprint is a SHA-256 of theAuthorization header; services running
mTLS or session cookies override it:
Persistence
Postgres layout
SqlxIdempotencyStore ships an ensure_schema() helper that idempotently
creates the table and index. The DDL is:
SqlxIdempotencyStore::garbage_collect() deletes rows whose expires_at
has passed. Banks call it from a scheduled task; the request path takes
over a single expired row on demand but doesn’t sweep.
Redis layout
RedisIdempotencyStore writes one Redis hash per (principal, key) at:
: in user-supplied values. Hash fields hold the
request hash, status, reservation token, captured response bytes, and
timestamps — all as raw bytes (Redis hash values are binary-safe; no
base64 wrapping is needed).
Atomicity comes from three small Lua scripts: reserve_or_fetch,
complete, and release. They run via EVALSHA with a NOSCRIPT
fallback, so the script body is loaded into the Redis server once per
instance and reused across requests.
Eviction is driven by PEXPIREAT — there is no separate GC. When the
TTL passes, Redis drops the hash; the next reservation observes a
missing key and claims a fresh slot. A late complete or release
from the previous reservation finds a rotated token and becomes a
silent no-op.
Custom stores
TheIdempotencyStore trait is async + dyn-compatible. Banks running
multi-region deployments can plug in their own backing store — typically
a Postgres replica with logical replication, a globally-consistent
key-value store, or a Redis Enterprise CRDT cluster — so the reservation
guarantee holds across regions.
Caveats
The shipped Postgres and Redis stores enforce the reservation guarantee within one database instance (or Redis primary). Banks running active-active multi-region clusters must either:- accept that retries hitting a different region within the TTL race the reservation, or
- plug a globally-coordinated store into
IdempotencyStoreand bring that consensus layer’s latency budget into the request path.
principal_fingerprint. Services that share a backing store between
unrelated applications should namespace the key in the fingerprint
function — and, for Redis, choose a per-application key_prefix so
deployments can’t trample each other’s keys.