Skip to content

Concurrent Requests

Handle multiple requests with the same idempotency key arriving simultaneously.

The Problem

The Solution

Lock Mechanism

Redis Implementation

lua
-- 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
end

Lock States

StateDescriptionAction for New Request
Not LockedNo request processingAcquire lock, process
LockedFirst request processingWait for completion
ReleasedProcessing completeReturn 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:

typescript
// 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 cached

Configuration

Lock Timeout

Maximum time to hold lock:

typescript
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:

typescript
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

typescript
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:

typescript
@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

lua
-- 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
end

Test Scenario

typescript
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

typescript
// ✅ 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

typescript
// ❌ 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:

yaml
# 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

typescript
if (record.status === 'processing') {
  logger.info('Concurrent request detected', {
    key,
    waitTimeout,
    startedAt: record.startedAt,
    elapsed: Date.now() - record.startedAt,
  });
}

Next Steps

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.

Released under the MIT License.