@bernierllc/retry-policy
Version:
Atomic retry policy utilities with exponential backoff and jitter
340 lines (276 loc) • 10.6 kB
text/typescript
/*
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);
});
});
});