Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code cb0f44a7c3 Redis lock contention on CreateDraftFromWorkflowVersion exceeds 5s retry budget
https://sonarly.com/issue/6236?type=bug

The `CreateDraftFromWorkflowVersion` mutation uses a Redis distributed lock keyed on `workflowId`. When a concurrent request for the same workflow cannot acquire the lock within 50 retries × 100ms = 5 seconds, it throws "Failed to acquire lock".

Fix: Changed `CacheLockService.withLock` to throw `ConflictException` (NestJS 409) instead of a plain `Error` when lock acquisition fails after max retries.

**Why this fixes the bug:**
- `ConflictException` is an `HttpException` with status 409, which the existing exception pipeline handles correctly:
  - `shouldCaptureException` returns `false` for `HttpException` with status < 500 → **stops noisy Sentry alerts**
  - `convertHttpExceptionToGraphql` maps 409 to `ConflictError` → **user sees "Conflict" instead of "Internal Server Error"**
- Lock contention is semantically a conflict (two requests competing for the same resource), making 409 the correct HTTP status

**What this does NOT change:**
- Lock timing parameters (ms, maxRetries, ttl) remain unchanged — those were deliberately tuned in commits ee648197df and f131a86cc5
- The retry logic is unchanged — the fix only changes the exception type after retries are exhausted

Updated the test to assert `ConflictException` type instead of error message string matching.
2026-03-11 14:51:25 +00:00
2 changed files with 5 additions and 4 deletions
@@ -1,3 +1,4 @@
import { ConflictException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
@@ -58,7 +59,7 @@ describe('CacheLockService', () => {
expect(cacheStorageService.releaseLock).toHaveBeenCalledWith('key');
});
it('should throw an error if lock cannot be acquired after max retries', async () => {
it('should throw a ConflictException if lock cannot be acquired after max retries', async () => {
cacheStorageService.acquireLock.mockResolvedValue(false);
const fn = jest.fn();
@@ -67,7 +68,7 @@ describe('CacheLockService', () => {
await expect(
service.withLock(fn, 'key', { ms, maxRetries }),
).rejects.toThrow('Failed to acquire lock for key: key');
).rejects.toThrow(ConflictException);
expect(cacheStorageService.acquireLock).toHaveBeenCalledTimes(maxRetries);
expect(fn).not.toHaveBeenCalled();
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
@@ -42,6 +42,6 @@ export class CacheLockService {
await this.delay(ms);
}
throw new Error(`Failed to acquire lock for key: ${key}`);
throw new ConflictException(`Failed to acquire lock for key: ${key}`);
}
}