UNPKG

@bernierllc/retry-policy

Version:

Atomic retry policy utilities with exponential backoff and jitter

340 lines (276 loc) 10.6 kB
/* Copyright (c) 2025 Bernier LLC This file is licensed to the client under a limited-use license. The client may use and modify this code *only within the scope of the project it was delivered for*. Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC. */ import { RetryPolicy, createRetryPolicy, calculateRetryDelay, shouldRetry, DEFAULT_RETRY_OPTIONS, DEFAULT_JITTER_CONFIG, DEFAULT_BACKOFF_CONFIG } from '../src'; describe('RetryPolicy', () => { describe('constructor', () => { it('should create with default options', () => { const policy = new RetryPolicy(); expect(policy.getOptions()).toEqual(DEFAULT_RETRY_OPTIONS); expect(policy.getBackoffConfig()).toEqual(DEFAULT_BACKOFF_CONFIG); }); it('should create with custom options', () => { const customOptions = { maxRetries: 3, initialDelayMs: 500, shouldRetry: (error: any) => error.retryable }; const policy = new RetryPolicy(customOptions); const options = policy.getOptions(); expect(options.maxRetries).toBe(3); expect(options.initialDelayMs).toBe(500); expect(options.shouldRetry).toBe(customOptions.shouldRetry); }); it('should create with custom backoff config', () => { const customBackoff = { type: 'linear' as const, baseDelay: 2000, maxDelay: 10000 }; const policy = new RetryPolicy(undefined, customBackoff); const config = policy.getBackoffConfig(); expect(config.type).toBe('linear'); expect(config.baseDelay).toBe(2000); expect(config.maxDelay).toBe(10000); }); }); describe('evaluateRetry', () => { it('should return shouldRetry true for first attempt', () => { const policy = new RetryPolicy(); const result = policy.evaluateRetry(0, new Error('test')); expect(result.shouldRetry).toBe(true); expect(result.attempt).toBe(0); expect(result.isFinalAttempt).toBe(false); expect(result.delay).toBeGreaterThan(0); }); it('should return shouldRetry false when max retries exceeded', () => { const policy = new RetryPolicy({ maxRetries: 2 }); const result = policy.evaluateRetry(2, new Error('test')); expect(result.shouldRetry).toBe(false); expect(result.attempt).toBe(2); expect(result.isFinalAttempt).toBe(true); expect(result.delay).toBe(0); }); it('should use custom shouldRetry function', () => { const shouldRetryFn = jest.fn().mockReturnValue(false); const policy = new RetryPolicy({ shouldRetry: shouldRetryFn }); const error = new Error('test'); const result = policy.evaluateRetry(0, error); expect(result.shouldRetry).toBe(false); expect(shouldRetryFn).toHaveBeenCalledWith(error); }); }); describe('calculateDelay', () => { it('should calculate exponential backoff correctly', () => { const policy = new RetryPolicy({ jitter: false }, { type: 'exponential', baseDelay: 1000, factor: 2 }); expect(policy.calculateDelay(0)).toBe(1000); expect(policy.calculateDelay(1)).toBe(2000); expect(policy.calculateDelay(2)).toBe(4000); }); it('should calculate linear backoff correctly', () => { const policy = new RetryPolicy({ jitter: false }, { type: 'linear', baseDelay: 1000 }); expect(policy.calculateDelay(0)).toBe(1000); expect(policy.calculateDelay(1)).toBe(2000); expect(policy.calculateDelay(2)).toBe(3000); }); it('should calculate constant backoff correctly', () => { const policy = new RetryPolicy({ jitter: false }, { type: 'constant', baseDelay: 1000 }); expect(policy.calculateDelay(0)).toBe(1000); expect(policy.calculateDelay(1)).toBe(1000); expect(policy.calculateDelay(2)).toBe(1000); }); it('should respect max delay limit', () => { const policy = new RetryPolicy({ jitter: false }, { type: 'exponential', baseDelay: 1000, factor: 2, maxDelay: 2000 }); expect(policy.calculateDelay(0)).toBe(1000); expect(policy.calculateDelay(1)).toBe(2000); expect(policy.calculateDelay(2)).toBe(2000); // Capped at maxDelay }); it('should apply jitter when enabled', () => { const policy = new RetryPolicy({ jitter: true }, { type: 'constant', baseDelay: 1000, jitter: { type: 'full', factor: 0.1 } }); const delay1 = policy.calculateDelay(0); const delay2 = policy.calculateDelay(0); // With jitter, delays should be different expect(delay1).toBeLessThanOrEqual(1000); expect(delay1).toBeGreaterThan(0); expect(delay2).toBeLessThanOrEqual(1000); expect(delay2).toBeGreaterThan(0); }); }); describe('updateOptions', () => { it('should update options correctly', () => { const policy = new RetryPolicy(); policy.updateOptions({ maxRetries: 10, initialDelayMs: 500 }); const options = policy.getOptions(); expect(options.maxRetries).toBe(10); expect(options.initialDelayMs).toBe(500); }); }); describe('updateBackoffConfig', () => { it('should update backoff config correctly', () => { const policy = new RetryPolicy(); policy.updateBackoffConfig({ type: 'linear', baseDelay: 2000 }); const config = policy.getBackoffConfig(); expect(config.type).toBe('linear'); expect(config.baseDelay).toBe(2000); }); }); }); describe('createRetryPolicy', () => { it('should create policy with default options', () => { const policy = createRetryPolicy(); expect(policy).toBeInstanceOf(RetryPolicy); expect(policy.getOptions()).toEqual(DEFAULT_RETRY_OPTIONS); }); it('should create policy with custom options', () => { const customOptions = { maxRetries: 3 }; const policy = createRetryPolicy(customOptions); expect(policy.getOptions().maxRetries).toBe(3); }); }); describe('calculateRetryDelay', () => { it('should calculate delay correctly', () => { const delay = calculateRetryDelay(1, { jitter: false }, { type: 'exponential', baseDelay: 1000, factor: 2 }); expect(delay).toBe(2000); }); }); describe('shouldRetry', () => { it('should return true for retryable errors', () => { const result = shouldRetry(0, new Error('retryable'), { maxRetries: 3 }); expect(result).toBe(true); }); it('should return false when max retries exceeded', () => { const result = shouldRetry(3, new Error('test'), { maxRetries: 2 }); expect(result).toBe(false); }); }); describe('Constants', () => { it('should export default options', () => { expect(DEFAULT_RETRY_OPTIONS).toBeDefined(); expect(DEFAULT_RETRY_OPTIONS.maxRetries).toBe(5); expect(DEFAULT_RETRY_OPTIONS.initialDelayMs).toBe(1000); }); it('should export default jitter config', () => { expect(DEFAULT_JITTER_CONFIG).toBeDefined(); expect(DEFAULT_JITTER_CONFIG.type).toBe('full'); }); it('should export default backoff config', () => { expect(DEFAULT_BACKOFF_CONFIG).toBeDefined(); expect(DEFAULT_BACKOFF_CONFIG.type).toBe('exponential'); }); }); describe('Runtime Configuration', () => { beforeEach(() => { // Clear environment variables delete process.env.RETRY_ENABLED; delete process.env.RETRY_MAX_RETRIES; }); describe('Disabled retry functionality', () => { it('should disable retries when enabled is false', () => { process.env.RETRY_ENABLED = 'false'; const policy = new RetryPolicy(); const options = policy.getOptions(); expect(options.maxRetries).toBe(0); }); }); describe('Unknown backoff type', () => { it('should default to exponential for unknown backoff type', () => { const policy = new RetryPolicy({ jitter: false }, { type: 'unknown-type' as any, baseDelay: 1000, factor: 2 }); // Should use exponential as default expect(policy.calculateDelay(0)).toBe(1000); expect(policy.calculateDelay(1)).toBe(2000); }); }); describe('Jitter types', () => { it('should apply equal jitter', () => { const policy = new RetryPolicy({ jitter: true }, { type: 'constant', baseDelay: 1000, jitter: { type: 'equal', factor: 0.1 } }); const delay = policy.calculateDelay(0); // Equal jitter should be between delay/2 and delay (500 to 1000) expect(delay).toBeGreaterThanOrEqual(500); expect(delay).toBeLessThanOrEqual(1000); }); it('should apply decorrelated jitter', () => { const policy = new RetryPolicy({ jitter: true }, { type: 'constant', baseDelay: 1000, jitter: { type: 'decorrelated', factor: 0.1 } }); const delay = policy.calculateDelay(0); // Decorrelated jitter should be between delay and delay * 3 (1000 to 3000) expect(delay).toBeGreaterThanOrEqual(1000); expect(delay).toBeLessThanOrEqual(3000); }); it('should apply none jitter', () => { const policy = new RetryPolicy({ jitter: true }, { type: 'constant', baseDelay: 1000, jitter: { type: 'none', factor: 0.1 } }); const delay = policy.calculateDelay(0); // None jitter should return original delay expect(delay).toBe(1000); }); it('should handle default jitter type', () => { const policy = new RetryPolicy({ jitter: true }, { type: 'constant', baseDelay: 1000, jitter: { type: undefined as any, factor: 0.1 } }); const delay = policy.calculateDelay(0); // Default jitter should return original delay expect(delay).toBe(1000); }); }); describe('getConfigurationSources', () => { it('should return configuration sources when available', () => { process.env.RETRY_MAX_RETRIES = '10'; const policy = new RetryPolicy(); const sources = policy.getConfigurationSources(); expect(Array.isArray(sources)).toBe(true); }); it('should handle errors gracefully', () => { // Create policy without any configuration const policy = new RetryPolicy(); // This should not throw even if config loading fails const sources = policy.getConfigurationSources(); expect(Array.isArray(sources)).toBe(true); }); }); });