use std::collections::HashMap;
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
pub const DEFAULT_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Clone)]
pub struct IdempotencyEntry {
pub request_body_hash: [u8; 32],
pub status: u16,
pub content_type: String,
pub body: Vec<u8>,
pub inserted_at: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct IdempotencyCacheKey {
pub client_id: String,
pub endpoint_path: String,
pub idempotency_key: String,
}
#[derive(Debug, Clone)]
pub enum IdempotencyVerdict {
Miss,
Hit(IdempotencyEntry),
Conflict {
cached_body_hash_hex: String,
},
}
#[derive(Debug)]
pub struct IdempotencyStore {
entries: HashMap<IdempotencyCacheKey, IdempotencyEntry>,
capacity: usize,
retention: Duration,
}
impl Default for IdempotencyStore {
fn default() -> Self {
Self::new(10_000, DEFAULT_RETENTION)
}
}
impl IdempotencyStore {
pub fn new(capacity: usize, retention: Duration) -> Self {
Self {
entries: HashMap::new(),
capacity,
retention,
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn hash_prefix_hex(hash: &[u8; 32]) -> String {
let mut s = String::with_capacity(16);
for byte in &hash[..8] {
s.push_str(&format!("{byte:02x}"));
}
s
}
pub fn hash_body(body: &[u8]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(body);
h.finalize().into()
}
pub fn lookup(
&mut self,
key: &IdempotencyCacheKey,
request_body_hash: &[u8; 32],
) -> IdempotencyVerdict {
let now = Instant::now();
let entry = match self.entries.get(key) {
Some(e) => e.clone(),
None => return IdempotencyVerdict::Miss,
};
if now.duration_since(entry.inserted_at) > self.retention {
self.entries.remove(key);
return IdempotencyVerdict::Miss;
}
if &entry.request_body_hash == request_body_hash {
IdempotencyVerdict::Hit(entry)
} else {
IdempotencyVerdict::Conflict {
cached_body_hash_hex: Self::hash_prefix_hex(&entry.request_body_hash),
}
}
}
pub fn insert(&mut self, key: IdempotencyCacheKey, entry: IdempotencyEntry) {
if self.entries.len() >= self.capacity && !self.entries.contains_key(&key) {
if let Some(oldest_key) = self
.entries
.iter()
.min_by_key(|(_, e)| e.inserted_at)
.map(|(k, _)| k.clone())
{
self.entries.remove(&oldest_key);
}
}
self.entries.insert(key, entry);
}
pub fn reap_expired(&mut self) -> usize {
let now = Instant::now();
let before = self.entries.len();
let retention = self.retention;
self.entries
.retain(|_, e| now.duration_since(e.inserted_at) <= retention);
before - self.entries.len()
}
pub fn set_retention(&mut self, retention: Duration) {
self.retention = retention;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(c: &str, p: &str, k: &str) -> IdempotencyCacheKey {
IdempotencyCacheKey {
client_id: c.to_string(),
endpoint_path: p.to_string(),
idempotency_key: k.to_string(),
}
}
fn entry(body: &str, status: u16) -> (IdempotencyEntry, [u8; 32]) {
let body_bytes = body.as_bytes().to_vec();
let hash = IdempotencyStore::hash_body(&body_bytes);
(
IdempotencyEntry {
request_body_hash: hash,
status,
content_type: "application/json".to_string(),
body: body_bytes,
inserted_at: Instant::now(),
},
hash,
)
}
#[test]
fn miss_on_empty_store() {
let mut s = IdempotencyStore::default();
let h = IdempotencyStore::hash_body(b"{}");
assert!(matches!(
s.lookup(&key("c1", "/p", "k1"), &h),
IdempotencyVerdict::Miss
));
}
#[test]
fn hit_on_same_key_and_body() {
let mut s = IdempotencyStore::default();
let (e, h) = entry("{\"amount\":42}", 200);
s.insert(key("c1", "/p", "k1"), e.clone());
let verdict = s.lookup(&key("c1", "/p", "k1"), &h);
match verdict {
IdempotencyVerdict::Hit(got) => {
assert_eq!(got.status, 200);
assert_eq!(got.body, e.body);
}
_ => panic!("expected Hit"),
}
}
#[test]
fn conflict_on_same_key_different_body() {
let mut s = IdempotencyStore::default();
let (e, _h) = entry("{\"amount\":42}", 200);
s.insert(key("c1", "/p", "k1"), e);
let h_other = IdempotencyStore::hash_body(b"{\"amount\":99}");
match s.lookup(&key("c1", "/p", "k1"), &h_other) {
IdempotencyVerdict::Conflict { cached_body_hash_hex } => {
assert_eq!(cached_body_hash_hex.len(), 16);
}
_ => panic!("expected Conflict"),
}
}
#[test]
fn cross_tenant_isolation() {
let mut s = IdempotencyStore::default();
let (e, h) = entry("{\"x\":1}", 200);
s.insert(key("c1", "/p", "k1"), e);
assert!(matches!(
s.lookup(&key("c2", "/p", "k1"), &h),
IdempotencyVerdict::Miss
));
assert!(matches!(
s.lookup(&key("c1", "/other", "k1"), &h),
IdempotencyVerdict::Miss
));
}
#[test]
fn retention_expiry_evicts_old_entry() {
let mut s = IdempotencyStore::new(10, Duration::from_millis(0));
let (e, h) = entry("{}", 200);
s.insert(key("c1", "/p", "k1"), e);
std::thread::sleep(Duration::from_millis(2));
assert!(matches!(
s.lookup(&key("c1", "/p", "k1"), &h),
IdempotencyVerdict::Miss
));
assert_eq!(s.len(), 0);
}
#[test]
fn reap_expired_returns_count() {
let mut s = IdempotencyStore::new(10, Duration::from_millis(0));
let (e1, _) = entry("{\"a\":1}", 200);
let (e2, _) = entry("{\"a\":2}", 200);
s.insert(key("c1", "/p", "k1"), e1);
s.insert(key("c1", "/p", "k2"), e2);
assert_eq!(s.len(), 2);
std::thread::sleep(Duration::from_millis(2));
assert_eq!(s.reap_expired(), 2);
assert_eq!(s.len(), 0);
}
#[test]
fn capacity_eviction_drops_oldest_on_overflow() {
let mut s = IdempotencyStore::new(2, DEFAULT_RETENTION);
let (e1, h1) = entry("{\"a\":1}", 200);
s.insert(key("c1", "/p", "k1"), e1);
std::thread::sleep(Duration::from_millis(1));
let (e2, _) = entry("{\"a\":2}", 200);
s.insert(key("c1", "/p", "k2"), e2);
std::thread::sleep(Duration::from_millis(1));
let (e3, _) = entry("{\"a\":3}", 200);
s.insert(key("c1", "/p", "k3"), e3);
assert_eq!(s.len(), 2);
assert!(matches!(
s.lookup(&key("c1", "/p", "k1"), &h1),
IdempotencyVerdict::Miss
));
}
#[test]
fn hash_prefix_hex_is_16_chars_lowercase() {
let h = IdempotencyStore::hash_body(b"hello");
let prefix = IdempotencyStore::hash_prefix_hex(&h);
assert_eq!(prefix.len(), 16);
for c in prefix.chars() {
assert!(c.is_ascii_hexdigit() && !c.is_ascii_uppercase());
}
}
#[test]
fn hash_body_deterministic() {
let a = IdempotencyStore::hash_body(b"{\"x\":1}");
let b = IdempotencyStore::hash_body(b"{\"x\":1}");
assert_eq!(a, b);
}
#[test]
fn hash_body_sensitive_to_whitespace() {
let a = IdempotencyStore::hash_body(b"{\"x\":1}");
let b = IdempotencyStore::hash_body(b"{ \"x\": 1 }");
assert_ne!(a, b);
}
}