Concurrent Requests
Handle multiple requests with the same idempotency key arriving simultaneously.
The Problem
The Solution
Lock Mechanism
Redis Implementation
-- Lua script for acquiring lock
local key = KEYS[1]
local token = ARGV[1]
local ttl = tonumber(ARGV[2])
-- Try to set lock
if redis.call("SET", key, token, "NX", "PX", ttl) then
return 1 -- Lock acquired
else
return 0 -- Already locked
endLock States
| State | Description | Action for New Request |
|---|---|---|
| Not Locked | No request processing | Acquire lock, process |
| Locked | First request processing | Wait for completion |
| Released | Processing complete | Return cached response |
Wait Mechanism
Polling Strategy
The wait mechanism is handled internally by checkAndLock(). When a second request arrives and the key is locked (processing), checkAndLock() polls Redis until the first request completes:
// Simplified internal logic (not a public API):
async function waitForCompletion(key: string, timeout: number): Promise<void> {
const interval = 100; // 100ms
const start = Date.now();
while (Date.now() - start < timeout) {
const record = await this.store.get(key);
if (record.status === 'completed' || record.status === 'failed') {
return; // Done!
}
// Still processing, wait and try again
await sleep(interval);
}
throw new IdempotencyTimeoutError(key);
}INFO
You do not call waitForCompletion() directly — checkAndLock() handles it automatically.
Timeline
t=0ms: Client 1 arrives → Lock acquired
t=10ms: Client 2 arrives → Wait...
t=100ms: Client 2 checks → Still processing
t=200ms: Client 2 checks → Still processing
t=500ms: Client 1 completes → Lock released
t=600ms: Client 2 checks → Completed! Return cachedConfiguration
Lock Timeout
Maximum time to hold lock:
new IdempotencyPlugin({
lockTimeout: 30000, // 30 seconds
})Request starts: t=0
Lock acquired: t=0
Lock stale: t=30000 (holder presumed dead — the next contender TAKES OVER)
Record kept: until t=60000 (2x lockTimeout retention)lockTimeout is a staleness threshold, not the record's lifetime. The processing record is retained for 2x lockTimeout: once the original attempt exceeds lockTimeout without completing (it crashed between locking and storing its response), the record still exists — so the next request or waiter takes the lock over atomically inside the Lua script. Exactly one contender wins and executes the handler; everyone else keeps waiting on the new owner's record. The takeover never fires while the holder is within its lock window.
Choose Wisely
lockTimeout must exceed your slowest legitimate handler: if it is too short a SLOW-BUT-ALIVE first attempt is presumed dead and a second execution starts in parallel. If too long, recovery after a real crash is delayed.
Wait Timeout
Maximum time to wait for concurrent request:
new IdempotencyPlugin({
waitTimeout: 60000, // 60 seconds
})Relationship:
waitTimeout should be >= lockTimeout + safety_margin
Example:
lockTimeout = 30000ms
waitTimeout = 60000ms (2x for safety)Error Handling
Timeout Error
import { IdempotencyTimeoutError } from '@nestjs-redisx/idempotency';
try {
await service.createPayment(key, dto);
} catch (error) {
if (error instanceof IdempotencyTimeoutError) {
// Concurrent request took too long
return {
error: 'Request timeout',
message: 'Operation is still processing, try again later',
};
}
}Custom Timeout Handler
By default the plugin's built-in exception filter already maps IdempotencyTimeoutError to HTTP 409 Conflict (the original request is still in progress). Register your own filter only if you want a different status or response shape — the example below customizes the payload while keeping the 409 status:
@Catch(IdempotencyTimeoutError)
export class TimeoutFilter implements ExceptionFilter {
catch(exception: IdempotencyTimeoutError, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
response.status(409).json({
statusCode: 409,
error: 'Conflict',
message: 'The original request is still processing',
retryAfter: 30,
idempotencyKey: exception.idempotencyKey,
});
}
}Race Condition Prevention
Atomic Operations
-- Atomic check-and-lock
local key = KEYS[1]
local token = ARGV[1]
local fingerprint = ARGV[2]
-- Check if exists
local existing = redis.call("GET", key)
if existing then
local data = cjson.decode(existing)
-- Validate fingerprint
if data.fingerprint ~= fingerprint then
return {-1, "fingerprint_mismatch"}
end
return {0, existing} -- Return existing
end
-- Doesn't exist, acquire lock
local success = redis.call("SET", key, cjson.encode({
status = "processing",
fingerprint = fingerprint,
startedAt = redis.call("TIME")[1]
}), "NX", "PX", 30000)
if success then
return {1, nil} -- Lock acquired
else
return {0, nil} -- Someone else got lock
endTest Scenario
describe('Concurrent requests', () => {
it('should handle simultaneous requests', async () => {
const key = 'payment-123';
const dto = { amount: 100 };
// Send 10 requests simultaneously
const requests = Array.from({ length: 10 }, () =>
service.createPayment(key, dto)
);
const results = await Promise.all(requests);
// All should get same result
const uniquePayments = new Set(results.map(r => r.id));
expect(uniquePayments.size).toBe(1);
// Verify only one payment created in database
const payments = await db.payments.find({ idempotencyKey: key });
expect(payments.length).toBe(1);
});
});Best Practices
Do
// ✅ Set appropriate timeouts
new IdempotencyPlugin({
lockTimeout: 30000, // 30s
waitTimeout: 60000, // 60s (2x lock)
})
// ✅ Handle timeout errors gracefully
@Catch(IdempotencyTimeoutError)
export class TimeoutHandler {
catch(error, host) {
// Return helpful message
}
}
// ✅ Log concurrent requests for monitoring
if (record.status === 'processing') {
logger.warn(`Concurrent request for key: ${key}`);
}Don't
// ❌ waitTimeout < lockTimeout
new IdempotencyPlugin({
lockTimeout: 60000,
waitTimeout: 30000, // Too short!
})
// ❌ Ignore timeout errors
try {
await service.create(key, dto);
} catch {
// Swallowing error - bad!
}
// ❌ Very short lockTimeout
new IdempotencyPlugin({
lockTimeout: 1000, // 1s - too short for payments!
})Monitoring
Metrics
When MetricsPlugin is registered, concurrent requests are tracked automatically:
# Replay rate (includes concurrent replays)
rate(redisx_idempotency_requests_total{status="replay"}[5m])
# Processing duration (includes wait time for concurrent requests)
histogram_quantile(0.95, rate(redisx_idempotency_duration_seconds_bucket[5m]))Logging
if (record.status === 'processing') {
logger.info('Concurrent request detected', {
key,
waitTimeout,
startedAt: record.startedAt,
elapsed: Date.now() - record.startedAt,
});
}Next Steps
- Configuration — Timeout settings
- Troubleshooting — Debug concurrency issues
When the first request dies mid-flight
If the process handling the first request crashes between acquiring the lock and storing the response (deploy, OOM, kill), its processing record expires after lockTimeout. Waiting requests do not fail: the first waiter to notice the vanished record atomically takes over the lock and executes the request itself; the remaining waiters continue waiting and replay the new owner's response. If nobody can take over within waitTimeout, the client receives a retryable 409 Conflict.