fix: round 2 red-team fixes — onRetry safety, validation, barrel

- retry-strategy: wrap onRetry in try/catch to prevent
  callback errors from aborting retries
- retry-strategy: validate baseDelayMs >= 0
- retry-strategy: update JSDoc to clarify
  retryAfter/maxDelayMs interaction
- errors/index.ts: add ValidationError to barrel
- Tests: onRetry throw test, negative baseDelayMs test
This commit is contained in:
Tam Nhu Tran committed 2026-04-30 14:59:44 -04:00
1 parent 9bb1bdbad9
commit 18e865ea36
3 files changed
+39 -4

No files matched your search

+1
View File
@@ -37,6 +37,7 @@ export {
ProxyError, ProxyError,
MigrationError, MigrationError,
UserAbortError, UserAbortError,
ValidationError,
RetryableError, RetryableError,
isCCSError, isCCSError,
isRecoverableError, isRecoverableError,
@@ -184,4 +184,31 @@ describe('withRetry', () => {
expect(delay).toBeGreaterThanOrEqual(500); expect(delay).toBeGreaterThanOrEqual(500);
sleepSpy.mockRestore(); sleepSpy.mockRestore();
}); });
it('swallows onRetry callback errors and continues retrying', async () => {
let attempt = 0;
const fn = mock(() => {
attempt++;
if (attempt < 3) {
return Promise.reject(new RetryableError('fail'));
}
return Promise.resolve('ok');
});
const onRetry = mock(() => {
throw new Error('callback blew up');
});
const result = await withRetry(fn, { maxRetries: 5, baseDelayMs: 1, onRetry });
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(3);
expect(onRetry).toHaveBeenCalledTimes(2);
});
it('throws on negative baseDelayMs', async () => {
const fn = mock(() => Promise.resolve('ok'));
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: -1 })).rejects.toThrow(
'baseDelayMs must be >= 0'
);
expect(fn).not.toHaveBeenCalled();
});
}); });
+11 -4
View File
@@ -16,13 +16,13 @@ export interface RetryOptions {
maxRetries: number; maxRetries: number;
/** Base delay in ms for the first retry (default: 1000) */ /** Base delay in ms for the first retry (default: 1000) */
baseDelayMs: number; baseDelayMs: number;
/** Upper bound for the computed delay (default: 30000) */ /** Upper bound for the computed backoff delay. Note: server-provided `retryAfter` (from RetryableError) takes precedence and may exceed this cap. */
maxDelayMs?: number; maxDelayMs?: number;
/** Multiplier applied per attempt (default: 2) */ /** Multiplier applied per attempt (default: 2). Values <1 produce degrowth. */
backoffMultiplier?: number; backoffMultiplier?: number;
/** Override the default retryability check */ /** Override the default retryability check */
retryableCheck?: (error: unknown) => boolean; retryableCheck?: (error: unknown) => boolean;
/** Callback fired before each retry (not fired on initial call) */ /** Callback fired before each retry. Errors thrown by this callback are swallowed to prevent aborting the retry loop. */
onRetry?: (error: Error, attempt: number) => void; onRetry?: (error: Error, attempt: number) => void;
} }
@@ -95,6 +95,9 @@ export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions):
if (maxRetries < 0) { if (maxRetries < 0) {
throw new Error('withRetry: maxRetries must be >= 0'); throw new Error('withRetry: maxRetries must be >= 0');
} }
if (baseDelayMs < 0) {
throw new Error('withRetry: baseDelayMs must be >= 0');
}
const isRetryable = retryableCheck ?? defaultRetryableCheck; const isRetryable = retryableCheck ?? defaultRetryableCheck;
let lastError: unknown; let lastError: unknown;
@@ -116,7 +119,11 @@ export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions):
} }
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
onRetry?.(err, attempt + 1); try {
onRetry?.(err, attempt + 1);
} catch {
// Swallow callback errors — retry decision is already made
}
const retryAfter = error instanceof RetryableError ? error.retryAfter : undefined; const retryAfter = error instanceof RetryableError ? error.retryAfter : undefined;
const delay = computeDelay(attempt, baseDelayMs, maxDelayMs, backoffMultiplier, retryAfter); const delay = computeDelay(attempt, baseDelayMs, maxDelayMs, backoffMultiplier, retryAfter);