cratestack_axum/idempotency/record.rs
1//! Persisted record + reservation-outcome state machine.
2
3use std::time::SystemTime;
4
5/// Persisted idempotency record returned on a replay. Banks need an
6/// invariant view of the captured response — the store rebuilds this from
7/// its persisted columns when the second caller asks to replay.
8///
9/// `response_headers` is an opaque blob produced by [`super::encode_headers`]
10/// at capture time and consumed by [`super::decode_headers`] on replay. The
11/// blob carries every end-to-end header the handler returned, including
12/// `Location`, `ETag`, cache directives, and `Content-Type` — replaying
13/// only the status + body would silently drop these and give a retry
14/// different observable behaviour from the original execution.
15#[derive(Debug, Clone)]
16pub struct IdempotencyRecord {
17 pub key: String,
18 pub principal_fingerprint: String,
19 pub request_hash: [u8; 32],
20 pub response_status: u16,
21 pub response_headers: Vec<u8>,
22 pub response_body: Vec<u8>,
23 pub created_at: SystemTime,
24 pub expires_at: SystemTime,
25}
26
27/// Outcome of an atomic `reserve_or_fetch` call.
28///
29/// The middleware uses this state machine to decide whether to run the
30/// handler, replay a cached response, or reject. Exactly one caller per
31/// `(principal, key)` ever gets `Reserved` — that's the contract banking
32/// flows like transfers rely on.
33#[derive(Debug, Clone)]
34pub enum ReservationOutcome {
35 /// This caller claimed the key. It MUST run the handler and then
36 /// invoke `complete` (success) or `release` (give up the
37 /// reservation so a retry can re-acquire). The `token` uniquely
38 /// identifies THIS reservation — `complete` and `release` only
39 /// write when the row still carries the same token, so a handler
40 /// that overran the TTL and had its row reclaimed by a retry
41 /// can't poison the newer reservation.
42 Reserved { token: uuid::Uuid },
43 /// Another caller already completed an execution with the same
44 /// request hash. The middleware returns the cached response.
45 Replay(IdempotencyRecord),
46 /// Another caller is currently executing under the same key + hash.
47 /// The middleware returns `409 Conflict` with `Retry-After: 1` so
48 /// the client retries shortly.
49 InFlight,
50 /// Same key was claimed by a different request body — the IETF
51 /// draft's `idempotency_key_conflict` (422).
52 Conflict,
53}