/**
* @vitest-environment node
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { executeWithRetry, withRetry, RetryContext } from '../../../utils/retry/retry-executor.js';
import { CircuitBreakerManager } from '../../../utils/retry/circuit-breaker.js';
import { RetryBudgetManager } from '../../../utils/retry/retry-budget.js';
import { ErrorCategory } from '../../../utils/errors/categories.js';
import { createClassifiedError } from '../../../utils/errors/factory.js';
describe('Retry Executor', () => {
let circuitBreakerManager: CircuitBreakerManager;
let retryBudgetManager: RetryBudgetManager;
beforeEach(() => {
circuitBreakerManager = CircuitBreakerManager.getInstance();
retryBudgetManager = RetryBudgetManager.getInstance();
circuitBreakerManager.clear();
retryBudgetManager.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('executeWithRetry', () => {
it('should succeed on first attempt', async () => {
vi.useRealTimers(); // Use real timers for synchronous success
const fn = vi.fn().mockResolvedValue('success');
const result = await executeWithRetry(fn);
expect(result.success).toBe(true);
expect(result.data).toBe('success');
expect(result.attempts).toBe(1);
expect(fn).toHaveBeenCalledTimes(1);
vi.useFakeTimers(); // Reset to fake timers
});
it('should retry on retriable errors', async () => {
const retriableError = createClassifiedError(
'Rate limit exceeded',
ErrorCategory.RATE_LIMIT,
new Error('429')
);
const fn = vi
.fn()
.mockRejectedValueOnce(retriableError)
.mockRejectedValueOnce(retriableError)
.mockResolvedValueOnce('success');
const promise = executeWithRetry(fn, {
endpoint: 'test',
});
// Advance through retries
await vi.advanceTimersByTimeAsync(1500); // First retry
await vi.advanceTimersByTimeAsync(3000); // Second retry
const result = await promise;
expect(result.success).toBe(true);
expect(result.data).toBe('success');
expect(result.attempts).toBe(3);
expect(fn).toHaveBeenCalledTimes(3);
});
it('should not retry non-retriable errors', async () => {
const nonRetriableError = createClassifiedError(
'Not found',
ErrorCategory.NOT_FOUND,
new Error('404')
);
const fn = vi.fn().mockRejectedValue(nonRetriableError);
const result = await executeWithRetry(fn);
expect(result.success).toBe(false);
expect(result.error).toBe(nonRetriableError);
expect(result.attempts).toBe(1);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should respect circuit breaker', async () => {
const endpoint = 'test-circuit';
const breaker = circuitBreakerManager.getBreaker(endpoint);
// Open the circuit
for (let i = 0; i < 5; i++) {
breaker.recordFailure();
}
const fn = vi.fn().mockResolvedValue('success');
const result = await executeWithRetry(fn, { endpoint });
expect(result.success).toBe(false);
expect(result.circuitBreakerBlocked).toBe(true);
expect(result.attempts).toBe(0);
expect(fn).not.toHaveBeenCalled();
});
it('should respect retry budget', async () => {
const endpoint = 'test-budget';
const budget = retryBudgetManager.getBudget(endpoint);
// Exhaust the budget
for (let i = 0; i < 10; i++) {
budget.consumeRetry();
}
const retriableError = createClassifiedError(
'Server error',
ErrorCategory.SERVER,
new Error('500')
);
const fn = vi.fn().mockRejectedValue(retriableError);
const result = await executeWithRetry(fn, { endpoint });
expect(result.success).toBe(false);
expect(result.budgetExhausted).toBe(true);
expect(result.attempts).toBe(1); // First attempt is made
expect(fn).toHaveBeenCalledTimes(1);
});
it('should call onRetryAttempt callback', async () => {
const retriableError = createClassifiedError(
'Network error',
ErrorCategory.NETWORK,
new Error('ECONNREFUSED')
);
const fn = vi.fn().mockRejectedValueOnce(retriableError).mockResolvedValueOnce('success');
const contexts: RetryContext[] = [];
const onRetryAttempt = vi.fn((context: RetryContext) => {
contexts.push(context);
});
const promise = executeWithRetry(fn, {
endpoint: 'test',
onRetryAttempt,
});
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result.success).toBe(true);
expect(onRetryAttempt).toHaveBeenCalledTimes(1);
expect(contexts[0]).toMatchObject({
endpoint: 'test',
attemptNumber: 1,
isLastAttempt: false,
});
});
it('should respect maximum total duration', async () => {
vi.useRealTimers(); // Use real timers for this test
const retriableError = createClassifiedError(
'Timeout',
ErrorCategory.TIMEOUT,
new Error('ETIMEDOUT')
);
const fn = vi.fn().mockRejectedValue(retriableError);
const result = await executeWithRetry(fn, {
endpoint: 'test',
maxTotalDuration: 50, // Very short duration
});
expect(result.success).toBe(false);
expect(fn).toHaveBeenCalledTimes(1); // Should stop after first attempt due to max duration
vi.useFakeTimers(); // Reset to fake timers
});
it('should use custom Retry-After header', async () => {
const retriableError = createClassifiedError(
'Rate limit',
ErrorCategory.RATE_LIMIT,
new Error('429')
);
const fn = vi.fn().mockRejectedValueOnce(retriableError).mockResolvedValueOnce('success');
const promise = executeWithRetry(fn, {
endpoint: 'test',
extractRetryAfter: () => '2', // 2 seconds
});
// Should wait 2 seconds as specified by Retry-After
await vi.advanceTimersByTimeAsync(1500);
expect(fn).toHaveBeenCalledTimes(1); // Still waiting
await vi.advanceTimersByTimeAsync(1000);
const result = await promise;
expect(result.success).toBe(true);
expect(fn).toHaveBeenCalledTimes(2);
});
it('should exhaust all retry attempts', async () => {
const retriableError = createClassifiedError(
'Server error',
ErrorCategory.SERVER,
new Error('503')
);
const fn = vi.fn().mockRejectedValue(retriableError);
const promise = executeWithRetry(fn, {
endpoint: 'test',
});
// Advance through all retries
await vi.advanceTimersByTimeAsync(1500); // First retry
await vi.advanceTimersByTimeAsync(3000); // Second retry
await vi.advanceTimersByTimeAsync(6000); // Third retry
const result = await promise;
expect(result.success).toBe(false);
expect(result.error).toBe(retriableError);
expect(result.attempts).toBe(4); // Initial + 3 retries
expect(fn).toHaveBeenCalledTimes(4);
});
it('should handle circuit breaker opening during retries', async () => {
vi.useRealTimers(); // Use real timers
const endpoint = 'test-circuit-open';
const breaker = circuitBreakerManager.getBreaker(endpoint, {
failureThreshold: 2,
failureWindow: 10000,
recoveryTimeout: 5000,
});
const retriableError = createClassifiedError(
'Server error',
ErrorCategory.SERVER,
new Error('500')
);
const fn = vi.fn().mockRejectedValue(retriableError);
const result = await executeWithRetry(fn, { endpoint });
expect(result.success).toBe(false);
expect(result.attempts).toBe(3); // Initial + 2 retries before circuit opens
expect(breaker.getState()).toBe('open');
vi.useFakeTimers(); // Reset to fake timers
});
});
describe('withRetry', () => {
it('should create a retry-enabled wrapper', async () => {
let attemptCount = 0;
const fn = vi.fn(async () => {
attemptCount++;
if (attemptCount < 3) {
throw createClassifiedError('Temporary error', ErrorCategory.NETWORK, new Error());
}
return 'success';
});
const wrappedFn = withRetry(fn);
const promise = wrappedFn();
await vi.advanceTimersByTimeAsync(5000);
const result = await promise;
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(3);
});
it('should throw error when all retries fail', async () => {
const error = createClassifiedError(
'Persistent error',
ErrorCategory.SERVER,
new Error('500')
);
const fn = vi.fn().mockRejectedValue(error);
const wrappedFn = withRetry(fn);
const promise = wrappedFn().catch((e) => e); // Catch to prevent unhandled rejection
await vi.advanceTimersByTimeAsync(20000); // Advance through all retries
const result = await promise;
expect(result).toBe(error);
expect(fn).toHaveBeenCalledTimes(4); // Initial + 3 retries
});
it('should use provided options', async () => {
vi.useRealTimers(); // Use real timers
const fn = vi
.fn()
.mockRejectedValue(createClassifiedError('Error', ErrorCategory.NETWORK, new Error()));
const wrappedFn = withRetry(fn, {
endpoint: 'custom',
maxTotalDuration: 50, // Very short duration
});
await expect(wrappedFn()).rejects.toThrow();
// Should stop early due to max duration
expect(fn).toHaveBeenCalledTimes(1);
vi.useFakeTimers(); // Reset to fake timers
});
});
describe('Error classification', () => {
it('should correctly identify retriable errors', async () => {
vi.useRealTimers(); // Use real timers
const testCases = [
{ error: new Error('Network error'), shouldRetry: true },
{ error: new Error('ECONNREFUSED'), shouldRetry: true },
{ error: new Error('Rate limit exceeded'), shouldRetry: true },
{ error: new Error('500 Internal Server Error'), shouldRetry: true },
{ error: new Error('502 Bad Gateway'), shouldRetry: true },
{ error: new Error('503 Service Unavailable'), shouldRetry: true },
{ error: new Error('Timeout occurred'), shouldRetry: true },
{ error: new Error('404 Not Found'), shouldRetry: false },
{ error: new Error('Invalid input'), shouldRetry: false },
{ error: new Error('Authentication failed'), shouldRetry: false },
];
for (const testCase of testCases) {
circuitBreakerManager.clear(); // Clear state between tests
retryBudgetManager.clear();
const fn = vi.fn().mockRejectedValue(testCase.error);
// Use a custom policy with minimal retry attempts for speed
const quickPolicy = {
maxAttempts: 1,
baseDelay: 10,
maxDelay: 100,
jitterFactor: 0,
retriableErrors: [
ErrorCategory.RATE_LIMIT,
ErrorCategory.NETWORK,
ErrorCategory.TIMEOUT,
ErrorCategory.SERVER,
],
respectRetryAfter: false,
name: 'test-quick',
};
const result = await executeWithRetry(fn, {
endpoint: `test-${Math.random()}`, // Use unique endpoint for each test
policy: quickPolicy,
});
if (testCase.shouldRetry) {
expect(result.attempts).toBeGreaterThan(1);
} else {
expect(result.attempts).toBe(1);
}
}
vi.useFakeTimers(); // Reset to fake timers
});
it('should handle classified errors', async () => {
const classifiedRetriable = createClassifiedError(
'Server error',
ErrorCategory.SERVER,
new Error()
);
const classifiedNonRetriable = createClassifiedError(
'Auth error',
ErrorCategory.AUTH,
new Error()
);
const fnRetriable = vi
.fn()
.mockRejectedValueOnce(classifiedRetriable)
.mockResolvedValueOnce('success');
const fnNonRetriable = vi.fn().mockRejectedValue(classifiedNonRetriable);
const promise1 = executeWithRetry(fnRetriable);
await vi.advanceTimersByTimeAsync(2000);
const result1 = await promise1;
const result2 = await executeWithRetry(fnNonRetriable);
expect(result1.attempts).toBe(2);
expect(result2.attempts).toBe(1);
});
});
describe('Integration with all components', () => {
it('should coordinate circuit breaker, budget, and backoff', async () => {
const endpoint = 'integration-test';
const breaker = circuitBreakerManager.getBreaker(endpoint, {
failureThreshold: 5,
recoveryTimeout: 1000,
});
const budget = retryBudgetManager.getBudget(endpoint);
const retriableError = createClassifiedError(
'Intermittent error',
ErrorCategory.NETWORK,
new Error()
);
let attemptCount = 0;
const fn = vi.fn(async () => {
attemptCount++;
if (attemptCount < 4) {
throw retriableError;
}
return 'success';
});
const promise = executeWithRetry(fn, {
endpoint,
onRetryAttempt: (context) => {
// Verify context is properly populated
expect(context.endpoint).toBe(endpoint);
expect(context.attemptNumber).toBeGreaterThan(0);
expect(context.elapsedMs).toBeGreaterThan(0);
},
});
// Advance through retries with exponential backoff
await vi.advanceTimersByTimeAsync(1500); // ~1s + jitter
await vi.advanceTimersByTimeAsync(2500); // ~2s + jitter
await vi.advanceTimersByTimeAsync(5000); // ~4s + jitter
const result = await promise;
expect(result.success).toBe(true);
expect(result.attempts).toBe(4);
// Check budget was consumed
const budgetStats = budget.getStats();
expect(budgetStats.consumed).toBe(3); // 3 retries
// Check circuit breaker recorded attempts
const breakerStats = breaker.getStats();
expect(breakerStats.failureCount).toBe(3);
expect(breakerStats.successCount).toBe(1);
expect(breakerStats.state).toBe('closed');
});
});
});