diff --git a/packages/misc/src/lib/conditional-retry/conditional-retry.spec.ts b/packages/misc/src/lib/conditional-retry/conditional-retry.spec.ts new file mode 100644 index 000000000..90255556c --- /dev/null +++ b/packages/misc/src/lib/conditional-retry/conditional-retry.spec.ts @@ -0,0 +1,107 @@ +import { ConditionalRetry, RetryError, TimeoutError, RetryCondition } from './'; + +describe('ConditionalRetry', () => { + const successfulOperation = jest.fn(async () => 'Success'); + const failingOperation = jest.fn(async () => { + throw new Error('Test failure'); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return the result of a successful operation without retries', async () => { + const retry = new ConditionalRetry({ + operation: successfulOperation, + globalMaxRetries: 3, + timeoutSeconds: 5, + }); + + const result = await retry.start(); + expect(result).toBe('Success'); + expect(successfulOperation).toHaveBeenCalledTimes(1); + }); + + test('should retry on failure and eventually succeed', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(new Error('Temporary error')) + .mockResolvedValueOnce('Success'); + + const retry = new ConditionalRetry({ + operation, + globalMaxRetries: 3, + timeoutSeconds: 5, + }); + + const result = await retry.start(); + expect(result).toBe('Success'); + expect(operation).toHaveBeenCalledTimes(2); // 1 failure + 1 success + }); + + test('should retry based on condition and fail after condition max retries', async () => { + const condition: RetryCondition = { + maxRetries: 2, + attempts: 1, + backoffType: 'fixed', + baseDelay: 100, + shouldRetry: (error) => error.message === 'Condition failure', + }; + + const retry = new ConditionalRetry({ + operation: failingOperation, + globalMaxRetries: 5, + timeoutSeconds: 5, + conditions: [condition], + }); + + await expect(retry.start()).rejects.toThrow(RetryError); + expect(failingOperation).toHaveBeenCalledTimes(5); // 1 initial attempt + 2 retries from condition + 2 more from globalMaxRetries + }); + + test('should retry up to global max retries if no condition matches', async () => { + const retry = new ConditionalRetry({ + operation: failingOperation, + globalMaxRetries: 3, + timeoutSeconds: 5, + }); + + await expect(retry.start()).rejects.toThrow(RetryError); + expect(failingOperation).toHaveBeenCalledTimes(3); // Retry up to globalMaxRetries + }); + + test('should honor global max retries even if a condition exists but is not matched', async () => { + const condition: RetryCondition = { + maxRetries: 2, + attempts: 1, + backoffType: 'fixed', + baseDelay: 100, + shouldRetry: (error) => error.message === 'Non-matching error', + }; + + const retry = new ConditionalRetry({ + operation: failingOperation, + globalMaxRetries: 3, + timeoutSeconds: 5, + conditions: [condition], + }); + + await expect(retry.start()).rejects.toThrow(RetryError); + expect(failingOperation).toHaveBeenCalledTimes(3); // Retries up to the global max + }); + + test('should timeout if the operation does not complete within the specified time', async () => { + const longOperation = jest.fn( + () => new Promise((resolve, reject) => setTimeout(reject, 3000)) + ); + + const retry = new ConditionalRetry({ + operation: longOperation, + globalMaxRetries: 3, + timeoutSeconds: 2, + }); + + await expect(retry.start()).rejects.toThrow(TimeoutError); + expect(longOperation).toHaveBeenCalledTimes(1); // Only one attempt due to timeout + }); +}); diff --git a/packages/misc/src/lib/conditional-retry/conditional-retry.ts b/packages/misc/src/lib/conditional-retry/conditional-retry.ts new file mode 100644 index 000000000..c040f999a --- /dev/null +++ b/packages/misc/src/lib/conditional-retry/conditional-retry.ts @@ -0,0 +1,204 @@ +import { RetryError } from './retry-error'; +import { TimeoutError } from './timeout-error'; + +import type { + BackoffParams, + ConditionalRetryConfig, + OnRetryParams, + Operation, + RetryCondition, +} from './types'; + +/** + * A configurable retry handler that attempts an operation based on global and condition-specific retry settings. + * Supports different backoff strategies for each condition and a global configuration if no conditions are provided. + * + * @template T The type of result returned by the operation. + */ +export class ConditionalRetry { + private operation: Operation; + private globalMaxRetries: number; + private timeoutMs: number; // Timeout in milliseconds, converted from seconds + private conditions: RetryCondition[]; + private globalAttempts: number = 1; // Start global attempts at 1 + private baseDelay: number; // Default base delay in ms for backoff + private maxDelay: number; // Default max delay in ms for backoff + private errors: Error[] = []; // Stores errors from each retry attempt + + /** + * Creates a new instance of `ConditionalRetry`. + * + * @param {ConditionalRetryConfig} config - The configuration for the retry logic. + * @param {Operation} config.operation - The asynchronous operation to execute with retries. + * @param {number} config.globalMaxRetries - The maximum number of retry attempts globally. + * @param {number} config.timeoutSeconds - The maximum duration in seconds to attempt retries. + * @param {RetryCondition[]} [config.conditions=[]] - Optional array of retry conditions with specific rules and backoff strategies. + * @param {number} [config.baseDelay=100] - The default base delay in milliseconds for retries. + * @param {number} [config.maxDelay=2000] - The maximum delay in milliseconds for retries. + * + * @example + * // Example with global configuration only + * const conditionalRetry = new ConditionalRetry({ + * operation: async () => { + * // Simulate a network request + * throw new Error("Network error"); + * }, + * globalMaxRetries: 5, + * timeoutSeconds: 10, // 10 seconds + * baseDelay: 100, + * maxDelay: 2000 + * }); + * + * conditionalRetry.start() + * .then(result => console.log("Data fetched:", result)) + * .catch(error => { + * if (error instanceof RetryError) { + * console.error("Failed to fetch data. Reasons:", error.reasons); + * } else { + * console.error("Failed to fetch data:", error); + * } + * }); + */ + constructor({ + operation, + globalMaxRetries, + timeoutSeconds, + conditions = [], // Default to an empty array if no conditions are provided + baseDelay = 100, + maxDelay = 2000, + }: ConditionalRetryConfig) { + this.operation = operation; + this.globalMaxRetries = globalMaxRetries; + this.timeoutMs = timeoutSeconds * 1000; // Convert seconds to milliseconds + this.baseDelay = baseDelay; + this.maxDelay = maxDelay; + this.conditions = conditions.map((cond) => ({ ...cond, attempts: 1 })); // Start attempts at 1 for each condition + } + + /** + * Starts the retry operation with the provided global and condition-specific configurations. + * + * @returns {ReturnType>} The result of the operation if successful. + * @throws {RetryError | TimeoutError} If the operation fails after the specified retries or times out. + */ + async start(): ReturnType> { + this.globalAttempts = 1; // Start globalAttempts at 1 for first execution + this.errors = []; // Reset errors array before execution starts + const startTime = Date.now(); + + // eslint-disable-next-line no-constant-condition + while (true) { + const elapsed = Date.now() - startTime; + if (elapsed >= this.timeoutMs) { + throw new TimeoutError( + `Operation timed out after ${elapsed / 1000} seconds`, + this.errors + ); + } + + try { + return await this.operation(); + } catch (error: unknown) { + this.errors.push(error as Error); // Track each error encountered + + // Define global config object + const globalConfig = { + maxRetries: this.globalMaxRetries, + timeoutSeconds: this.timeoutMs / 1000, + }; + + // Try to find a matching retry condition + const condition: RetryCondition | undefined = this.conditions.find( + (cond) => { + const retryParams: OnRetryParams = { + globalAttemptNum: this.globalAttempts, + conditionAttemptNum: cond.attempts, + globalConfig, + previousErrors: [...this.errors], // Pass all previous errors + }; + + // Call shouldRetry with the error and retryParams + return cond.shouldRetry(error as Error, retryParams); + } + ); + + // Determine the max retries to use based on whether a condition was matched + const maxRetries = condition + ? condition.maxRetries + : this.globalMaxRetries; + const attempts = condition ? condition.attempts : this.globalAttempts; + + // Check if retries have been exhausted + if (attempts >= maxRetries) { + throw new RetryError( + `Failed after ${attempts} attempts${ + condition ? ' for condition' : '' + }: ${(error as Error).message}`, + this.errors + ); + } + + console.log( + `Global attempts: ${this.globalAttempts}, Condition attempts: ${attempts}` + ); + + // Determine backoff delay based on the selected backoff type, using either condition-specific or class-level delay values + const delay = this.calculateBackoffDelay({ + attempt: this.globalAttempts, + backoffType: condition ? condition.backoffType : 'fullJitter', + baseDelay: condition?.baseDelay ?? this.baseDelay, + maxDelay: condition?.maxDelay ?? this.maxDelay, + }); + + // Increment global and condition attempts after each retry + this.globalAttempts += 1; + if (condition) { + condition.attempts += 1; + } + + await this.sleep(delay); + } + } + } + + /** + * Calculates the backoff delay for the next retry attempt based on the provided parameters. + * + * @param {BackoffParams} params - Parameters to calculate the backoff delay. + * @param {number} params.attempt - The current attempt number. + * @param {BackoffType} params.backoffType - The backoff strategy to use. + * @param {number} params.baseDelay - The base delay in milliseconds. + * @param {number} params.maxDelay - The maximum allowable delay in milliseconds. + * @returns {number} The calculated delay in milliseconds. + */ + private calculateBackoffDelay({ + attempt, + backoffType, + baseDelay, + maxDelay, + }: BackoffParams): number { + const exponentialBackoff = Math.min(baseDelay * 2 ** attempt, maxDelay); + + switch (backoffType) { + case 'exponential': + return exponentialBackoff; + case 'fixed': + return baseDelay; + case 'linear': + return Math.min(baseDelay * attempt, maxDelay); + case 'fullJitter': + default: + return Math.random() * exponentialBackoff; + } + } + + /** + * Delays execution for the specified number of milliseconds. + * + * @param {number} ms - The duration to delay in milliseconds. + * @returns {Promise} A promise that resolves after the delay. + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/misc/src/lib/conditional-retry/index.ts b/packages/misc/src/lib/conditional-retry/index.ts new file mode 100644 index 000000000..882b0b8dc --- /dev/null +++ b/packages/misc/src/lib/conditional-retry/index.ts @@ -0,0 +1,5 @@ +export { ConditionalRetry } from './conditional-retry'; +export { RetryError } from './retry-error'; +export { TimeoutError } from './timeout-error'; + +export * from './types'; diff --git a/packages/misc/src/lib/conditional-retry/retry-error.ts b/packages/misc/src/lib/conditional-retry/retry-error.ts new file mode 100644 index 000000000..f39ffa698 --- /dev/null +++ b/packages/misc/src/lib/conditional-retry/retry-error.ts @@ -0,0 +1,9 @@ +export class RetryError extends Error { + reasons: Error[]; + + constructor(message: string, reasons: Error[]) { + super(message); + this.name = 'RetryError'; + this.reasons = reasons; + } +} diff --git a/packages/misc/src/lib/conditional-retry/timeout-error.ts b/packages/misc/src/lib/conditional-retry/timeout-error.ts new file mode 100644 index 000000000..51ec916e3 --- /dev/null +++ b/packages/misc/src/lib/conditional-retry/timeout-error.ts @@ -0,0 +1,8 @@ +import { RetryError } from './retry-error'; + +export class TimeoutError extends RetryError { + constructor(message: string, reasons: Error[]) { + super(message, reasons); + this.name = 'TimeoutError'; + } +} diff --git a/packages/misc/src/lib/conditional-retry/types.ts b/packages/misc/src/lib/conditional-retry/types.ts new file mode 100644 index 000000000..167fc4ef9 --- /dev/null +++ b/packages/misc/src/lib/conditional-retry/types.ts @@ -0,0 +1,72 @@ +export type BackoffType = 'fullJitter' | 'exponential' | 'fixed' | 'linear'; + +/** + * Defines a retry condition with specific retry logic, backoff strategy, and limits. + */ +export interface RetryCondition { + /** + * The maximum number of retry attempts for this specific condition. + */ + maxRetries: number; + + /** + * Tracks the number of attempts made under this condition. + */ + attempts: number; + + /** + * The type of backoff strategy to use for this condition. + * Options include: + * - "fullJitter": Exponential backoff with a random jitter. + * - "exponential": Exponential backoff without jitter. + * - "fixed": A fixed delay between retries. + * - "linear": A linearly increasing delay. + */ + backoffType: BackoffType; + + /** + * The base delay in milliseconds for retries under this condition. + * Optional: If not provided, the global base delay will be used. + */ + baseDelay?: number; + + /** + * The maximum allowable delay in milliseconds for retries under this condition. + * Optional: If not provided, the global max delay will be used. + */ + maxDelay?: number; + + /** + * Determines if the retry should proceed based on the current error and retry parameters. + * + * @param {Error} error - The error encountered during the last attempt. + * @param {OnRetryParams} retryParams - Parameters for the current retry attempt. + * @returns {boolean} - True if the operation should be retried, false otherwise. + */ + shouldRetry: (error: Error, retryParams: OnRetryParams) => boolean; +} + +export interface OnRetryParams { + globalAttemptNum: number; // Global attempt count + conditionAttemptNum: number; // Condition-specific attempt count + globalConfig: { maxRetries: number; timeoutSeconds: number }; + previousErrors: Error[]; // Array of previously encountered errors +} + +export interface BackoffParams { + attempt: number; + backoffType: BackoffType; + baseDelay: number; + maxDelay: number; +} + +export type Operation = () => Promise; + +export interface ConditionalRetryConfig { + operation: Operation; + globalMaxRetries: number; + timeoutSeconds: number; + conditions?: RetryCondition[]; // Optional conditions + baseDelay?: number; + maxDelay?: number; +}