cratestack_axum/idempotency/store.rs
1//! `IdempotencyStore` trait + companion DDL.
2
3use std::time::SystemTime;
4
5use async_trait::async_trait;
6use cratestack_core::CoolError;
7
8use super::record::ReservationOutcome;
9
10/// Maximum body size the middleware will buffer when computing the hash. A
11/// request beyond this returns 413 rather than risking unbounded memory.
12pub(super) const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
13
14#[async_trait]
15pub trait IdempotencyStore: Send + Sync + 'static {
16 /// Atomically reserve `(principal, key)` for the caller, or report
17 /// the outcome of an existing reservation. Implementations MUST be
18 /// concurrent-safe: two simultaneous callers seeing the same key and
19 /// hash must observe exactly one `Reserved` and one `InFlight`,
20 /// never two `Reserved`. The `expires_at` argument bounds the
21 /// reservation's lifetime so a forgotten release doesn't pin the
22 /// key forever; when a retry reclaims an expired row the store
23 /// MUST rotate the reservation token so `complete`/`release` from
24 /// the original handler can no longer touch the newer slot.
25 async fn reserve_or_fetch(
26 &self,
27 principal: &str,
28 key: &str,
29 request_hash: [u8; 32],
30 expires_at: SystemTime,
31 ) -> Result<ReservationOutcome, CoolError>;
32
33 /// Persist the captured response for a previously-reserved key so
34 /// subsequent attempts replay it. Banks treat the IETF idempotency
35 /// contract as "freeze the outcome": if the handler returned 5xx,
36 /// retries see the same 5xx unless they use a fresh key. The
37 /// `token` must match the value returned by `reserve_or_fetch`
38 /// when this caller claimed the key; mismatched tokens are
39 /// silently no-ops so a stale handler whose reservation has been
40 /// reclaimed cannot overwrite a newer execution's response.
41 ///
42 /// `headers` is the encoded blob from [`super::encode_headers`] —
43 /// replays rebuild the response with the same `Location`, `ETag`,
44 /// `Cache-Control`, `Content-Type`, etc. that the original handler
45 /// set.
46 async fn complete(
47 &self,
48 principal: &str,
49 key: &str,
50 token: uuid::Uuid,
51 status: u16,
52 headers: &[u8],
53 body: &[u8],
54 ) -> Result<(), CoolError>;
55
56 /// Release a reservation without recording a completion (e.g. the
57 /// inner service panicked or the middleware itself errored before
58 /// the response was ready). Subsequent attempts with the same key
59 /// can re-reserve. As with `complete`, the `token` must match the
60 /// active reservation.
61 async fn release(&self, principal: &str, key: &str, token: uuid::Uuid)
62 -> Result<(), CoolError>;
63}
64
65/// SQL DDL for the idempotency table. Banks typically run migrations through
66/// their own tooling — `cratestack` currently ships migrations as raw DDL
67/// since the migration engine is deferred to Phase 3.
68pub const IDEMPOTENCY_TABLE_DDL: &str = r#"
69CREATE TABLE IF NOT EXISTS cratestack_idempotency (
70 principal_fingerprint TEXT NOT NULL,
71 key TEXT NOT NULL,
72 request_hash BYTEA NOT NULL,
73 reservation_id UUID NOT NULL,
74 response_status INT,
75 response_headers BYTEA,
76 response_body BYTEA,
77 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
78 expires_at TIMESTAMPTZ NOT NULL,
79 PRIMARY KEY (principal_fingerprint, key)
80);
81
82CREATE INDEX IF NOT EXISTS cratestack_idempotency_expires_idx
83 ON cratestack_idempotency (expires_at);
84"#;