Troubleshooting
Common issues and how to fix them.
Fingerprint Mismatch Error
Problem: Error on legitimate retry
Surfaces as HTTP 422
IdempotencyFingerprintMismatchError extends RedisXError rather than HttpException, but the plugin registers a built-in exception filter that maps it to HTTP 422 Unprocessable Entity. You do not need to add your own filter; if you want a different status you can still register one (NestJS uses the most specific filter).
Symptoms:
- First request succeeds
- Retry with same key but different request data fails
- Server returns HTTP 422; logs show
IdempotencyFingerprintMismatchError/ "Fingerprint mismatch"
Causes:
- Request data changed:
// First request
POST /payments
Idempotency-Key: pay-123
Body: { amount: 100 }
→ Success
// Retry with DIFFERENT data
POST /payments
Idempotency-Key: pay-123
Body: { amount: 200 } // ← Changed!
→ 422 Fingerprint mismatchSolution: Use same data on retry, or generate new key for different data.
- Timestamp in fingerprint:
// Request includes timestamp
Body: { amount: 100, timestamp: Date.now() }
// On retry, timestamp changed!
Body: { amount: 100, timestamp: Date.now() }
→ Different fingerprintSolution: Exclude volatile fields from fingerprint:
new IdempotencyPlugin({
fingerprintGenerator: (ctx) => {
const req = ctx.switchToHttp().getRequest();
const { timestamp, requestId, ...data } = req.body;
return createHash('sha256')
.update(JSON.stringify(data))
.digest('hex');
},
})- Object key order:
// First request
Body: { amount: 100, currency: "USD" }
// Retry with different order
Body: { currency: "USD", amount: 100 }
→ Different fingerprint (if not normalized)Solution: Normalize object keys:
fingerprintGenerator: (ctx) => {
const req = ctx.switchToHttp().getRequest();
const normalized = JSON.stringify(
req.body,
Object.keys(req.body).sort()
);
return createHash('sha256').update(normalized).digest('hex');
}Missing Idempotency-Key Header
Problem: No idempotency key in request
Symptoms:
- Requests are NOT deduplicated (the interceptor skips them when no key is present)
- No error is thrown for a missing key — duplicate operations may occur
Solutions:
- Add header to request:
// ✅ Correct
fetch('/api/payments', {
method: 'POST',
headers: {
'Idempotency-Key': crypto.randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
// ❌ Wrong - missing header
fetch('/api/payments', {
method: 'POST',
body: JSON.stringify(data),
});- Check header name:
// Server expects (default):
'Idempotency-Key'
// Client sends:
'idempotency-key' // ❌ Case-sensitive!- Idempotency is already optional per request:
Requests that arrive without an idempotency key are passed straight through — the interceptor skips deduplication when no key is present. There is no "require key" enforcement to disable, and errorPolicy does not affect this (it governs store-unavailable behavior, not key presence; see Configuration). If you want a missing key to be an error, enforce it in your own validation/guard.
Timeout Errors
Problem: Request Timeout while waiting for a concurrent request
Surfaces as HTTP 409
IdempotencyTimeoutError extends RedisXError rather than HttpException, but the plugin's built-in exception filter maps it to HTTP 409 Conflict (a concurrent request with the same key is still in progress). Register your own filter only if you need a different status.
Symptoms:
- A concurrent request with the same key waits longer than
waitTimeoutfor the in-flight request to complete - Server returns HTTP 409; logs show
IdempotencyTimeoutError/ "Timeout waiting for completion"
Causes:
- First request too slow:
// Handler takes 2 minutes
@Post('process')
@Idempotent()
async process() {
await this.slowOperation(); // 2 min
}
// But timeout is 60 seconds
waitTimeout: 60000 // ← Too short!Solution: Increase timeouts:
new IdempotencyPlugin({
lockTimeout: 120000, // 2 minutes
waitTimeout: 240000, // 4 minutes
})- Lock not released:
@Post('process')
@Idempotent()
async process() {
try {
await this.work();
} catch (error) {
// Error thrown but lock not released!
throw error;
}
}Solution: Lock is automatically released on error. Check for deadlocks.
Duplicate Operations
Problem: Operation executes multiple times
Symptoms:
- Same payment charged twice
- Multiple emails sent
- Duplicate database records
Causes:
- No idempotency key:
// Client doesn't send key
POST /payments
// No Idempotency-Key headerSolution: Always send idempotency key:
const key = crypto.randomUUID();
await fetch('/api/payments', {
headers: { 'Idempotency-Key': key },
});- Different keys:
// First request
Idempotency-Key: key-1
// Retry with different key
Idempotency-Key: key-2 // ← New key = new operation!Solution: Reuse same key on retry.
- Decorator not applied:
// ❌ Wrong - no decorator
@Post('payments')
async createPayment() {}
// ✅ Correct
@Post('payments')
@Idempotent()
async createPayment() {}Retrying a Previously-Failed Key
Problem: Retry of a failed request keeps returning HTTP 409
Symptoms:
- First attempt with a key throws inside the handler (the operation failed)
- Retrying with the same key returns HTTP 409 for a while, then eventually works
Why this happens:
When the handler throws, the interceptor records the key as failed. A subsequent retry of the same key sees the failed record and throws IdempotencyFailedError. Like the other idempotency errors, this extends RedisXError (not HttpException), but the plugin's built-in exception filter maps it to HTTP 409 Conflict.
The failed record is short-lived: it is stored with an explicit TTL equal to lockTimeout (default 30000 ms = 30s). While it exists, every retry of the same key returns HTTP 409; once it expires, a fresh attempt with the same key is allowed. (Successful responses, by contrast, are cached for the full defaultTtl.)
Implications:
- A client that retries immediately after a failure will receive HTTP 409 until the
lockTimeoutwindow passes — it cannot retry successfully right away. - To allow an immediate clean retry, use a new idempotency key for the retry, or explicitly delete the failed key (see Inspect Redis Keys).
TIP
If you need a different HTTP status for failed/mismatch cases, register a NestJS exception filter that maps IdempotencyFailedError and IdempotencyFingerprintMismatchError to the status codes you want (NestJS uses the most specific filter, so it overrides the built-in one).
Redis Connection Issues
Problem: Redis unavailable
Symptoms:
- All requests fail
- Error: "Redis connection refused"
Solutions:
- Check Redis is running:
redis-cli ping
# Should return: PONG- Check connection config:
RedisModule.forRoot({
clients: {
host: 'localhost', // Correct host?
port: 6379, // Correct port?
},
})Choose fail-open vs fail-closed
By default (errorPolicy: 'fail-closed') an unreachable store causes the request to be rejected — the store error propagates and surfaces as a server error. If availability matters more than deduplication, set errorPolicy: 'fail-open' so the request proceeds without idempotency protection (a warning is logged) while Redis is down. See Configuration.
TTL Issues
Problem: Key expires too quickly
Symptoms:
- Client retries after TTL
- New operation created instead of returning cached
- "This shouldn't have charged twice"
Solution: Increase TTL:
new IdempotencyPlugin({
defaultTtl: 86400, // 24 hours instead of 1 hour
})Guidelines:
| Operation | TTL | Reason |
|---|---|---|
| Payments | 24-48h | Critical, users may retry next day |
| Orders | 4-24h | Session-based |
| Webhooks | 24-72h | External systems may replay |
Memory Issues
Problem: Redis memory growing
Symptoms:
- Redis memory usage increasing
OOMerrors- Slow Redis responses
Causes:
- TTL not set:
// ❌ Records never expire
defaultTtl: 0 // Don't do this!
// ✅ Set reasonable TTL
defaultTtl: 86400 // 24 hours- TTL too long:
// ❌ 30 days for everything
defaultTtl: 2592000
// ✅ Match to use case
payments: 86400, // 24h
orders: 3600, // 1h
webhooks: 86400, // 24hSolution: Adjust TTL and monitor memory:
# Check Redis memory
redis-cli INFO memory
# Check key count
redis-cli DBSIZE
# Find keys by pattern
redis-cli --scan --pattern 'idempotency:*'Common Errors
All idempotency errors extend RedisXError (a plain Error), but the plugin registers a built-in exception filter that maps them to meaningful HTTP statuses out of the box — fingerprint mismatch to 422, previously-failed and timeout to 409, and a missing key passes through with no error. You only need your own filter if you want different statuses or a custom payload.
| Error | Default HTTP status | Cause | Fix |
|---|---|---|---|
IdempotencyFingerprintMismatchError | 422 | Same key, different request data | Use same data or a new key; add a filter to change the status/payload |
IdempotencyFailedError | 409 | Retry of a key whose first attempt failed (sticky until ~lockTimeout expiry) | Use a new key or wait for the key to expire |
IdempotencyTimeoutError | 409 | Concurrent request waited longer than waitTimeout | Increase timeouts; speed up handler |
IdempotencyKeyRequiredError | 400 | Idempotency key required but not provided | Send an Idempotency-Key header |
| Missing key | n/a (passed through) | No idempotency key on the request | Deduplication is skipped; send a key to enable it |
| Redis error / unexpected | 500 | Store unavailable with fail-closed (default), or unmapped error | Keep Redis healthy; set errorPolicy: 'fail-open' to proceed during outages |
Debug Checklist
- [ ] Redis is running and accessible
- [ ] Plugin registered in module
- [ ] @Idempotent decorator applied
- [ ] Client sends Idempotency-Key header
- [ ] Same key used on retry
- [ ] Same request data on retry
- [ ] TTL appropriate for operation
- [ ] Timeouts configured correctly
- [ ] Fingerprint validation appropriate
Debugging Tools
Inspect Redis Keys
# List all idempotency keys
redis-cli --scan --pattern 'idempotency:*'
# Get specific key (stored as hash)
redis-cli HGETALL idempotency:payment-123
# Check TTL
redis-cli TTL idempotency:payment-123
# Delete specific key
redis-cli DEL idempotency:payment-123Enable Debug Logging
Use NestJS logger to see idempotency debug output:
const app = await NestFactory.create(AppModule, {
logger: ['debug', 'log', 'warn', 'error'],
});Test Endpoints
# Test with curl
KEY=$(uuidgen)
# First request
curl -i -X POST http://localhost:3000/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 100}'
# Duplicate (should return cached)
curl -i -X POST http://localhost:3000/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 100}'
# Mismatch (same key, different body) — returns HTTP 422
# (IdempotencyFingerprintMismatchError, mapped by the built-in exception filter)
curl -i -X POST http://localhost:3000/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 200}'Getting Help
If still stuck:
- Enable debug logging
- Check Redis with
redis-cli - Verify request headers with
curl -i - Check server logs
- Test with simple case first
- Review Configuration and Fingerprinting docs
Next Steps
- Monitoring — Track operations
- Overview — Back to overview