- normalise self.key_header via to_lower before the req.headers lookup - req.headers keys are already lowercased on read (internal/parse.wo); the documented key_header: "Idempotency-Key" never matched, silently disabling idempotency (falls through to inner.handle) on every request - key/digest lookups use the normalised name consistently - digest now always includes method+path, body appended only when include_body is set -- a bare "" digest under include_body:false previously matched any other request reusing the same key - log a genuine pool_begin trap instead of silently folding it into 503 - update the two doc comments describing the old, unsafe shape Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 91099cfbb2fb9a2d351894e444fed306b25557d5)
121 lines
6.2 KiB
Text
121 lines
6.2 KiB
Text
-- porch/middleware/idempotent.wo — idempotency, actor-run.
|
|
-- Iteration 1 of the porch track, porch-store task 4.
|
|
--
|
|
-- Rebuilt, not patched: the old before/after shape ran the handler and
|
|
-- stored the response afterward, so two simultaneous duplicates both
|
|
-- missed and both executed — before() has nothing to find until after()
|
|
-- runs, which is too late for the request that is racing it. The fix is
|
|
-- that the ACTOR runs the handler: Idempotent wraps the route's own
|
|
-- Handler and hands both the request and that handler to the pool. A
|
|
-- duplicate for the same key then simply waits in the actor's mailbox
|
|
-- (one message at a time) and is dequeued once the owner's receive has
|
|
-- already committed the row — no in-flight heuristic needed, because
|
|
-- there is no window where a duplicate can see "nothing yet".
|
|
--
|
|
-- `call`'s reply is a copyable scalar only (WO-E226): keypool.wo's kind-2
|
|
-- arm answers with the SAME pool_pack(count, remaining_ms) encoding kind-1
|
|
-- uses, never the response itself. The response travels through the
|
|
-- @table instead — the actor stores it, this file reads the same row
|
|
-- back and builds the Resp from it, so owner and duplicate answer with
|
|
-- byte-identical bytes structurally, not by careful bookkeeping.
|
|
use http
|
|
use json
|
|
|
|
-- Idempotent wraps a route's Handler. Registration: Idempotent { key_header:
|
|
-- "Idempotency-Key", pool: p, inner: CreateOrder {} } in place of the bare
|
|
-- handler in app.post(...) -- key_header is matched case-insensitively
|
|
-- (req.headers keys are lowercased on read, so any case here works). Only
|
|
-- the response allowlists content-type, location, etag, cache-control for
|
|
-- replay (never Set-Cookie, never Date) — cookies arrive in porch 2.
|
|
pub class Idempotent {
|
|
key_header: Text -- e.g., "Idempotency-Key" (matched case-insensitively)
|
|
pool: Pool
|
|
inner: Handler -- the real route handler; the actor runs this
|
|
include_body: Bool = true -- digest method+path+body, refuse a key reused with a different one
|
|
ttl: Int = 86_400_000_000 -- 24h in µs, lazy-expired on access
|
|
|
|
fn handle(req: Req) -> Resp {
|
|
-- req.headers keys are lowercased on read (internal/parse.wo); normalise
|
|
-- key_header the same way, or a documented `key_header: "Idempotency-Key"`
|
|
-- (capitalised, as app authors are told to write it) NEVER matches and
|
|
-- this middleware silently falls through to self.inner.handle(req) below
|
|
-- on every request -- idempotency disabled, no 503, no log.
|
|
let name = to_lower(self.key_header);
|
|
let header_val = req.headers[name];
|
|
if header_val == nil { return self.inner.handle(req); }
|
|
|
|
let key = "idem:${name}:${header_val}";
|
|
-- method+path scope the digest unconditionally: with include_body false
|
|
-- a bare "" digest would match ANY other request under this same key,
|
|
-- letting a different route/method replay this one's stored response.
|
|
let digest_input = "${req.method}|${req.path}";
|
|
if self.include_body { digest_input = "${digest_input}|${req.body}"; }
|
|
let digest = base64_encode(bytes_slice(sha256(bytes_of_text(digest_input)), 0, 16));
|
|
|
|
let raw = try pool_begin(self.pool, key, digest, self.ttl, req, self.inner) catch (e) {
|
|
print_err("idempotent: pool_begin trapped: ${e.msg}");
|
|
nil
|
|
};
|
|
if raw == nil {
|
|
let r = Resp { status: 503, headers: {}, body: "{\"error\":\"idempotency store saturated\"}" };
|
|
set_header(r, "content-type", "application/json");
|
|
set_header(r, "retry-after", "1");
|
|
return r;
|
|
}
|
|
|
|
let outcome = raw / 1_000_000_000;
|
|
if outcome == 2 {
|
|
-- Same key, a different request: refuse rather than serve the
|
|
-- other request's response.
|
|
let r = Resp { status: 422, headers: {}, body: "{\"error\":\"idempotency key reused with a different request\"}" };
|
|
set_header(r, "content-type", "application/json");
|
|
return r;
|
|
}
|
|
|
|
-- Outcome 1: the bare key names a durable (2xx/3xx) replay target --
|
|
-- this file never deletes it; it is the whole point of a durable row.
|
|
-- Outcome 3: this call's own 4xx/5xx answer lives under a row keyed
|
|
-- by a nonce (the reply's low digits) that nobody else's message
|
|
-- for this same bare key ever writes to -- rebuild that exact key
|
|
-- rather than reading the bare one, so a race with a LATER message
|
|
-- for this key (which never touches this row) can't hand back the
|
|
-- wrong response.
|
|
let lookup_key = key;
|
|
if outcome == 3 { lookup_key = "${key}#eph:${raw % 1_000_000_000}"; }
|
|
let hits = from k in IdempotencyKey where k.key == lookup_key take 1 select k;
|
|
if len(hits) == 0 { return server_error(); }
|
|
let row = hits[0];
|
|
let stored = json.decode(row.response) as IdempotentStoredResp;
|
|
if stored == nil { return server_error(); }
|
|
-- `.. ""` forces a fresh, independently-owned Text for every key/value
|
|
-- copied out of the decoded record: json.decode's Text values do not
|
|
-- survive being handed onward as-is once the decoded record itself
|
|
-- goes out of scope (a stale row read back corrupted mid-response
|
|
-- otherwise) — concat is documented to always allocate new owned text.
|
|
let hdrs: map<Text, Text> = {};
|
|
for k, v in stored.headers { hdrs[k .. ""] = v .. ""; }
|
|
let result = Resp { status: stored.status, headers: hdrs, body: stored.body .. "" };
|
|
if outcome == 3 {
|
|
-- Safe to delete here, unlike the shared bare-key row rounds 1/2
|
|
-- removed: the nonce that names this row was never handed to
|
|
-- anyone but this one call() reply, so no other request -- a
|
|
-- duplicate, a retry, anything -- can ever construct this exact
|
|
-- key to read it. Deleting it removes the leak AND the
|
|
-- nonce-wraparound collision (time.ticks() % 1_000_000_000 repeats
|
|
-- every ~1000s; a lingering row from an earlier failed attempt
|
|
-- landing on the same nonce would otherwise be there to collide
|
|
-- with, or worse, poison the actor's OWN unguarded insert on the
|
|
-- next failure for this key).
|
|
delete row;
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
-- JSON shape of IdempotencyKey.response. Allowlisted headers only:
|
|
-- content-type, location, etag, cache-control, never Set-Cookie or Date.
|
|
typedef IdempotentStoredResp = {
|
|
status: Int,
|
|
headers: map<Text, Text>,
|
|
body: Text
|
|
}
|