From 0cdf0db2833ba75b49bab12641d500482230577c Mon Sep 17 00:00:00 2001 From: xtrial01 Date: Sun, 28 Jun 2026 13:18:14 +0100 Subject: [PATCH 1/3] perf(retry): add jitter to retryWithBackoff to prevent thundering herd (#740) --- src/utils/__tests__/errorUtils.test.ts | 109 +++++++++++++++++++++++++ src/utils/errorUtils.ts | 11 ++- 2 files changed, 116 insertions(+), 4 deletions(-) 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..386f109f 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; } } @@ -262,4 +265,4 @@ export class TypedError extends Error { super(message); this.name = 'TypedError'; } -} +} \ No newline at end of file From a00e1b853d322fd0ccafad816b530435eb8ae3b5 Mon Sep 17 00:00:00 2001 From: xtrial01 Date: Sun, 28 Jun 2026 13:39:29 +0100 Subject: [PATCH 2/3] fix(logging): replace require() with ESM-compatible import for async_hooks - Replace require('node:async_hooks') with top-level ESM import - Fixes ReferenceError: require is not defined in Next.js 15 ESM context - Maintains fallback behavior for environments without async context support Closes #772 --- src/lib/logging/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/logging/index.ts b/src/lib/logging/index.ts index e5fb9a15..8a08f801 100644 --- a/src/lib/logging/index.ts +++ b/src/lib/logging/index.ts @@ -3,14 +3,10 @@ import { logContextStorage as simpleStorage } from './context'; import type { AsyncContextStorage, LogContextStore } from './context'; // Try to enhance with Node's AsyncLocalStorage for proper async context tracking -import type { AsyncLocalStorage as NodeAsyncLocalStorage } from 'node:async_hooks'; +import { AsyncLocalStorage as NodeAsyncLocalStorage } from 'node:async_hooks'; let nodeAsyncLocalStorage: NodeAsyncLocalStorage | null = null; try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const asyncHooks = require('node:async_hooks') as typeof import('node:async_hooks'); - if (asyncHooks?.AsyncLocalStorage) { - nodeAsyncLocalStorage = new asyncHooks.AsyncLocalStorage(); - } + nodeAsyncLocalStorage = new NodeAsyncLocalStorage(); } catch { nodeAsyncLocalStorage = null; } From 9cc832b193dfe015f266ad30d04131466731277f Mon Sep 17 00:00:00 2001 From: xtrial01 Date: Sun, 28 Jun 2026 23:08:38 +0100 Subject: [PATCH 3/3] ci: retrigger workflow checks