diff --git a/src/utils/__tests__/errorUtils.test.ts b/src/utils/__tests__/errorUtils.test.ts index ac460f9d..3cca6f55 100644 --- a/src/utils/__tests__/errorUtils.test.ts +++ b/src/utils/__tests__/errorUtils.test.ts @@ -231,6 +231,115 @@ describe('retryWithBackoff', () => { expect(err).toMatchObject({ status: 500 }); expect(fn).toHaveBeenCalledTimes(2); }); + + it('adds jitter to retry delays', async () => { + const delays: number[] = []; + const fn = vi + .fn() + .mockImplementation(() => { + const start = Date.now(); + return new Promise((_, reject) => { + setTimeout(() => reject({ status: 500 }), 0); + }).then(() => { + delays.push(Date.now() - start); + throw new Error('should not reach here'); + }); + }); + + const retryPromise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + jitterMs: 50, + }); + + // Attach error handler immediately to avoid Unhandled Rejection warning + const caughtPromise = retryPromise.catch((e) => e); + await vi.runAllTimersAsync(); + await caughtPromise; + + // With jitter, delays should vary between attempts + // The base delay is 100ms, with up to 50ms jitter + // So delays should be in range [100, 150] for first retry + expect(delays.length).toBeGreaterThan(0); + delays.forEach(delay => { + expect(delay).toBeGreaterThanOrEqual(100); + expect(delay).toBeLessThanOrEqual(150); + }); + }); + + it('consecutive retry calls produce different delays with jitter', async () => { + const callDelays: number[][] = []; + + // Simulate multiple concurrent retry calls + const promises = Array.from({ length: 10 }, async () => { + const delays: number[] = []; + const fn = vi + .fn() + .mockImplementation(() => { + const start = Date.now(); + return new Promise((_, reject) => { + setTimeout(() => reject({ status: 500 }), 0); + }).then(() => { + delays.push(Date.now() - start); + throw new Error('should not reach here'); + }); + }); + + const retryPromise = retryWithBackoff(fn, { + maxAttempts: 2, + initialDelayMs: 100, + jitterMs: 50, + }); + + const caughtPromise = retryPromise.catch((e) => e); + await vi.runAllTimersAsync(); + await caughtPromise; + + return delays; + }); + + const allDelays = await Promise.all(promises); + callDelays.push(...allDelays); + + // Extract first retry delay from each call + const firstRetryDelays = callDelays.map(d => d[0]).filter(d => d !== undefined); + + // With jitter, not all delays should be identical + // At least some should differ + const uniqueDelays = new Set(firstRetryDelays); + expect(uniqueDelays.size).toBeGreaterThan(1); + }); + + it('jitter does not exceed maxDelayMs', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce({ status: 500 }) + .mockRejectedValueOnce({ status: 500 }) + .mockResolvedValue('success'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 150, + jitterMs: 1000, // Large jitter to test maxDelay constraint + }); + + await vi.runAllTimersAsync(); + expect(await promise).toBe('success'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('jitterMs is configurable with default of 500ms', async () => { + const fn = vi.fn().mockResolvedValue('ok'); + + // Test with default jitter + await retryWithBackoff(fn, { maxAttempts: 1 }); + + // Test with custom jitter + await retryWithBackoff(fn, { maxAttempts: 1, jitterMs: 200 }); + + expect(fn).toHaveBeenCalledTimes(2); + }); }); // --------------------------------------------------------------------------- diff --git a/src/utils/errorUtils.ts b/src/utils/errorUtils.ts index d6d78509..77ef5d9e 100644 --- a/src/utils/errorUtils.ts +++ b/src/utils/errorUtils.ts @@ -207,6 +207,7 @@ export async function retryWithBackoff( initialDelayMs?: number; maxDelayMs?: number; backoffFactor?: number; + jitterMs?: number; }, ): Promise { const { @@ -214,10 +215,10 @@ export async function retryWithBackoff( initialDelayMs = 1000, maxDelayMs = 30000, backoffFactor = 2, + jitterMs = 500, } = options || {}; let lastError: any; - let delayMs = initialDelayMs; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { @@ -229,9 +230,11 @@ export async function retryWithBackoff( throw error; } - const actualDelay = Math.min(delayMs, maxDelayMs); + const actualDelay = Math.min( + initialDelayMs * Math.pow(backoffFactor, attempt - 1) + Math.random() * jitterMs, + maxDelayMs, + ); await new Promise((resolve) => setTimeout(resolve, actualDelay)); - delayMs *= backoffFactor; } }