-- 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(...). Only the digest allowlists content-type for -- replay (never Set-Cookie, never Date) — cookies arrive in porch 2. pub class Idempotent { key_header: Text -- e.g., "Idempotency-Key" 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 { let header_val = req.headers[self.key_header]; if header_val == nil { return self.inner.handle(req); } let key = "idem:${self.key_header}:${header_val}"; let digest = ""; if self.include_body { let digest_input = "${req.method}|${req.path}|${req.body}"; 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) 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 = {}; 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, never Set-Cookie or Date. typedef IdempotentStoredResp = { status: Int, headers: map, body: Text }