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)
This commit is contained in:
parent
b21cfde1bf
commit
bf343045ab
1 changed files with 105 additions and 0 deletions
105
docs/examples/porch/middleware/idempotent.wo
Normal file
105
docs/examples/porch/middleware/idempotent.wo
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
-- 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}";
|
||||
}
|
||||
Loading…
Reference in a new issue