Skip to main content

cratestack_core/store/
idempotency.rs

1//! `IdempotencyStore` trait and companion types for duplicate-execution protection.
2
3use std::time::SystemTime;
4
5use async_trait::async_trait;
6
7use crate::CoolError;
8use crate::idempotency_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 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#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn max_body_bytes_is_2_megabytes() {
71        assert_eq!(MAX_BODY_BYTES, 2 * 1024 * 1024);
72    }
73}