Skip to main content

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"#;