Bastion.Idempotency
lib/idempotency.march — Bastion.Idempotency: idempotency-key middleware.
Implements Stripe-style idempotency keys for POST and PUT requests. When a client retries an operation with the same Idempotency-Key header, the cached response is replayed verbatim without re-executing the handler. This prevents duplicate charges, duplicate emails, and duplicate records.
── Flow ───────────────────────────────────────────────────────────────────────
Request has Idempotency-Key header?
NO → pass through normally (no enforcement)
YES → check Vault cache
HIT → return 200 with cached response (X-Idempotent-Replayed: true)
MISS → execute handler, cache result if 2xx, return result── Usage ──────────────────────────────────────────────────────────────────────
-- Protect all POST/PUT routes with default settings (24h TTL):
let c = Bastion.Idempotency.protect(conn, fn () -> router_plug(conn) end)
-- Custom TTL (1 hour) and optional scope prefix (e.g. user ID):
let scope = match Session.get(conn, "current_user_id") do
Some(uid) -> uid
None -> "anon"
end
let c = Bastion.Idempotency.protect_with(conn, fn () -> router_plug(conn) end,
Bastion.Idempotency.opts(3600, scope))Types
Functions
Default options: 24-hour TTL, no scope prefix.
Invalidate a cached idempotency key (e.g. after a compensating transaction).
Invalidate a cached idempotency key under a given scope.
Return true if an idempotency-key response is currently cached. Useful in tests to assert that a key was stored after a successful request.
Return true if an idempotency-key response is cached under a given scope.
Build custom options with a TTL and an optional scope prefix.
Protect a handler with idempotency-key semantics using default options.
handler_fn is a zero-arg function that produces the conn response.
let c = Bastion.Idempotency.protect(conn, fn () -> router_plug(conn) end)Protect a handler with idempotency-key semantics and custom options.
handler_fn — fn() → Conn, called only on cache miss
opts — IdempotencyOpts with ttl_secs and scope
let scope_key = Session.get(conn, "user_id") |> Option.unwrap_or("anon")
let c = Bastion.Idempotency.protect_with(conn, fn () -> router(conn) end,
Bastion.Idempotency.opts(3600, scope_key))