March Docs

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

typeIdempotencyOptsIdempotencyOpts = {#

Functions

fndefault_optsdefault_opts() : IdempotencyOpts#

Default options: 24-hour TTL, no scope prefix.

fninvalidateinvalidate(key : String)#

Invalidate a cached idempotency key (e.g. after a compensating transaction).

fninvalidate_with_scopeinvalidate_with_scope(key : String, scope : String)#

Invalidate a cached idempotency key under a given scope.

fnis_cachedis_cached(key : String) : Bool#

Return true if an idempotency-key response is currently cached. Useful in tests to assert that a key was stored after a successful request.

fnis_cached_with_scopeis_cached_with_scope(key : String, scope : String) : Bool#

Return true if an idempotency-key response is cached under a given scope.

fnoptsopts(ttl_secs : Int, scope : String) : IdempotencyOpts#

Build custom options with a TTL and an optional scope prefix.

fnprotectprotect(conn, handler_fn : () -> Conn)#

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)
fnprotect_withprotect_with(conn, handler_fn : () -> Conn, opts : IdempotencyOpts)#

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))