- Add digest field to store sha256(method|path|body) separately from key - Enables detection of "same key, different body" in future tasks - Update idempotent.wo insert to compute and store digest value - Typechecker passes: exit 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 3a9bddcd1e3c11f5b371ce54cafc373685ca08b6)
110 lines
No EOL
4.1 KiB
Text
110 lines
No EOL
4.1 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
|
|
use json
|
|
|
|
-- 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();
|
|
-- Compute digest of method|path|body
|
|
let digest_input = "${req.method}|${req.path}|${req.body}";
|
|
let digest = sha256(bytes_of_text(digest_input));
|
|
let digest_hex = base64_encode(bytes_slice(digest, 0, 16));
|
|
try insert IdempotencyKey { key: key, response: resp_json, created_at: now, digest: digest_hex } 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}";
|
|
} |