use std::net::IpAddr;
use axum::extract::ConnectInfo;
use axum::http::request::Parts;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::class::IdempotencyClass;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Principal {
AuthToken(Vec<u8>),
Ip(IpAddr),
Did(String),
Anonymous,
}
impl Principal {
pub fn hash(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
match self {
Principal::AuthToken(bytes) => {
hasher.update(b"auth-token:");
hasher.update(bytes);
}
Principal::Did(did) => {
hasher.update(b"did:");
hasher.update(did.as_bytes());
}
Principal::Ip(ip) => {
hasher.update(b"ip:");
hasher.update(ip.to_string().as_bytes());
}
Principal::Anonymous => {
hasher.update(b"anonymous");
}
}
hasher.finalize().into()
}
}
pub fn principal_from_request(parts: &Parts) -> Principal {
if let Some(auth) = parts.headers.get(axum::http::header::AUTHORIZATION) {
return Principal::AuthToken(auth.as_bytes().to_vec());
}
if let Some(ConnectInfo(addr)) = parts.extensions.get::<ConnectInfo<std::net::SocketAddr>>() {
return Principal::Ip(addr.ip());
}
Principal::Anonymous
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EntryState {
InFlight,
#[default]
Completed,
CompletedNotRetained,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CacheEntry {
#[serde(default)]
pub state: EntryState,
pub idempotency_key: String,
pub request_hash: [u8; 32],
pub response_status: u16,
pub response_headers: Vec<(String, String)>,
pub response_body: Vec<u8>,
pub class: IdempotencyClass,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl CacheEntry {
pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
self.expires_at <= now
}
pub fn is_in_flight(&self) -> bool {
self.state == EntryState::InFlight
}
pub fn has_replayable_response(&self) -> bool {
self.state == EntryState::Completed
}
}
#[derive(Clone)]
pub struct IdempotencyStore {
ks: KeyspaceHandle,
}
impl IdempotencyStore {
pub fn new(ks: KeyspaceHandle) -> Self {
Self { ks }
}
pub async fn get(
&self,
principal_hash: &[u8; 32],
key: &str,
) -> Result<Option<CacheEntry>, AppError> {
let storage_key = storage_key(principal_hash, key);
let entry: Option<CacheEntry> = self.ks.get(storage_key).await?;
let now = Utc::now();
Ok(entry.filter(|e| !e.is_expired(now)))
}
pub async fn put(&self, principal_hash: &[u8; 32], entry: &CacheEntry) -> Result<(), AppError> {
let storage_key = storage_key(principal_hash, &entry.idempotency_key);
self.ks.insert(storage_key, entry).await
}
pub async fn claim(
&self,
principal_hash: &[u8; 32],
key: &str,
request_hash: [u8; 32],
class: IdempotencyClass,
in_flight_grace: chrono::Duration,
) -> Result<ClaimOutcome, AppError> {
let now = Utc::now();
let pending = CacheEntry {
state: EntryState::InFlight,
idempotency_key: key.to_string(),
request_hash,
response_status: 0,
response_headers: Vec::new(),
response_body: Vec::new(),
class,
created_at: now,
expires_at: now + chrono::Duration::seconds(class.ttl_seconds() as i64),
};
let sk = storage_key(principal_hash, key);
if self.ks.insert_if_absent(sk.clone(), &pending).await? {
return Ok(ClaimOutcome::Claimed);
}
let Some(existing): Option<CacheEntry> = self.ks.get(sk.clone()).await? else {
self.ks.insert(sk, &pending).await?;
return Ok(ClaimOutcome::Claimed);
};
if existing.is_expired(now) {
self.ks.insert(sk, &pending).await?;
return Ok(ClaimOutcome::Claimed);
}
if existing.request_hash != request_hash {
return Ok(ClaimOutcome::Conflict);
}
if existing.is_in_flight() {
if now - existing.created_at > in_flight_grace {
self.ks.insert(sk, &pending).await?;
return Ok(ClaimOutcome::Claimed);
}
return Ok(ClaimOutcome::InFlight);
}
Ok(ClaimOutcome::Completed(Box::new(existing)))
}
pub async fn complete(
&self,
principal_hash: &[u8; 32],
key: &str,
response: Option<CompletedResponse>,
) -> Result<(), AppError> {
let sk = storage_key(principal_hash, key);
let Some(mut entry): Option<CacheEntry> = self.ks.get(sk.clone()).await? else {
return Ok(());
};
match response {
Some(r) => {
entry.state = EntryState::Completed;
entry.response_status = r.status;
entry.response_headers = r.headers;
entry.response_body = r.body;
}
None => {
entry.state = EntryState::CompletedNotRetained;
entry.response_status = 0;
entry.response_headers = Vec::new();
entry.response_body = Vec::new();
}
}
self.ks.insert(sk, &entry).await
}
pub async fn release(&self, principal_hash: &[u8; 32], key: &str) -> Result<(), AppError> {
self.ks.remove(storage_key(principal_hash, key)).await
}
}
#[derive(Debug, Clone)]
pub struct CompletedResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
#[derive(Debug)]
pub enum ClaimOutcome {
Claimed,
InFlight,
Completed(Box<CacheEntry>),
Conflict,
}
fn storage_key(principal_hash: &[u8; 32], key: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(64 + key.len() + 5);
out.extend_from_slice(b"idem:");
out.extend_from_slice(hex::encode(principal_hash).as_bytes());
out.push(b':');
out.extend_from_slice(key.as_bytes());
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::store::Store;
use chrono::Duration;
fn temp_store() -> (IdempotencyStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&cfg).expect("store");
let ks = store.keyspace("idempotency-test").expect("ks");
(IdempotencyStore::new(ks), dir)
}
fn sample_entry() -> CacheEntry {
let now = Utc::now();
CacheEntry {
state: EntryState::Completed,
idempotency_key: "key-1".into(),
request_hash: [0xAB; 32],
response_status: 201,
response_headers: vec![("content-type".into(), "application/json".into())],
response_body: br#"{"ok":true}"#.to_vec(),
class: IdempotencyClass::NonDestructive,
created_at: now,
expires_at: now
+ Duration::seconds(IdempotencyClass::NonDestructive.ttl_seconds() as i64),
}
}
#[test]
fn principal_hash_is_stable_and_distinct_across_kinds() {
let a = Principal::AuthToken(b"Bearer abc".to_vec());
let a_again = Principal::AuthToken(b"Bearer abc".to_vec());
let b = Principal::AuthToken(b"Bearer xyz".to_vec());
let ip = Principal::Ip(IpAddr::V4("127.0.0.1".parse().unwrap()));
let anon = Principal::Anonymous;
assert_eq!(a.hash(), a_again.hash());
assert_ne!(a.hash(), b.hash());
assert_ne!(a.hash(), ip.hash());
assert_ne!(a.hash(), anon.hash());
assert_ne!(ip.hash(), anon.hash());
}
#[tokio::test]
async fn put_then_get_returns_entry() {
let (store, _dir) = temp_store();
let principal = Principal::AuthToken(b"Bearer t".to_vec()).hash();
let entry = sample_entry();
store.put(&principal, &entry).await.unwrap();
let got = store.get(&principal, &entry.idempotency_key).await.unwrap();
assert_eq!(got.as_ref(), Some(&entry));
}
#[tokio::test]
async fn entries_are_scoped_by_principal() {
let (store, _dir) = temp_store();
let a = Principal::AuthToken(b"alice".to_vec()).hash();
let b = Principal::AuthToken(b"bob".to_vec()).hash();
let entry = sample_entry();
store.put(&a, &entry).await.unwrap();
let got_a = store.get(&a, &entry.idempotency_key).await.unwrap();
let got_b = store.get(&b, &entry.idempotency_key).await.unwrap();
assert!(got_a.is_some());
assert!(got_b.is_none(), "principal scoping leaked");
}
const GRACE: chrono::Duration = chrono::Duration::minutes(10);
#[tokio::test]
async fn a_second_claim_on_the_same_request_does_not_also_win() {
let (store, _d) = temp_store();
let p = Principal::Did("did:web:alice".into()).hash();
let first = store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
assert!(matches!(first, ClaimOutcome::Claimed));
let second = store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
assert!(
matches!(second, ClaimOutcome::InFlight),
"a concurrent attempt must be told to wait, got {second:?}"
);
}
#[tokio::test]
async fn a_completed_claim_replays_its_response() {
let (store, _d) = temp_store();
let p = Principal::Did("did:web:alice".into()).hash();
store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
store
.complete(
&p,
"k",
Some(CompletedResponse {
status: 201,
headers: vec![],
body: b"body".to_vec(),
}),
)
.await
.expect("complete");
match store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim")
{
ClaimOutcome::Completed(e) => {
assert!(e.has_replayable_response());
assert_eq!(e.response_status, 201);
assert_eq!(e.response_body, b"body".to_vec());
}
other => panic!("expected a completed replay, got {other:?}"),
}
}
#[tokio::test]
async fn an_unretained_completion_dedups_without_offering_a_body() {
let (store, _d) = temp_store();
let p = Principal::Did("did:web:alice".into()).hash();
store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
store.complete(&p, "k", None).await.expect("complete");
match store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim")
{
ClaimOutcome::Completed(e) => {
assert_eq!(e.state, EntryState::CompletedNotRetained);
assert!(!e.has_replayable_response());
assert!(e.response_body.is_empty());
}
other => panic!("expected a completed record, got {other:?}"),
}
}
#[tokio::test]
async fn the_same_key_with_a_different_request_conflicts() {
let (store, _d) = temp_store();
let p = Principal::Did("did:web:alice".into()).hash();
store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
let other = store
.claim(&p, "k", [2u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
assert!(
matches!(other, ClaimOutcome::Conflict),
"a different body under the same key must conflict, got {other:?}"
);
}
#[tokio::test]
async fn conflict_is_reported_ahead_of_in_flight() {
let (store, _d) = temp_store();
let p = Principal::Did("did:web:alice".into()).hash();
store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
let other = store
.claim(&p, "k", [9u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
assert!(matches!(other, ClaimOutcome::Conflict), "got {other:?}");
}
#[tokio::test]
async fn a_stale_in_flight_claim_is_reclaimed() {
let (store, _d) = temp_store();
let p = Principal::Did("did:web:alice".into()).hash();
store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
let again = store
.claim(
&p,
"k",
[1u8; 32],
IdempotencyClass::NonDestructive,
chrono::Duration::zero(),
)
.await
.expect("claim");
assert!(matches!(again, ClaimOutcome::Claimed), "got {again:?}");
}
#[tokio::test]
async fn releasing_a_claim_frees_the_key() {
let (store, _d) = temp_store();
let p = Principal::Did("did:web:alice".into()).hash();
store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
store.release(&p, "k").await.expect("release");
let again = store
.claim(&p, "k", [1u8; 32], IdempotencyClass::NonDestructive, GRACE)
.await
.expect("claim");
assert!(matches!(again, ClaimOutcome::Claimed), "got {again:?}");
}
#[tokio::test]
async fn claims_are_scoped_by_principal() {
let (store, _d) = temp_store();
let alice = Principal::Did("did:web:alice".into()).hash();
let bob = Principal::Did("did:web:bob".into()).hash();
store
.claim(
&alice,
"k",
[1u8; 32],
IdempotencyClass::NonDestructive,
GRACE,
)
.await
.expect("claim");
let bobs = store
.claim(
&bob,
"k",
[1u8; 32],
IdempotencyClass::NonDestructive,
GRACE,
)
.await
.expect("claim");
assert!(
matches!(bobs, ClaimOutcome::Claimed),
"one caller's key must not block another's, got {bobs:?}"
);
}
#[test]
fn did_principals_are_distinct_and_stable() {
let a = Principal::Did("did:web:alice".into());
assert_eq!(a.hash(), Principal::Did("did:web:alice".into()).hash());
assert_ne!(a.hash(), Principal::Did("did:web:bob".into()).hash());
assert_ne!(
a.hash(),
Principal::AuthToken(b"did:web:alice".to_vec()).hash()
);
}
#[test]
fn a_record_without_a_state_member_reads_as_completed() {
let json = serde_json::json!({
"idempotency_key": "k",
"request_hash": vec![0u8; 32],
"response_status": 200,
"response_headers": [],
"response_body": [],
"class": "NonDestructive",
"created_at": "2026-01-01T00:00:00Z",
"expires_at": "2036-01-01T00:00:00Z",
});
let e: CacheEntry = serde_json::from_value(json).expect("legacy record decodes");
assert_eq!(e.state, EntryState::Completed);
assert!(e.has_replayable_response());
}
#[tokio::test]
async fn expired_entries_are_filtered_at_read_time() {
let (store, _dir) = temp_store();
let principal = Principal::AuthToken(b"Bearer t".to_vec()).hash();
let mut entry = sample_entry();
entry.expires_at = Utc::now() - Duration::seconds(1);
store.put(&principal, &entry).await.unwrap();
let got = store.get(&principal, &entry.idempotency_key).await.unwrap();
assert!(got.is_none(), "stale entry served");
}
#[tokio::test]
async fn put_overwrites_existing_entry_under_same_key() {
let (store, _dir) = temp_store();
let principal = Principal::AuthToken(b"Bearer t".to_vec()).hash();
let first = sample_entry();
store.put(&principal, &first).await.unwrap();
let mut second = first.clone();
second.response_status = 204;
second.response_body = b"updated".to_vec();
store.put(&principal, &second).await.unwrap();
let got = store.get(&principal, &first.idempotency_key).await.unwrap();
assert_eq!(got.unwrap().response_status, 204);
}
}