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