writeonce/docs/examples/porch/middleware/idempotent.wo
shoney.arickathil bf343045ab feat(porch-store): Idempotent middleware (Phase C, in progress)
- replays a stored response for a repeated Idempotency-Key: before()
  checks the key, after() stores status/body/content-type on a 2xx/3xx
- key is "idem:<header>:<value>", optionally plus a sha256 digest of
  method|path|body when include_body is set
- 409 while a key is in flight (stored within the last 10s), lazy TTL
  expiry on access, default 24h
- replay allowlists content-type only — never Set-Cookie or Date
- backed by IdempotencyKey from Phase A (519d411)

Written by a parallel session and committed here as-is because its
branch was consolidated away. NOT verified: it calls json.decode and
json.encode without a `use json` import, which every other example that
uses json has. Left unedited rather than fixed blind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit aee79264095eea3b2c38c92789c44b31f1c9ef8a)
2026-09-15 01:15:30 +02:00

105 lines
No EOL
3.9 KiB
Text

-- porch/middleware/idempotent.wo — idempotency middleware backed by @table.
-- Stores successful responses keyed by Idempotency-Key header (+ optional body digest).
-- Replays stored response on subsequent requests with same key.
-- Iteration 1 of the porch track.
use time
use http
-- Idempotent middleware: before (check/replay) + after (store on miss).
-- Key composition: "idem:${header}" or "idem:${header}:${sha256(method|path|body)}"
-- Only stores status, body, content-type (allowlist). Never replays Set-Cookie, Date, etc.
-- In-flight collision: returns 409 if key exists but response not yet stored.
-- Lazy expiry: deletes expired keys on access (TTL default 24h).
pub class Idempotent {
key_header: Text -- e.g., "Idempotency-Key"
include_body: Bool = true -- digest method+path+body into key
ttl: Int = 86_400_000_000 -- 24h in µs
fn before(mut req: Req) -> ?Resp {
let header_val = req.headers[self.key_header];
if header_val == nil { return nil; }
let key = idempotent_key(self, header_val, req);
let now = time.ticks();
-- Look up existing key
let hits = from k in IdempotencyKey where k.key == key take 1 select k;
if len(hits) > 0 {
let stored = hits[0];
-- Check expiry
if now - stored.created_at > self.ttl {
-- Expired: delete and treat as miss
delete stored;
} else {
-- Check if response is stored (created_at within last 10s = in-flight)
if now - stored.created_at < 10_000_000 {
-- In-flight collision: another request with same key is being processed
let r = Resp { status: 409, headers: {}, body: "{\"error\":\"idempotency key in flight\"}" };
set_header(r, "content-type", "application/json");
return r;
}
-- Valid stored response: decode and replay
let resp_json = stored.response;
-- Parse JSON response (status, headers, body)
let resp = json.decode(resp_json) as IdempotentStoredResp;
if resp != nil {
let r = Resp { status: resp.status, headers: resp.headers, body: resp.body };
return r;
}
-- Corrupted stored response: delete and fall through to miss
delete stored;
}
}
-- Miss: mark request so after() knows to store the response
req.ctx["idem_miss"] = "true";
req.ctx["idem_key"] = key;
return nil;
}
fn after(req: Req, mut resp: Resp) {
-- Only store on successful responses (2xx/3xx) and only if before() was a miss
if req.ctx["idem_miss"] != "true" { return; }
if resp.status < 200 { return; }
if resp.status >= 400 { return; }
let key = req.ctx["idem_key"];
if key == nil { return; }
-- Allowlist headers for replay: only content-type
let hdrs: map<Text, Text> = {};
let ct = resp.headers["content-type"];
if ct != nil { hdrs["content-type"] = ct; }
let stored = IdempotentStoredResp {
status: resp.status,
headers: hdrs,
body: resp.body
};
let resp_json = json.encode(stored);
let now = time.ticks();
try insert IdempotencyKey { key: key, response: resp_json, created_at: now } catch (e) nil;
}
}
-- Internal typedef for JSON decode of stored response
typedef IdempotentStoredResp = {
status: Int,
headers: map<Text, Text>,
body: Text
}
-- Key composition function
pub fn idempotent_key(self: Idempotent, header_val: Text, req: Req) -> Text {
if self.key_header == "" { return "idem:${header_val}"; }
if self.include_body == false { return "idem:${self.key_header}:${header_val}"; }
-- Include body digest: sha256(method|path|body)
let digest_input = "${req.method}|${req.path}|${req.body}";
let digest = sha256(bytes_of_text(digest_input));
-- Take first 16 chars of hex digest for brevity (base64_encode of bytes)
let short_digest = base64_encode(bytes_slice(digest, 0, 16));
return "idem:${self.key_header}:${header_val}:${short_digest}";
}