idempotency

Idempotency-Key store for mutating HTTP APIs, backed by std.sql. Stripe-style create-refund semantics: double-clicks do not double-pay.

ecko get github.com/ecko-lang/idempotency
import idempotency

Pure computation: it declares no capabilities, so it cannot touch the network, the filesystem or the environment.

Version 0.5.0 - source - MIT.


init(db)

init(db) -> null. Creates the idempotency_keys table if it does not already exist. db is a std.sql connection the caller owns; call this once after opening it, before the first run.

db = sql.open(":memory:")
idempotency.init(db)

run(db, key, request_fingerprint, action, opts = empty_map())

run(db, key, request_fingerprint, action, opts?) -> the result of action().

The first call for key runs action(), stores its return value, and returns it. A later call with the same key and the same request_fingerprint returns the stored value without calling action again - the double-click case. A later call with the same key and a different fingerprint throws { kind: "idempotency", reason: "mismatch" }: the caller reused a key for a different request, which this package refuses rather than guess which one was intended. A call that arrives while an earlier call for the same key is still running throws { kind: "idempotency", reason: "in_progress" } rather than running action a second time.

If action throws, the key is released (its row deleted) before the error is re-thrown, so a retry with the same key runs action again. This package does not store failures - matching Stripe, which does not treat a 5xx-like failure as a result worth replaying.

action's return value must be JSON-serializable: it is stored with json.encode and restored with json.decode on replay.

opts (a map, all keys optional): ttl_seconds - how long the key is remembered, default 86400 (24h); 0 means never expire. now - a zero-argument function returning the current time in unix milliseconds, default time.now. Inject a fixed or stepped clock to test expiry deterministically.

fp = idempotency.fingerprint("POST", "/v1/refunds", { amount: 500 })
result = idempotency.run(db, "refund-42", fp, fn() charge_refund(42))

fingerprint(method, path, body)

fingerprint(method, path, body) -> a sha256 hex digest identifying a request.

body is JSON-encoded before hashing, so any JSON-serializable value works (a map, a list, a string, null). Two calls with the same method, path and body produce the same digest; run uses this to tell a legitimate replay from a caller reusing a key for a different request.

idempotency.fingerprint("POST", "/v1/refunds", { amount: 500 })

middleware(db, opts = empty_map())

middleware(db, opts?) -> a fn(req, next) for web.router's middleware list.

For POST/PATCH/DELETE requests carrying an Idempotency-Key header, wraps next(req) with run: a repeat request with the same key and the same method/path/body replays the stored response instead of calling next again; a repeat with a different body throws the same { kind: "idempotency", reason: "mismatch" } / "in_progress" errors as run (see idempotency.ecko). Requests with no key, and GET/PUT/HEAD requests, pass straight through to next untouched.

opts is passed to run as-is (ttl_seconds, now).

next(req)'s return value must be JSON-serializable for replay to work - a plain http.json(...) or http.text(...) response qualifies; a streamed or raw-bytes response does not (see README "Middleware").

app = web.router(routes, [idempotency.middleware(db, { ttl_seconds: 3600 })])