Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions backend/src/idempotency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ pub struct CachedResponse {
pub status: u16,
pub body: serde_json::Value,
pub recorded_at: Instant,
/// Hash of the request payload to detect reuse with different payloads
pub payload_hash: String,
}

/// Shared in-memory idempotency store.
Expand Down Expand Up @@ -53,31 +55,48 @@ impl IdempotencyStore {
format!("{endpoint}:{invoice_id}:{idempotency_key}")
}

/// Return the cached response if the key exists and has not expired.
pub fn get(&self, key: &str) -> Option<CachedResponse> {
/// Return the cached response if the key exists, has not expired, and matches the payload hash.
/// Returns None if the key doesn't exist or has expired.
/// Panics if the key exists but payload doesn't match (idempotency violation).
pub fn get(&self, key: &str, payload_hash: &str) -> Result<Option<CachedResponse>, String> {
if let Some(entry) = self.inner.get(key) {
if entry.recorded_at.elapsed() < self.ttl {
return Some(entry.clone());
// Key exists and is fresh - validate payload matches
if entry.payload_hash != payload_hash {
return Err("Idempotency-Key reused with different request payload".to_string());
}
return Ok(Some(entry.clone()));
}
// Expired — evict lazily.
drop(entry);
self.inner.remove(key);
}
None
Ok(None)
}

/// Insert a response into the store.
pub fn insert(&self, key: String, status: u16, body: serde_json::Value) {
/// Insert a response into the store with payload hash for idempotency validation.
pub fn insert(&self, key: String, status: u16, body: serde_json::Value, payload_hash: String) {
self.inner.insert(
key,
CachedResponse {
status,
body,
recorded_at: Instant::now(),
payload_hash,
},
);
}

/// Compute a hash of the request payload for idempotency validation.
/// This ensures the same Idempotency-Key isn't reused with different payloads.
pub fn hash_payload(payload: &serde_json::Value) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
payload.to_string().hash(&mut hasher);
format!("{:x}", hasher.finish())
}

/// Remove every entry whose TTL has elapsed. Called both lazily on lookup
/// (via [`Self::get`]) and periodically by the background sweeper so that
/// entries are evicted even when they are never looked up again.
Expand Down
4 changes: 3 additions & 1 deletion backend/src/rate_limiter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,11 @@ fn build_rate_limit_response(retry_after_secs: u64, limit: u64, reset: u64) -> R
"error": "Too many requests. Please retry after the indicated number of seconds.",
"retryAfter": retry_after_secs,
});
// Retry-After header must be a non-negative integer (seconds) per RFC 7231
let retry_after_value = retry_after_secs.max(1).to_string();
let mut response = (
StatusCode::TOO_MANY_REQUESTS,
[("retry-after", retry_after_secs.to_string())],
[("retry-after", retry_after_value)],
Json(body),
)
.into_response();
Expand Down