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}