mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-10 06:20:13 +00:00
refactor(errors): add RetryableError and retry-strategy utility
Extract retryable error class and reusable withRetry wrapper from scattered retry logic in glmt-proxy and binary/downloader.
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { CCSError, RetryableError, isCCSError, isRecoverableError } from '../error-types';
|
||||||
|
import { ExitCode } from '../exit-codes';
|
||||||
|
|
||||||
|
describe('RetryableError', () => {
|
||||||
|
it('extends CCSError', () => {
|
||||||
|
const err = new RetryableError('test');
|
||||||
|
expect(err).toBeInstanceOf(CCSError);
|
||||||
|
expect(err).toBeInstanceOf(RetryableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets name to RetryableError', () => {
|
||||||
|
const err = new RetryableError('test');
|
||||||
|
expect(err.name).toBe('RetryableError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets recoverable to true', () => {
|
||||||
|
const err = new RetryableError('test');
|
||||||
|
expect(err.recoverable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults exit code to GENERAL_ERROR', () => {
|
||||||
|
const err = new RetryableError('test');
|
||||||
|
expect(err.code).toBe(ExitCode.GENERAL_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes message through', () => {
|
||||||
|
const err = new RetryableError('something went wrong');
|
||||||
|
expect(err.message).toBe('something went wrong');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an optional cause', () => {
|
||||||
|
const cause = new Error('original');
|
||||||
|
const err = new RetryableError('wrapped', cause);
|
||||||
|
expect(err.cause).toBe(cause);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an optional retryAfter (ms)', () => {
|
||||||
|
const err = new RetryableError('rate limited', undefined, 5000);
|
||||||
|
expect(err.retryAfter).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults retryAfter to undefined', () => {
|
||||||
|
const err = new RetryableError('test');
|
||||||
|
expect(err.retryAfter).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is identified by isCCSError', () => {
|
||||||
|
const err = new RetryableError('test');
|
||||||
|
expect(isCCSError(err)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is identified as recoverable by isRecoverableError', () => {
|
||||||
|
const err = new RetryableError('test');
|
||||||
|
expect(isRecoverableError(err)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a proper stack trace', () => {
|
||||||
|
const err = new RetryableError('test');
|
||||||
|
expect(err.stack).toBeDefined();
|
||||||
|
expect(err.stack).toContain('RetryableError');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,6 +169,21 @@ export class ValidationError extends CCSError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retryable/transient error
|
||||||
|
* Signals that the operation may succeed on retry (e.g. rate limits, timeouts)
|
||||||
|
*/
|
||||||
|
export class RetryableError extends CCSError {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly cause?: Error,
|
||||||
|
public readonly retryAfter?: number // ms until next attempt
|
||||||
|
) {
|
||||||
|
super(message, ExitCode.GENERAL_ERROR, true);
|
||||||
|
this.name = 'RetryableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard to check if an error is a CCSError
|
* Type guard to check if an error is a CCSError
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export {
|
|||||||
ProxyError,
|
ProxyError,
|
||||||
MigrationError,
|
MigrationError,
|
||||||
UserAbortError,
|
UserAbortError,
|
||||||
|
RetryableError,
|
||||||
isCCSError,
|
isCCSError,
|
||||||
isRecoverableError,
|
isRecoverableError,
|
||||||
} from './error-types';
|
} from './error-types';
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, mock, spyOn } from 'bun:test';
|
||||||
|
import { withRetry, type RetryOptions } from '../retry-strategy';
|
||||||
|
import { RetryableError } from '../../errors/error-types';
|
||||||
|
|
||||||
|
describe('withRetry', () => {
|
||||||
|
it('returns the result on first success', async () => {
|
||||||
|
const fn = mock(() => Promise.resolve(42));
|
||||||
|
const result = await withRetry(fn, { maxRetries: 3, baseDelayMs: 10 });
|
||||||
|
expect(result).toBe(42);
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries on RetryableError and succeeds', async () => {
|
||||||
|
let attempt = 0;
|
||||||
|
const fn = mock(() => {
|
||||||
|
attempt++;
|
||||||
|
if (attempt < 3) {
|
||||||
|
return Promise.reject(new RetryableError('transient failure'));
|
||||||
|
}
|
||||||
|
return Promise.resolve('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await withRetry(fn, { maxRetries: 5, baseDelayMs: 1 });
|
||||||
|
expect(result).toBe('ok');
|
||||||
|
expect(fn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws after max retries exhausted', async () => {
|
||||||
|
const fn = mock(() => Promise.reject(new RetryableError('always fails')));
|
||||||
|
await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 })).rejects.toThrow('always fails');
|
||||||
|
// 1 initial + 2 retries = 3 total calls
|
||||||
|
expect(fn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry non-retryable errors', async () => {
|
||||||
|
const fn = mock(() => Promise.reject(new Error('fatal')));
|
||||||
|
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).rejects.toThrow('fatal');
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry errors with recoverable=false', async () => {
|
||||||
|
const fn = mock(() => Promise.reject(new Error('non-retryable')));
|
||||||
|
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).rejects.toThrow('non-retryable');
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom retryableCheck when provided', async () => {
|
||||||
|
let attempt = 0;
|
||||||
|
const fn = mock(() => {
|
||||||
|
attempt++;
|
||||||
|
if (attempt < 2) {
|
||||||
|
return Promise.reject(new Error('custom-retry'));
|
||||||
|
}
|
||||||
|
return Promise.resolve('recovered');
|
||||||
|
});
|
||||||
|
|
||||||
|
const customCheck = (error: unknown) =>
|
||||||
|
error instanceof Error && error.message === 'custom-retry';
|
||||||
|
|
||||||
|
const result = await withRetry(fn, {
|
||||||
|
maxRetries: 5,
|
||||||
|
baseDelayMs: 1,
|
||||||
|
retryableCheck: customCheck,
|
||||||
|
});
|
||||||
|
expect(result).toBe('recovered');
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRetry callback on each retry attempt', async () => {
|
||||||
|
const onRetry = mock(() => {});
|
||||||
|
const fn = mock(() => Promise.reject(new RetryableError('fail')));
|
||||||
|
|
||||||
|
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1, onRetry })).rejects.toThrow('fail');
|
||||||
|
|
||||||
|
// 1 initial + 3 retries = 3 onRetry calls (not called for initial)
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(3);
|
||||||
|
// First retry call
|
||||||
|
expect(onRetry.mock.calls[0][1]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects maxDelayMs cap', async () => {
|
||||||
|
const sleepSpy = spyOn(globalThis, 'setTimeout');
|
||||||
|
let attempt = 0;
|
||||||
|
const fn = mock(() => {
|
||||||
|
attempt++;
|
||||||
|
if (attempt <= 2) {
|
||||||
|
return Promise.reject(new RetryableError('fail'));
|
||||||
|
}
|
||||||
|
return Promise.resolve('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
await withRetry(fn, {
|
||||||
|
maxRetries: 5,
|
||||||
|
baseDelayMs: 1000,
|
||||||
|
maxDelayMs: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify setTimeout was called with delay <= maxDelayMs (200ms) + jitter buffer
|
||||||
|
// Jitter adds 0-20% of delay, so max possible is 240ms
|
||||||
|
for (const call of sleepSpy.mock.calls) {
|
||||||
|
const delay = call[1] as number;
|
||||||
|
expect(delay).toBeLessThanOrEqual(250); // allow small jitter overhead
|
||||||
|
}
|
||||||
|
sleepSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default backoffMultiplier when not specified', async () => {
|
||||||
|
let attempt = 0;
|
||||||
|
const fn = mock(() => {
|
||||||
|
attempt++;
|
||||||
|
if (attempt < 2) {
|
||||||
|
return Promise.reject(new RetryableError('fail'));
|
||||||
|
}
|
||||||
|
return Promise.resolve('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not throw - defaults to multiplier of 2
|
||||||
|
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).resolves.toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies exponential backoff with custom multiplier', async () => {
|
||||||
|
const sleepSpy = spyOn(globalThis, 'setTimeout');
|
||||||
|
let attempt = 0;
|
||||||
|
const fn = mock(() => {
|
||||||
|
attempt++;
|
||||||
|
if (attempt <= 3) {
|
||||||
|
return Promise.reject(new RetryableError('fail'));
|
||||||
|
}
|
||||||
|
return Promise.resolve('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
await withRetry(fn, {
|
||||||
|
maxRetries: 5,
|
||||||
|
baseDelayMs: 10,
|
||||||
|
backoffMultiplier: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delays should grow: ~10, ~30, ~90 (with jitter)
|
||||||
|
const delays = sleepSpy.mock.calls.map((call) => call[1] as number);
|
||||||
|
// First delay should be close to base * multiplier^0 = 10
|
||||||
|
expect(delays[0]).toBeGreaterThan(5);
|
||||||
|
expect(delays[0]).toBeLessThan(25); // 10 + jitter
|
||||||
|
// Second delay should be close to base * multiplier^1 = 30
|
||||||
|
expect(delays[1]).toBeGreaterThan(20);
|
||||||
|
expect(delays[1]).toBeLessThan(50); // 30 + jitter
|
||||||
|
|
||||||
|
sleepSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through errors that are not Error instances', async () => {
|
||||||
|
const fn = mock(() => Promise.reject('string error'));
|
||||||
|
await expect(withRetry(fn, { maxRetries: 3, baseDelayMs: 1 })).rejects.toBe('string error');
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with maxRetries of 0 (no retries)', async () => {
|
||||||
|
const fn = mock(() => Promise.reject(new RetryableError('fail')));
|
||||||
|
await expect(withRetry(fn, { maxRetries: 0, baseDelayMs: 1 })).rejects.toThrow('fail');
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Retry Strategy Utility
|
||||||
|
*
|
||||||
|
* Reusable exponential-backoff retry wrapper extracted from
|
||||||
|
* scattered retry logic in glmt-proxy and binary/downloader.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const data = await withRetry(() => fetch(url), { maxRetries: 3, baseDelayMs: 100 });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RetryableError, isRecoverableError } from '../errors/error-types';
|
||||||
|
|
||||||
|
/** Configuration options for retry behavior */
|
||||||
|
export interface RetryOptions {
|
||||||
|
/** Maximum number of retry attempts (default: 3) */
|
||||||
|
maxRetries: number;
|
||||||
|
/** Base delay in ms for the first retry (default: 1000) */
|
||||||
|
baseDelayMs: number;
|
||||||
|
/** Upper bound for the computed delay (default: 30000) */
|
||||||
|
maxDelayMs?: number;
|
||||||
|
/** Multiplier applied per attempt (default: 2) */
|
||||||
|
backoffMultiplier?: number;
|
||||||
|
/** Override the default retryability check */
|
||||||
|
retryableCheck?: (error: unknown) => boolean;
|
||||||
|
/** Callback fired before each retry (not fired on initial call) */
|
||||||
|
onRetry?: (error: Error, attempt: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_DELAY_MS = 30_000;
|
||||||
|
const DEFAULT_MULTIPLIER = 2;
|
||||||
|
const JITTER_RATIO = 0.2; // 20% of delay as random jitter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an unknown thrown value is retryable.
|
||||||
|
* Uses CCSError.recoverable flag and RetryableError instance check.
|
||||||
|
*/
|
||||||
|
function defaultRetryableCheck(error: unknown): boolean {
|
||||||
|
if (error instanceof RetryableError) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isRecoverableError(error)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute backoff delay: base * multiplier^attempt + jitter, capped at maxDelay.
|
||||||
|
*/
|
||||||
|
function computeDelay(
|
||||||
|
attempt: number,
|
||||||
|
baseDelayMs: number,
|
||||||
|
maxDelayMs: number,
|
||||||
|
multiplier: number
|
||||||
|
): number {
|
||||||
|
const exponentialDelay = Math.min(baseDelayMs * Math.pow(multiplier, attempt), maxDelayMs);
|
||||||
|
const jitter = exponentialDelay * JITTER_RATIO * Math.random();
|
||||||
|
return exponentialDelay + jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for the specified number of milliseconds.
|
||||||
|
*/
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute `fn` with automatic retries on retryable failures.
|
||||||
|
*
|
||||||
|
* Retryability defaults to checking for `RetryableError` instances
|
||||||
|
* and CCSError with `recoverable === true`. Override with `retryableCheck`.
|
||||||
|
*
|
||||||
|
* @param fn - The async function to execute
|
||||||
|
* @param options - Retry configuration
|
||||||
|
* @returns The resolved value from `fn`
|
||||||
|
* @throws The last error encountered after exhausting retries
|
||||||
|
*/
|
||||||
|
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxRetries,
|
||||||
|
baseDelayMs,
|
||||||
|
maxDelayMs = DEFAULT_MAX_DELAY_MS,
|
||||||
|
backoffMultiplier = DEFAULT_MULTIPLIER,
|
||||||
|
retryableCheck = defaultRetryableCheck,
|
||||||
|
onRetry,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isRetryable = retryableCheck ?? defaultRetryableCheck;
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// No more retries left
|
||||||
|
if (attempt >= maxRetries) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check retryability
|
||||||
|
if (!isRetryable(error)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
onRetry?.(err, attempt + 1);
|
||||||
|
|
||||||
|
const delay = computeDelay(attempt, baseDelayMs, maxDelayMs, backoffMultiplier);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user