This implementation provides webhook idempotency using Redis to prevent duplicate transaction processing when webhooks are delivered multiple times due to network retries.
- Webhooks must include an
X-Idempotency-Keyheader (typically theanchor_transaction_id) - This key uniquely identifies each webhook request
- Client sends webhook with
X-Idempotency-Key: transaction-123 - Middleware checks Redis for key
idempotency:transaction-123 - Key doesn't exist → Set key to "PROCESSING" with 5-minute TTL
- Process the webhook normally
- On success (2xx response) → Store response in Redis with 24-hour TTL
- On failure → Delete the key to allow retry
- Client sends same webhook while first is still processing
- Middleware finds key with value "PROCESSING"
- Return
429 Too Many Requestswith retry-after header - Client should wait and retry
- Client sends same webhook after successful processing
- Middleware finds key with cached response
- Return cached response (200 OK) with
cached: trueflag - No duplicate processing occurs
- Processing Lock: 5 minutes (prevents stuck locks from failed requests)
- Completed Response: 24 hours (prevents duplicate processing within reasonable window)
Redis is the primary store, but it isn't the only one — check_idempotency (src/middleware/idempotency.rs) has three paths, not one:
- Healthy Redis, cache hit → return the cached response from Redis.
- Healthy Redis, cache miss → before issuing a fresh Redis lock, consult the
idempotency_keysPostgres table. If a row exists there (written during a prior Redis outage — see path 3), recognize it asCompleted/Processinginstead of treating the key as brand new. This is what makes a retry after Redis recovers, for a request that was originally recorded during an outage, come back as a duplicate instead of executing twice. Emitsidempotency_db_fallback_recovered_totalwhen this path finds a row. - Redis unreachable → fall back entirely to the
idempotency_keystable: check for an existing row, or insert one withstatus = 'processing'(lock_token: None).store_responsecorrespondingly writes to Postgres wheneverlock_tokenisNone.
Path 2 is the fix for the gap that used to exist here: before it was added, the healthy-path lookup only ever checked Redis, so a request recorded via path 3 during an outage was invisible once Redis recovered, and a caller's well-intentioned retry (the entire point of an idempotency key) would double-execute. See tests/idempotency_recovery_test.rs for the regression test driving this exact degraded→healthy→retry sequence.
Note the DB fallback path (check_idempotency_key/insert_idempotency_key) is keyed only by key, not tenant_id — the Redis path scopes by tenant (idempotency:<tenant_id>:<key>) but the idempotency_keys table has no tenant column. Two different tenants using the same literal key string during an outage would collide in the DB fallback where they wouldn't in Redis. This is a pre-existing narrower gap, not introduced or fixed by the DB-fallback-recovery change — worth knowing about, not addressed here.
For on-call triage of a reported duplicate action from a past Redis outage window, see the runbook's "Triaging a Reported Duplicate Action from a Past Redis Outage" section — short version: SELECT * FROM idempotency_keys WHERE key = '<key>'.
REDIS_URL=redis://localhost:6379Redis is automatically configured in docker-compose.yml:
redis:
image: redis:7-alpine
ports:
- "6379:6379"curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: anchor-tx-12345" \
-d '{
"id": "webhook-001",
"anchor_transaction_id": "anchor-tx-12345"
}'{
"success": true,
"message": "Webhook webhook-001 processed successfully"
}Status: 200 OK
{
"error": "Request is currently being processed",
"retry_after": 5
}Status: 429 Too Many Requests
{
"cached": true,
"message": "Request already processed"
}Status: 200 OK
-
IdempotencyService (
src/middleware/idempotency.rs)- Manages Redis connections
- Provides methods for checking and storing idempotency state
- Handles lock acquisition and release
-
Idempotency Middleware (
src/middleware/idempotency.rs)- Axum middleware that wraps webhook handlers
- Extracts idempotency key from headers
- Coordinates request flow based on idempotency status
-
Webhook Handler (
src/handlers/webhook.rs)- Business logic for processing webhooks
- Protected by idempotency middleware
idempotency:{anchor_transaction_id} → "PROCESSING" | CachedResponse
- Start services:
docker-compose up -d- Send first request:
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: test-123" \
-d '{"id": "w1", "anchor_transaction_id": "test-123"}'- Send duplicate immediately (should get 429):
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: test-123" \
-d '{"id": "w1", "anchor_transaction_id": "test-123"}'- Wait a few seconds and send again (should get cached response):
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: test-123" \
-d '{"id": "w1", "anchor_transaction_id": "test-123"}'docker exec -it synapse-redis redis-cli
> KEYS idempotency:*
> GET idempotency:test-123
> TTL idempotency:test-123- Middleware fails open (allows request to proceed)
- Logs error for monitoring
- Prevents Redis outage from blocking all webhooks
- Processing lock expires after 5 minutes
- Allows retry if original request failed/hung
- Prevents permanent lock from crashed requests
- Key Validation: Idempotency keys are validated for proper format
- TTL Limits: Keys automatically expire to prevent Redis memory exhaustion
- Fail Open: Redis failures don't block legitimate requests
- No Sensitive Data: Only status codes and success flags stored in Redis
- Response Body Caching: Store full response body for exact replay
- Distributed Locking: Use Redlock algorithm for multi-instance deployments
- Metrics: Track duplicate request rates and cache hit ratios
- Configurable TTLs: Make TTL values configurable per environment