Skip to content

feat(misc): LIT-4017 - Add ConditionalRetry functionality #711

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions packages/misc/src/lib/conditional-retry/conditional-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
204 changes: 204 additions & 0 deletions packages/misc/src/lib/conditional-retry/conditional-retry.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
private operation: Operation<T>;
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<T>} config - The configuration for the retry logic.
* @param {Operation<T>} 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<string>({
* 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<T>) {
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<Operation<T>>} The result of the operation if successful.
* @throws {RetryError | TimeoutError} If the operation fails after the specified retries or times out.
*/
async start(): ReturnType<Operation<T>> {
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<void>} A promise that resolves after the delay.
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
5 changes: 5 additions & 0 deletions packages/misc/src/lib/conditional-retry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { ConditionalRetry } from './conditional-retry';
export { RetryError } from './retry-error';
export { TimeoutError } from './timeout-error';

export * from './types';
9 changes: 9 additions & 0 deletions packages/misc/src/lib/conditional-retry/retry-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class RetryError extends Error {
reasons: Error[];

constructor(message: string, reasons: Error[]) {
super(message);
this.name = 'RetryError';
this.reasons = reasons;
}
}
8 changes: 8 additions & 0 deletions packages/misc/src/lib/conditional-retry/timeout-error.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
Loading
Loading