fix: round 3 red-team — test false-positive, dead code,

edge-case coverage

- Fix false-positive test: use CCSError with recoverable=false
  instead of plain Error (never exercised recoverable check)
- Remove redundant retryableCheck ?? defaultRetryableCheck
  (destructuring already defaults)
- Add test: retryAfter > maxDelayMs (server directive wins)
- Add test: baseDelayMs=0 produces immediate retries
- Add test: onRetry not called when maxRetries=0
This commit is contained in:
Tam Nhu Tran
2026-04-30 15:16:07 -04:00
parent 18e865ea36
commit b8ed36e370
2 changed files with 46 additions and 4 deletions
+45 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, mock, spyOn } from 'bun:test';
import { withRetry, type RetryOptions } from '../retry-strategy';
import { RetryableError } from '../../errors/error-types';
import { CCSError, RetryableError } from '../../errors/error-types';
describe('withRetry', () => {
it('returns the result on first success', async () => {
@@ -38,8 +38,8 @@ describe('withRetry', () => {
expect(fn).toHaveBeenCalledTimes(1);
});
it('does not retry errors with recoverable=false', async () => {
const fn = mock(() => Promise.reject(new Error('non-retryable')));
it('does not retry CCSErrors with recoverable=false', async () => {
const fn = mock(() => Promise.reject(new CCSError('non-retryable', 1, false)));
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).rejects.toThrow('non-retryable');
expect(fn).toHaveBeenCalledTimes(1);
});
@@ -211,4 +211,46 @@ describe('withRetry', () => {
);
expect(fn).not.toHaveBeenCalled();
});
it('retryAfter can exceed maxDelayMs (server directive wins)', async () => {
const sleepSpy = spyOn(globalThis, 'setTimeout');
let attempt = 0;
const fn = mock(() => {
attempt++;
if (attempt < 2) {
return Promise.reject(new RetryableError('rate limited', undefined, 500));
}
return Promise.resolve('ok');
});
await withRetry(fn, { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 100 });
// retryAfter=500 > maxDelayMs=100 — server directive takes precedence
const delay = sleepSpy.mock.calls[0][1] as number;
expect(delay).toBeGreaterThanOrEqual(500);
sleepSpy.mockRestore();
});
it('baseDelayMs=0 produces immediate retries', async () => {
let attempt = 0;
const fn = mock(() => {
attempt++;
if (attempt < 3) {
return Promise.reject(new RetryableError('fail'));
}
return Promise.resolve('ok');
});
const result = await withRetry(fn, { maxRetries: 5, baseDelayMs: 0 });
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(3);
});
it('does not call onRetry when maxRetries=0', async () => {
const onRetry = mock(() => {});
const fn = mock(() => Promise.reject(new RetryableError('fail')));
await expect(withRetry(fn, { maxRetries: 0, baseDelayMs: 1, onRetry })).rejects.toThrow('fail');
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -99,7 +99,7 @@ export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions):
throw new Error('withRetry: baseDelayMs must be >= 0');
}
const isRetryable = retryableCheck ?? defaultRetryableCheck;
const isRetryable = retryableCheck;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {