CE-012 — idempotency.rs stores keys in memory only — process restart loses all idempotency guarantees
Affected Files
src/idempotency.rs — IdempotencyStore using DashMap (primary bug site)
src/payment/mod.rs — payment submission logic that calls idempotency.check_and_set()
src/main.rs — where IdempotencyStore is constructed and shared
src/config.rs — Redis connection config; idempotency TTL config
docker-compose.yml — Redis service definition
Root Cause
The IdempotencyStore uses a DashMap (concurrent in-memory hash map) to store idempotency keys:
// src/idempotency.rs — current (BUGGY)
pub struct IdempotencyStore {
entries: DashMap<String, Entry>,
ttl: Duration, // 24 hours
}
#[derive(Clone)]
struct Entry {
response: CachedResponse,
expires_at: Instant,
}
impl IdempotencyStore {
pub fn new(ttl: Duration) -> Self {
Self { entries: DashMap::new(), ttl }
}
pub fn check_and_set(&self, key: &str, response: CachedResponse) -> Option<CachedResponse> {
if let Some(entry) = self.entries.get(key) {
if entry.expires_at > Instant::now() {
return Some(entry.response.clone()); // duplicate request
}
}
self.entries.insert(key.to_string(), Entry {
response,
expires_at: Instant::now() + self.ttl,
});
None // first request
}
}
When the process restarts (deploy, crash, OOM), the DashMap is gone. A client that:
- Sends a payment with
Idempotency-Key: pay-xyz-123
- Receives a network timeout (doesn't know if the payment went through)
- Retries with the same
Idempotency-Key: pay-xyz-123
...will receive a fresh payment execution if the process restarted between the original request and the retry, resulting in a duplicate payment.
The 24-hour TTL also means all keys expire together if there's a burst of traffic — there's no per-key TTL refresh and no eviction beyond TTL.
Impact
- Severity: High — Duplicate payments are possible whenever the engine restarts. In a Kubernetes rolling deploy, at least one pod restarts every deploy. Mobile clients that retry on timeout (standard behavior) are the most likely to trigger this.
- Financial impact: a user initiating a $100 XLM→USDC swap could have two swaps executed if a deploy happens between their request and retry
- The
DashMap also has unbounded growth — with 24h TTL and any volume, it accumulates entries in memory indefinitely until the process restarts
Fix
Replace the DashMap with Redis-backed persistence using SET NX EX (set if not exists, with expiry):
// src/idempotency.rs — FIXED with Redis
use redis::AsyncCommands;
pub struct IdempotencyStore {
redis: Arc<redis::Client>,
ttl_seconds: u64, // 24 * 3600 = 86400
key_prefix: String, // "idem:"
}
impl IdempotencyStore {
pub async fn check_and_set(
&self,
idempotency_key: &str,
response: &CachedResponse,
) -> Result<Option<CachedResponse>, IdempotencyError> {
let redis_key = format!("{}:{}", self.key_prefix, idempotency_key);
let mut conn = self.redis.get_async_connection().await?;
// Try to SET the key only if it doesn't exist (NX), with TTL (EX)
let serialized = serde_json::to_string(response)?;
let set_result: Option<String> = conn
.set_options(&redis_key, &serialized, SetOptions::default().nx().ex(self.ttl_seconds))
.await?;
if set_result.is_some() {
// We set it — this is the first request
return Ok(None);
}
// Key already exists — return cached response
let existing: String = conn.get(&redis_key).await?;
let cached: CachedResponse = serde_json::from_str(&existing)?;
Ok(Some(cached))
}
}
For resilience when Redis is unavailable, define a degradation policy:
// Option A: Fail closed — reject all payments when Redis is down (safest for financial ops)
Err(IdempotencyError::RedisUnavailable) => {
return (StatusCode::SERVICE_UNAVAILABLE, Json(error_body(
"IDEMPOTENCY_UNAVAILABLE",
"payment processing temporarily unavailable; please retry in a moment"
))).into_response();
}
Additional Files to Update
src/idempotency.rs — replace DashMap with Redis; add check_and_set async function
src/main.rs — pass Redis client to IdempotencyStore::new()
src/payment/mod.rs — await check_and_set call; handle IdempotencyError::RedisUnavailable
src/config.rs — add idempotency_ttl_hours: u64 config field
docker-compose.yml — ensure Redis maxmemory-policy is set (see BE-024 for Redis config)
tests/idempotency_tests.rs — add: restart simulation (drop and recreate store) shows key is gone with memory store, shows key is preserved with Redis store
CE-012 — idempotency.rs stores keys in memory only — process restart loses all idempotency guarantees
Affected Files
src/idempotency.rs—IdempotencyStoreusingDashMap(primary bug site)src/payment/mod.rs— payment submission logic that callsidempotency.check_and_set()src/main.rs— whereIdempotencyStoreis constructed and sharedsrc/config.rs— Redis connection config; idempotency TTL configdocker-compose.yml— Redis service definitionRoot Cause
The
IdempotencyStoreuses aDashMap(concurrent in-memory hash map) to store idempotency keys:When the process restarts (deploy, crash, OOM), the
DashMapis gone. A client that:Idempotency-Key: pay-xyz-123Idempotency-Key: pay-xyz-123...will receive a fresh payment execution if the process restarted between the original request and the retry, resulting in a duplicate payment.
The 24-hour TTL also means all keys expire together if there's a burst of traffic — there's no per-key TTL refresh and no eviction beyond TTL.
Impact
DashMapalso has unbounded growth — with 24h TTL and any volume, it accumulates entries in memory indefinitely until the process restartsFix
Replace the
DashMapwith Redis-backed persistence usingSET NX EX(set if not exists, with expiry):For resilience when Redis is unavailable, define a degradation policy:
Additional Files to Update
src/idempotency.rs— replaceDashMapwith Redis; addcheck_and_setasync functionsrc/main.rs— pass Redis client toIdempotencyStore::new()src/payment/mod.rs— awaitcheck_and_setcall; handleIdempotencyError::RedisUnavailablesrc/config.rs— addidempotency_ttl_hours: u64config fielddocker-compose.yml— ensure Redismaxmemory-policyis set (see BE-024 for Redis config)tests/idempotency_tests.rs— add: restart simulation (drop and recreate store) shows key is gone with memory store, shows key is preserved with Redis store