Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/lib/logging/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogContextStore> | 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<LogContextStore>();
}
nodeAsyncLocalStorage = new NodeAsyncLocalStorage<LogContextStore>();
} catch {
nodeAsyncLocalStorage = null;
}
Expand Down
109 changes: 109 additions & 0 deletions src/utils/__tests__/errorUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

// ---------------------------------------------------------------------------
Expand Down
11 changes: 7 additions & 4 deletions src/utils/errorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,17 +207,18 @@ export async function retryWithBackoff<T>(
initialDelayMs?: number;
maxDelayMs?: number;
backoffFactor?: number;
jitterMs?: number;
},
): Promise<T> {
const {
maxAttempts = 3,
initialDelayMs = 1000,
maxDelayMs = 30000,
backoffFactor = 2,
jitterMs = 500,
} = options || {};

let lastError: any;
let delayMs = initialDelayMs;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
Expand All @@ -229,9 +230,11 @@ export async function retryWithBackoff<T>(
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;
}
}

Expand Down Expand Up @@ -262,4 +265,4 @@ export class TypedError extends Error {
super(message);
this.name = 'TypedError';
}
}
}
Loading