Skip to main content

stateset_db/
http_idempotency.rs

1//! Durable HTTP idempotency response store.
2//!
3//! Backs the HTTP layer's `Idempotency-Key` middleware with the database so
4//! that replays survive process restarts and work across replicas sharing a
5//! database. Rows live in the `http_idempotency_keys` table, keyed by
6//! `(tenant, idempotency_key)`, and carry the request fingerprint
7//! (method + path + body hash) plus the stored response.
8//!
9//! TTL expiry is enforced lazily: [`HttpIdempotencyRepository::get`] deletes
10//! and ignores rows created before the caller-supplied cutoff, and
11//! [`HttpIdempotencyRepository::purge_expired`] performs bulk cleanup sweeps.
12
13use chrono::{DateTime, Utc};
14use stateset_core::Result;
15
16/// A durable idempotency entry: request fingerprint plus the stored response.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct HttpIdempotencyRecord {
19    /// Tenant namespace (empty string for single-tenant deployments).
20    pub tenant: String,
21    /// Client-chosen idempotency key.
22    pub idempotency_key: String,
23    /// Fingerprint of the originating request (hex SHA-256 of
24    /// method + path + body).
25    pub request_fingerprint: String,
26    /// HTTP status code of the stored response.
27    pub response_status: u16,
28    /// `Content-Type` of the stored response, if any.
29    pub content_type: Option<String>,
30    /// Raw response body bytes.
31    pub response_body: Vec<u8>,
32    /// When the entry was first stored (drives TTL expiry).
33    pub created_at: DateTime<Utc>,
34}
35
36/// Repository over the `http_idempotency_keys` table.
37pub trait HttpIdempotencyRepository: Send + Sync {
38    /// Fetch the entry for `(tenant, key)`.
39    ///
40    /// Rows created strictly before `expired_before` are treated as expired:
41    /// they are deleted (lazy cleanup) and `None` is returned.
42    fn get(
43        &self,
44        tenant: &str,
45        key: &str,
46        expired_before: DateTime<Utc>,
47    ) -> Result<Option<HttpIdempotencyRecord>>;
48
49    /// Insert a new entry. Returns `false` (without overwriting) when an entry
50    /// for `(tenant, key)` already exists — first write wins across replicas.
51    fn put(&self, record: &HttpIdempotencyRecord) -> Result<bool>;
52
53    /// Delete all entries created strictly before `expired_before`, returning
54    /// the number of rows removed.
55    fn purge_expired(&self, expired_before: DateTime<Utc>) -> Result<u64>;
56}