Created
April 5, 2025 01:09
-
-
Save xantiagoma/3d6438141ffcdbcd0984abdf1bd791c1 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { expect, test, describe } from "bun:test"; | |
import { | |
retry, | |
stopAfterAttempt, | |
stopAfterDelay, | |
stopBeforeDelay, | |
waitCombine, | |
waitExponential, | |
waitExponentialJitter, | |
waitFixed, | |
waitIncrementing, | |
waitRandom, | |
TryAgain, | |
RetryError, | |
retryIfException, | |
retryIfExceptionType, | |
retryIfResult, | |
retryIfNotResult | |
} from './retry'; | |
describe("retry utility", () => { | |
// Simple test function examples | |
const returnsNumber = () => 42; | |
const takesParameters = (a: string, b: number) => `${a}: ${b}`; | |
const asyncFunction = async (id: number) => ({ id, data: 'test' }); | |
test("should properly preserve types for functions with no arguments", async () => { | |
// Test the retry function with type inference | |
const retryNumber = retry({ | |
stop: stopAfterAttempt(3), | |
wait: waitFixed(0.01) // Use small value for tests | |
})(returnsNumber); | |
// Verify type is preserved (this is checked at compile time) | |
const result = await retryNumber(); | |
expect(typeof result).toBe("number"); | |
expect(result).toBe(42); | |
}); | |
test("should properly preserve types for functions with parameters", async () => { | |
const retryString = retry({ | |
stop: stopAfterAttempt(3), | |
wait: waitFixed(0.01) | |
})(takesParameters); | |
const result = await retryString("test", 123); | |
expect(typeof result).toBe("string"); | |
expect(result).toBe("test: 123"); | |
}); | |
test("should properly preserve types for async functions", async () => { | |
const retryAsync = retry({ | |
stop: stopAfterAttempt(3), | |
wait: waitFixed(0.01) | |
})(asyncFunction); | |
const result = await retryAsync(456); | |
expect(typeof result).toBe("object"); | |
expect(result.id).toBe(456); | |
expect(result.data).toBe("test"); | |
}); | |
test("should retry on failure until success", async () => { | |
let attempts = 0; | |
const sometimesFails = () => { | |
attempts++; | |
if (attempts < 3) { | |
throw new Error("Intentional failure"); | |
} | |
return "success"; | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(5), | |
wait: waitFixed(0.01) | |
})(sometimesFails); | |
const result = await retrier(); | |
expect(result).toBe("success"); | |
expect(attempts).toBe(3); | |
}); | |
test("should stop retrying after max attempts", async () => { | |
let attempts = 0; | |
const alwaysFails = () => { | |
attempts++; | |
throw new Error("Always fails"); | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(3), | |
wait: waitFixed(0.01) | |
})(alwaysFails); | |
await expect(retrier()).rejects.toThrow(); | |
expect(attempts).toBe(3); | |
}); | |
test("should support different wait strategies", async () => { | |
// Test with random wait | |
const randomWaitSuccess = async () => { | |
return "random success"; | |
}; | |
const randomRetrier = retry({ | |
stop: stopAfterAttempt(2), | |
wait: waitRandom(0.01, 0.02) | |
})(randomWaitSuccess); | |
expect(await randomRetrier()).toBe("random success"); | |
// Test with combined wait strategies | |
const combinedWait = waitCombine(waitFixed(0.01), waitExponentialJitter(0.01, 0.1, 2, 0.01)); | |
const combinedWaitSuccess = async () => { | |
return "combined success"; | |
}; | |
const combinedRetrier = retry({ | |
stop: stopAfterAttempt(2), | |
wait: combinedWait | |
})(combinedWaitSuccess); | |
expect(await combinedRetrier()).toBe("combined success"); | |
}); | |
// Additional tests to improve coverage | |
test("should support explicit retry with TryAgain error", async () => { | |
let attempts = 0; | |
const throwsTryAgain = () => { | |
attempts++; | |
if (attempts < 3) { | |
throw new TryAgain("Force retry"); | |
} | |
return "success after explicit retry"; | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(5), | |
wait: waitFixed(0.01) | |
})(throwsTryAgain); | |
const result = await retrier(); | |
expect(result).toBe("success after explicit retry"); | |
expect(attempts).toBe(3); | |
}); | |
test("should support error callbacks", async () => { | |
let errorCallbackCalled = false; | |
let attempts = 0; | |
const alwaysFails = () => { | |
attempts++; | |
throw new Error("Always fails"); | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(3), | |
wait: waitFixed(0.01), | |
retryErrorCallback: () => { | |
errorCallbackCalled = true; | |
// Return undefined instead of a string | |
} | |
})(alwaysFails); | |
await retrier(); | |
expect(errorCallbackCalled).toBe(true); | |
expect(attempts).toBe(3); | |
}); | |
test("should support re-raising original errors", async () => { | |
const customError = new Error("Custom error"); | |
const failsWithCustomError = () => { | |
throw customError; | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(2), | |
wait: waitFixed(0.01), | |
reraise: true | |
})(failsWithCustomError); | |
try { | |
await retrier(); | |
// Should not reach here | |
expect(true).toBe(false); | |
} catch (err) { | |
expect(err).toBe(customError); // Should be the exact same error | |
} | |
}); | |
test("should throw RetryError when retries are exhausted", async () => { | |
const alwaysFails = () => { | |
throw new Error("Failure"); | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(2), | |
wait: waitFixed(0.01) | |
})(alwaysFails); | |
try { | |
await retrier(); | |
// Should not reach here | |
expect(true).toBe(false); | |
} catch (error) { | |
const err = error as RetryError; | |
expect(err).toBeInstanceOf(RetryError); | |
expect(err.lastAttempt).toBeDefined(); | |
expect(err.lastAttempt.attemptNumber).toBe(2); | |
expect(err.lastAttempt.error).toBeDefined(); | |
} | |
}); | |
test("should support stopAfterDelay strategy", async () => { | |
let attempts = 0; | |
const slowOperation = async () => { | |
attempts++; | |
// Simulate a slow operation | |
await new Promise(resolve => setTimeout(resolve, 20)); | |
throw new Error("Operation failed"); | |
}; | |
const retrier = retry({ | |
stop: stopAfterDelay(0.03), // Stop after 30ms | |
wait: waitFixed(0.01) | |
})(slowOperation); | |
await expect(retrier()).rejects.toThrow(); | |
// Should have only had time for 1 or 2 attempts due to time constraints | |
expect(attempts).toBeLessThanOrEqual(2); | |
}); | |
test("should support stopBeforeDelay strategy", async () => { | |
let attempts = 0; | |
const alwaysFails = () => { | |
attempts++; | |
throw new Error("Always fails"); | |
}; | |
const retrier = retry({ | |
stop: stopBeforeDelay(0.1), // Stop if next attempt would exceed 100ms | |
wait: waitExponential(0.05, 1, 2) // Exponentially increasing wait | |
})(alwaysFails); | |
await expect(retrier()).rejects.toThrow(); | |
// Should stop before attempting one that would exceed the time limit | |
}); | |
test("should support waitIncrementing strategy", async () => { | |
let attempts = 0; | |
let lastWaitTime = 0; | |
const alwaysFails = () => { | |
attempts++; | |
throw new Error("Always fails"); | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(4), | |
wait: waitIncrementing(0.01, 0.01, 0.05), | |
beforeSleep: (state) => { | |
lastWaitTime = state.upcomingSleep; | |
} | |
})(alwaysFails); | |
await expect(retrier()).rejects.toThrow(); | |
// Last wait time should be 0.03 (0.01 + 3*0.01) | |
expect(lastWaitTime).toBeCloseTo(0.03, 2); | |
}); | |
test("should support waitExponential strategy", async () => { | |
let attempts = 0; | |
let lastWaitTime = 0; | |
const alwaysFails = () => { | |
attempts++; | |
throw new Error("Always fails"); | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(3), | |
wait: waitExponential(0.01, 1, 2), | |
beforeSleep: (state) => { | |
lastWaitTime = state.upcomingSleep; | |
} | |
})(alwaysFails); | |
await expect(retrier()).rejects.toThrow(); | |
// Last wait time should be 0.02 (0.01 * 2^1) for the second attempt | |
expect(lastWaitTime).toBeCloseTo(0.02, 2); | |
}); | |
test("should support retryIfException strategy", async () => { | |
let attempts = 0; | |
const sometimesFails = () => { | |
attempts++; | |
if (attempts < 2) { | |
throw new TypeError("Type error"); | |
} | |
return "success"; | |
}; | |
// Only retry if error message contains "Type" | |
const retrier = retry({ | |
stop: stopAfterAttempt(5), | |
wait: waitFixed(0.01), | |
retry: retryIfException(err => err instanceof TypeError && err.message.includes("Type")) | |
})(sometimesFails); | |
const result = await retrier(); | |
expect(result).toBe("success"); | |
expect(attempts).toBe(2); | |
}); | |
test("should support retryIfExceptionType strategy", async () => { | |
let attempts = 0; | |
const sometimesFails = () => { | |
attempts++; | |
if (attempts <= 2) { | |
// First two attempts throw TypeError | |
throw new TypeError("Type error"); | |
} | |
return "success"; | |
}; | |
// Reset attempts counter | |
attempts = 0; | |
// Only retry if error is a TypeError | |
const retrier = retry({ | |
stop: stopAfterAttempt(5), | |
wait: waitFixed(0.01), | |
retry: retryIfExceptionType(TypeError) | |
})(sometimesFails); | |
// Should eventually succeed after retrying TypeError twice | |
const result = await retrier(); | |
expect(result).toBe("success"); | |
expect(attempts).toBe(3); // 2 failures + 1 success | |
}); | |
test("should support retryIfResult strategy", async () => { | |
let attempts = 0; | |
const returnsIncreasingNumber = () => { | |
attempts++; | |
return attempts; | |
}; | |
// Retry until result is at least 3 | |
const retrier = retry({ | |
stop: stopAfterAttempt(5), | |
wait: waitFixed(0.01), | |
retry: retryIfResult(result => result < 3) | |
})(returnsIncreasingNumber); | |
const result = await retrier(); | |
expect(result).toBe(3); | |
expect(attempts).toBe(3); | |
}); | |
test("should support retryIfNotResult strategy", async () => { | |
let attempts = 0; | |
const returnsIncreasingNumber = () => { | |
attempts++; | |
return attempts; | |
}; | |
// Retry until result meets our criteria (is at least 3) | |
const retrier = retry({ | |
stop: stopAfterAttempt(5), | |
wait: waitFixed(0.01), | |
retry: retryIfNotResult(result => result >= 3) | |
})(returnsIncreasingNumber); | |
const result = await retrier(); | |
expect(result).toBe(3); | |
expect(attempts).toBe(3); | |
}); | |
test("should support all three retry function signatures", async () => { | |
// Test retry(fn) | |
const simpleFn = () => "simple"; | |
const retrySimple = retry(simpleFn); | |
expect(await retrySimple()).toBe("simple"); | |
// Test retry(options)(fn) | |
const options = { | |
stop: stopAfterAttempt(2), | |
wait: waitFixed(0.01) | |
}; | |
const retryWithOptions = retry(options)(simpleFn); | |
expect(await retryWithOptions()).toBe("simple"); | |
// Test retry(options, fn) | |
const retryWithBoth = retry(options, simpleFn); | |
expect(await retryWithBoth()).toBe("simple"); | |
}); | |
test("should track statistics during retry process", async () => { | |
let attempts = 0; | |
let statistics: any; | |
const sometimesFails = () => { | |
attempts++; | |
if (attempts < 3) { | |
throw new Error("Intentional failure"); | |
} | |
return "success"; | |
}; | |
const retrier = retry({ | |
stop: stopAfterAttempt(5), | |
wait: waitFixed(0.01), | |
after: (state) => { | |
statistics = state.retryObject.statistics; | |
} | |
})(sometimesFails); | |
await retrier(); | |
expect(statistics).toBeDefined(); | |
expect(statistics.attempt_number).toBe(3); | |
expect(statistics.idle_for).toBeGreaterThan(0); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// retry.ts | |
// | |
// A strongly-typed TypeScript port of tenacity's core retrying functionality. | |
// This module defines: | |
// • Core state types and marker classes (RetryCallState, IterState, TryAgain, DoAttempt, DoSleep) | |
// • An abstract BaseRetrying<T, Args> and concrete Retrying<T, Args> class (using Promises for async support) | |
// • Built-in generic strategies for retry, stop, and wait (each parameterized so that types are preserved) | |
// • A refined retry() decorator that uses overloads and generics to preserve argument and return types | |
// • Usage examples at the end. | |
////////////////////// | |
// Core Types & Classes | |
////////////////////// | |
/** | |
* Function that sleeps for a specified number of seconds. | |
* Used for delaying retry attempts. | |
* | |
* @param seconds - The number of seconds to sleep | |
* @returns Void or a Promise that resolves after the specified time | |
*/ | |
export type SleepFunction = (seconds: number) => void | Promise<void>; | |
/** | |
* Strategy for determining whether to stop retrying. | |
* | |
* @param state - The current retry call state | |
* @returns A boolean indicating whether to stop retrying | |
*/ | |
export type StopStrategy<T, Args extends unknown[]> = (state: RetryCallState<T, Args>) => boolean | Promise<boolean>; | |
/** | |
* Strategy for determining how long to wait before the next retry. | |
* | |
* @param state - The current retry call state | |
* @returns The number of seconds to wait | |
*/ | |
export type WaitStrategy<T, Args extends unknown[]> = (state: RetryCallState<T, Args>) => number | Promise<number>; | |
/** | |
* Strategy for determining whether to retry after an attempt. | |
* | |
* @param state - The current retry call state | |
* @returns A boolean indicating whether to retry | |
*/ | |
export type RetryStrategy<T, Args extends unknown[]> = (state: RetryCallState<T, Args>) => boolean | Promise<boolean>; | |
/** | |
* Callback function to be executed at various points in the retry cycle. | |
* | |
* @param state - The current retry call state | |
* @returns Any value or a Promise that resolves to any value | |
*/ | |
export type Callback<T, Args extends unknown[]> = (state: RetryCallState<T, Args>) => any | Promise<any>; | |
/** | |
* Error class that explicitly signals a retry should be attempted. | |
* Throwing this error will bypass any retry strategy and force a retry. | |
*/ | |
export class TryAgain extends Error { | |
constructor(message?: string) { | |
super(message || "TryAgain"); | |
Object.setPrototypeOf(this, new.target.prototype); | |
} | |
} | |
/** | |
* Symbol used to indicate no result was produced. | |
*/ | |
export const NO_RESULT = Symbol("NO_RESULT"); | |
/** | |
* Marker class indicating that an attempt should be made. | |
* Used internally by the retry mechanism. | |
*/ | |
export class DoAttempt { | |
// Marker class indicating that an attempt should be made. | |
} | |
/** | |
* Class representing a sleep action to be performed during retry. | |
* Used internally by the retry mechanism. | |
*/ | |
export class DoSleep { | |
/** | |
* @param sleep - The number of seconds to sleep | |
*/ | |
constructor(public sleep: number) { } | |
} | |
/** | |
* Represents the internal state of the iteration process. | |
* Used to track actions and results during retry execution. | |
*/ | |
export class IterState { | |
/** List of actions to be executed during the current iteration */ | |
public actions: Callback<any, any[]>[] = []; | |
/** Whether the retry strategy returned true */ | |
public retryRunResult: boolean = false; | |
/** Cumulative delay since the first attempt */ | |
public delaySinceFirstAttempt: number = 0; | |
/** Whether the stop strategy returned true */ | |
public stopRunResult: boolean = false; | |
/** Whether this retry was triggered by a TryAgain exception */ | |
public isExplicitRetry: boolean = false; | |
/** | |
* Resets the state for a new iteration. | |
*/ | |
reset() { | |
this.actions = []; | |
this.retryRunResult = false; | |
this.delaySinceFirstAttempt = 0; | |
this.stopRunResult = false; | |
this.isExplicitRetry = false; | |
} | |
} | |
/** | |
* Represents the outcome of a retry attempt. | |
* Contains either a result or an error. | |
* | |
* @template T - The type of the expected result | |
*/ | |
export interface AttemptOutcome<T> { | |
/** The attempt number (1-based) */ | |
attemptNumber: number; | |
/** The result of the attempt, if successful */ | |
result?: T; | |
/** The error from the attempt, if failed */ | |
error?: unknown; | |
} | |
/** | |
* Error thrown when all retry attempts have been exhausted. | |
* Contains information about the last attempt. | |
*/ | |
export class RetryError extends Error { | |
/** | |
* @param lastAttempt - The outcome of the last attempt | |
*/ | |
constructor(public lastAttempt: AttemptOutcome<unknown>) { | |
super(`RetryError: ${JSON.stringify(lastAttempt)}`); | |
Object.setPrototypeOf(this, new.target.prototype); | |
} | |
} | |
/** | |
* RetryCallState is generic over the return type T and the argument list Args. | |
* Represents the current state of a retry operation. | |
* | |
* @template T - The return type of the function being retried | |
* @template Args - The argument types of the function being retried | |
*/ | |
export class RetryCallState<T, Args extends unknown[]> { | |
/** Timestamp when the retry operation started */ | |
public startTime: number; | |
/** Current attempt number (1-based) */ | |
public attemptNumber: number = 1; | |
/** Outcome of the most recent attempt */ | |
public outcome?: AttemptOutcome<T>; | |
/** Timestamp when the most recent attempt completed */ | |
public outcomeTimestamp?: number; | |
/** Total time spent idle between retries (in seconds) */ | |
public idleFor: number = 0; | |
/** Next action to take in the retry process */ | |
public nextAction?: RetryAction; | |
/** Time to sleep before the next attempt (in seconds) */ | |
public upcomingSleep: number = 0; | |
/** Original arguments passed to the function */ | |
public args: Args; | |
/** | |
* Creates a new RetryCallState | |
* | |
* @param retryObject - The retry controller | |
* @param fn - The function being retried | |
* @param args - The arguments for the function | |
*/ | |
constructor( | |
public retryObject: BaseRetrying<T, Args>, | |
public fn: ((...args: Args) => T) | null, | |
args: Args | |
) { | |
this.startTime = Date.now(); | |
this.args = args; | |
} | |
/** | |
* Returns the number of seconds since the first attempt started | |
* @returns The elapsed time in seconds, or null if no attempt has completed | |
*/ | |
get secondsSinceStart(): number | null { | |
if (this.outcomeTimestamp === undefined) return null; | |
return (this.outcomeTimestamp - this.startTime) / 1000; | |
} | |
/** | |
* Prepares the state for the next retry attempt | |
*/ | |
prepareForNextAttempt(): void { | |
this.outcome = undefined; | |
this.outcomeTimestamp = undefined; | |
this.attemptNumber++; | |
this.nextAction = undefined; | |
} | |
/** | |
* Sets the result of a successful attempt | |
* @param val - The result value | |
*/ | |
setResult(val: T): void { | |
this.outcomeTimestamp = Date.now(); | |
this.outcome = { attemptNumber: this.attemptNumber, result: val }; | |
} | |
/** | |
* Sets the exception from a failed attempt | |
* @param err - The error that occurred | |
*/ | |
setException(err: unknown): void { | |
this.outcomeTimestamp = Date.now(); | |
this.outcome = { attemptNumber: this.attemptNumber, error: err }; | |
} | |
} | |
/** | |
* Represents a retry action with a specified sleep duration. | |
* Used internally to schedule the next retry attempt. | |
*/ | |
export class RetryAction { | |
/** | |
* @param sleep - The number of seconds to sleep before the next attempt | |
*/ | |
constructor(public sleep: number) { } | |
} | |
///////////////////////////// | |
// BaseRetrying & Retrying Classes | |
///////////////////////////// | |
/** | |
* BaseRetrying<T, Args> is the abstract retry controller. | |
* Provides core retry functionality that can be extended. | |
* | |
* @template T - The return type of the function being retried | |
* @template Args - The argument types of the function being retried | |
*/ | |
export abstract class BaseRetrying<T, Args extends unknown[]> { | |
/** Function to sleep between retry attempts */ | |
public sleep: SleepFunction; | |
/** Strategy to determine when to stop retrying */ | |
public stop: StopStrategy<T, Args>; | |
/** Strategy to determine how long to wait between attempts */ | |
public wait: WaitStrategy<T, Args>; | |
/** Strategy to determine whether to retry after an attempt */ | |
public retry: RetryStrategy<T, Args>; | |
/** Callback to execute before each attempt */ | |
public before: Callback<T, Args>; | |
/** Callback to execute after each attempt */ | |
public after: Callback<T, Args>; | |
/** Optional callback to execute before sleeping between attempts */ | |
public beforeSleep?: Callback<T, Args>; | |
/** Whether to re-raise the original exception when retries are exhausted */ | |
public reraise: boolean; | |
/** Optional callback to execute when retries are exhausted */ | |
public retryErrorCallback?: Callback<T, Args>; | |
/** Error class to use when retries are exhausted */ | |
public retryErrorCls: typeof RetryError; | |
/** Statistics collected during the retry operation */ | |
public statistics: { [key: string]: any } = {}; | |
/** Internal state for the retry iteration process */ | |
public iterState: IterState = new IterState(); | |
/** | |
* Creates a new BaseRetrying instance | |
* | |
* @param options - Configuration options for the retry controller | |
*/ | |
constructor(options: { | |
sleep?: SleepFunction; | |
stop?: StopStrategy<T, Args>; | |
wait?: WaitStrategy<T, Args>; | |
retry?: RetryStrategy<T, Args>; | |
before?: Callback<T, Args>; | |
after?: Callback<T, Args>; | |
beforeSleep?: Callback<T, Args>; | |
reraise?: boolean; | |
retryErrorCallback?: Callback<T, Args>; | |
retryErrorCls?: typeof RetryError; | |
} = {}) { | |
this.sleep = | |
options.sleep || | |
((seconds: number) => | |
new Promise<void>((resolve) => setTimeout(resolve, seconds * 1000))); | |
this.stop = options.stop || (() => false); | |
this.wait = options.wait || (() => 0); | |
this.retry = | |
options.retry || | |
((state: RetryCallState<T, Args>) => | |
state.outcome ? !!state.outcome.error : false); | |
this.before = options.before || (() => { }); | |
this.after = options.after || (() => { }); | |
this.beforeSleep = options.beforeSleep; | |
this.reraise = options.reraise ?? false; | |
this.retryErrorCallback = options.retryErrorCallback; | |
this.retryErrorCls = options.retryErrorCls || RetryError; | |
} | |
/** | |
* Creates a copy of this retry controller with optionally modified properties | |
* | |
* @param options - Properties to override in the new instance | |
* @returns A new instance with the specified properties | |
*/ | |
copy(options: Partial<{ | |
sleep: SleepFunction; | |
stop: StopStrategy<T, Args>; | |
wait: WaitStrategy<T, Args>; | |
retry: RetryStrategy<T, Args>; | |
before: Callback<T, Args>; | |
after: Callback<T, Args>; | |
beforeSleep: Callback<T, Args>; | |
reraise: boolean; | |
retryErrorCallback: Callback<T, Args>; | |
retryErrorCls: typeof RetryError; | |
}> = {}): this { | |
const cls = this.constructor as { new(opts: any): any }; | |
return new cls({ | |
sleep: options.sleep || this.sleep, | |
stop: options.stop || this.stop, | |
wait: options.wait || this.wait, | |
retry: options.retry || this.retry, | |
before: options.before || this.before, | |
after: options.after || this.after, | |
beforeSleep: options.beforeSleep ?? this.beforeSleep, | |
reraise: options.reraise ?? this.reraise, | |
retryErrorCallback: options.retryErrorCallback || this.retryErrorCallback, | |
retryErrorCls: options.retryErrorCls || this.retryErrorCls, | |
}); | |
} | |
/** | |
* Initializes the retry operation | |
* Sets up initial statistics and resets state | |
*/ | |
begin(): void { | |
this.statistics = {}; | |
this.statistics["start_time"] = Date.now(); | |
this.statistics["attempt_number"] = 1; | |
this.statistics["idle_for"] = 0; | |
} | |
/** | |
* Adds an action to be executed during the current iteration | |
* | |
* @param fn - The callback function to add | |
*/ | |
protected _addAction(fn: Callback<T, Args>): void { | |
this.iterState.actions.push(fn as any); | |
} | |
/** | |
* Executes the retry strategy and stores the result | |
* | |
* @param state - The current retry call state | |
*/ | |
protected async _runRetry(state: RetryCallState<T, Args>): Promise<void> { | |
const res = this.retry(state); | |
this.iterState.retryRunResult = res instanceof Promise ? await res : res; | |
} | |
/** | |
* Executes the wait strategy and stores the result | |
* | |
* @param state - The current retry call state | |
*/ | |
protected async _runWait(state: RetryCallState<T, Args>): Promise<void> { | |
const sleepTime = this.wait(state); | |
state.upcomingSleep = sleepTime instanceof Promise ? await sleepTime : sleepTime; | |
} | |
/** | |
* Executes the stop strategy and stores the result | |
* | |
* @param state - The current retry call state | |
*/ | |
protected async _runStop(state: RetryCallState<T, Args>): Promise<void> { | |
const stopRes = this.stop(state); | |
this.iterState.stopRunResult = stopRes instanceof Promise ? await stopRes : stopRes; | |
this.statistics["delay_since_first_attempt"] = state.secondsSinceStart; | |
} | |
/** | |
* Prepares the iteration process for the current state. | |
* Determines what actions need to be executed based on the current state. | |
* | |
* @param state - The current retry call state | |
*/ | |
protected _beginIter(state: RetryCallState<T, Args>): void { | |
this.iterState.reset(); | |
if (!state.outcome) { | |
if (this.before) { | |
this._addAction(this.before); | |
} | |
this._addAction((_state: RetryCallState<T, Args>) => new DoAttempt()); | |
return; | |
} | |
this.iterState.isExplicitRetry = state.outcome.error instanceof TryAgain; | |
if (!this.iterState.isExplicitRetry) { | |
this._addAction(async (s: RetryCallState<T, Args>) => { | |
await this._runRetry(s); | |
}); | |
} | |
this._addAction(async (s: RetryCallState<T, Args>) => { | |
if (!this.iterState.isExplicitRetry && !this.iterState.retryRunResult) { | |
return state.outcome?.result; | |
} | |
if (this.after) { | |
await this.after(s); | |
} | |
await this._runWait(s); | |
await this._runStop(s); | |
if (this.iterState.stopRunResult) { | |
if (this.retryErrorCallback) { | |
await this.retryErrorCallback(s); | |
return; | |
} | |
if (this.reraise) { | |
throw state.outcome?.error; | |
} | |
throw new this.retryErrorCls(state.outcome!); | |
} | |
state.idleFor += state.upcomingSleep; | |
this.statistics["idle_for"] = (this.statistics["idle_for"] as number | undefined || 0) + state.upcomingSleep; | |
this.statistics["attempt_number"]++; | |
if (this.beforeSleep) { | |
await this.beforeSleep(s); | |
} | |
return new DoSleep(state.upcomingSleep); | |
}); | |
} | |
/** | |
* Executes one iteration of the retry process. | |
* | |
* @param state - The current retry call state | |
* @returns A DoAttempt, DoSleep, or the final result | |
*/ | |
async iter(state: RetryCallState<T, Args>): Promise<DoAttempt | DoSleep | unknown> { | |
this._beginIter(state); | |
let result: unknown; | |
for (const action of this.iterState.actions) { | |
result = await action(state as any); | |
} | |
return result; | |
} | |
/** | |
* Runs the function with retry logic. | |
* | |
* @param fn - The function to run with retries | |
* @param args - The arguments to pass to the function | |
* @returns A promise that resolves with the eventual result | |
*/ | |
abstract run(fn: (...args: Args) => T, ...args: Args): Promise<Awaited<T>>; | |
} | |
/** | |
* Retrying<T, Args> is the concrete implementation. | |
* Provides a promise-based retry mechanism for any function. | |
* | |
* @template T - The return type of the function being retried | |
* @template Args - The argument types of the function being retried | |
*/ | |
export class Retrying<T, Args extends unknown[]> extends BaseRetrying<T, Args> { | |
/** | |
* Runs the function with retry logic. | |
* | |
* @param fn - The function to run with retries | |
* @param args - The arguments to pass to the function | |
* @returns A promise that resolves with the eventual result or rejects with an error | |
*/ | |
async run(fn: (...args: Args) => T, ...args: Args): Promise<Awaited<T>> { | |
this.begin(); | |
const state = new RetryCallState<T, Args>(this, fn, args); | |
while (true) { | |
const action = await this.iter(state); | |
if (action instanceof DoAttempt) { | |
try { | |
const result = fn(...args); | |
// Check if result is thenable in a type-safe way | |
if (result !== null && result !== undefined && | |
typeof (result as any).then === "function") { | |
state.setResult(await result as any); | |
} else { | |
state.setResult(result); | |
} | |
} catch (err) { | |
state.setException(err); | |
} | |
} else if (action instanceof DoSleep) { | |
state.prepareForNextAttempt(); | |
await this.sleep(action.sleep); | |
} else { | |
return action as Awaited<T>; | |
} | |
} | |
} | |
} | |
///////////////////////////// | |
// Strategies | |
///////////////////////////// | |
// Retry strategies | |
/** | |
* Creates a strategy that never retries, regardless of outcome. | |
* | |
* @returns A retry strategy that always returns false | |
*/ | |
export function retryNever<T = unknown, Args extends unknown[] = unknown[]>(): RetryStrategy<T, Args> { | |
return () => false; | |
} | |
/** | |
* Creates a strategy that always retries, regardless of outcome. | |
* | |
* @returns A retry strategy that always returns true | |
*/ | |
export function retryAlways<T = unknown, Args extends unknown[] = unknown[]>(): RetryStrategy<T, Args> { | |
return () => true; | |
} | |
/** | |
* Creates a strategy that retries if an exception matches a predicate. | |
* | |
* @param predicate - Function that takes an error and returns true if it should trigger a retry | |
* @returns A retry strategy that checks exceptions against the predicate | |
*/ | |
export function retryIfException<T = unknown, Args extends unknown[] = unknown[]>( | |
predicate: (error: unknown) => boolean | |
): RetryStrategy<T, Args> { | |
return (state) => { | |
if (!state.outcome) throw new Error("Outcome not set"); | |
return !!state.outcome.error && predicate(state.outcome.error); | |
}; | |
} | |
/** | |
* Creates a strategy that retries if an exception is of one of the specified types. | |
* | |
* @param exceptionTypes - List of exception classes to retry on | |
* @returns A retry strategy that checks exception types | |
*/ | |
export function retryIfExceptionType<T = unknown, Args extends unknown[] = unknown[]>( | |
...exceptionTypes: Array<new (...args: any[]) => unknown> | |
): RetryStrategy<T, Args> { | |
return retryIfException((err) => | |
exceptionTypes.some((type) => err instanceof type) | |
); | |
} | |
/** | |
* Creates a strategy that retries if the result matches a predicate. | |
* | |
* @param predicate - Function that takes a result and returns true if it should trigger a retry | |
* @returns A retry strategy that checks results against the predicate | |
*/ | |
export function retryIfResult<T, Args extends unknown[] = unknown[]>( | |
predicate: (result: T) => boolean | |
): RetryStrategy<T, Args> { | |
return (state) => { | |
if (!state.outcome) throw new Error("Outcome not set"); | |
return !state.outcome.error && predicate(state.outcome.result as T); | |
}; | |
} | |
/** | |
* Creates a strategy that retries if the result does NOT match a predicate. | |
* | |
* @param predicate - Function that takes a result and returns true if it should NOT trigger a retry | |
* @returns A retry strategy that checks results against the negated predicate | |
*/ | |
export function retryIfNotResult<T, Args extends unknown[] = unknown[]>( | |
predicate: (result: T) => boolean | |
): RetryStrategy<T, Args> { | |
return (state) => { | |
if (!state.outcome) throw new Error("Outcome not set"); | |
return !state.outcome.error && !predicate(state.outcome.result as T); | |
}; | |
} | |
// Stop strategies | |
/** | |
* Creates a strategy that never stops retrying. | |
* | |
* @returns A stop strategy that always returns false | |
*/ | |
export function stopNever<T = unknown, Args extends unknown[] = unknown[]>(): StopStrategy<T, Args> { | |
return () => false; | |
} | |
/** | |
* Creates a strategy that stops retrying after a maximum number of attempts. | |
* | |
* @param maxAttempts - The maximum number of attempts before stopping | |
* @returns A stop strategy that checks the attempt count | |
*/ | |
export function stopAfterAttempt<T = unknown, Args extends unknown[] = unknown[]>(maxAttempts: number): StopStrategy<T, Args> { | |
return (state) => state.attemptNumber >= maxAttempts; | |
} | |
/** | |
* Creates a strategy that stops retrying after a maximum delay since the first attempt. | |
* | |
* @param maxDelaySeconds - The maximum delay in seconds before stopping | |
* @returns A stop strategy that checks the elapsed time | |
*/ | |
export function stopAfterDelay<T = unknown, Args extends unknown[] = unknown[]>(maxDelaySeconds: number): StopStrategy<T, Args> { | |
return (state) => { | |
if (state.secondsSinceStart === null) throw new Error("Start time not set"); | |
return state.secondsSinceStart >= maxDelaySeconds; | |
}; | |
} | |
/** | |
* Creates a strategy that stops retrying if the next attempt would exceed the maximum delay. | |
* | |
* @param maxDelaySeconds - The maximum delay in seconds before stopping | |
* @returns A stop strategy that checks the potential elapsed time after the next sleep | |
*/ | |
export function stopBeforeDelay<T = unknown, Args extends unknown[] = unknown[]>(maxDelaySeconds: number): StopStrategy<T, Args> { | |
return (state) => { | |
if (state.secondsSinceStart === null) throw new Error("Start time not set"); | |
return state.secondsSinceStart + state.upcomingSleep >= maxDelaySeconds; | |
}; | |
} | |
// Wait strategies | |
/** | |
* Creates a strategy that waits a fixed amount of time between attempts. | |
* | |
* @param waitTime - The fixed wait time in seconds | |
* @returns A wait strategy that returns the fixed wait time | |
*/ | |
export function waitFixed<T = unknown, Args extends unknown[] = unknown[]>(waitTime: number): WaitStrategy<T, Args> { | |
return () => waitTime; | |
} | |
/** | |
* Creates a strategy that doesn't wait between attempts. | |
* | |
* @returns A wait strategy that returns 0 | |
*/ | |
export function waitNone<T = unknown, Args extends unknown[] = unknown[]>(): WaitStrategy<T, Args> { | |
return waitFixed(0); | |
} | |
/** | |
* Creates a strategy that waits a random amount of time between attempts. | |
* | |
* @param min - The minimum wait time in seconds | |
* @param max - The maximum wait time in seconds | |
* @returns A wait strategy that returns a random value between min and max | |
*/ | |
export function waitRandom<T = unknown, Args extends unknown[] = unknown[]>(min: number = 0, max: number = 1): WaitStrategy<T, Args> { | |
return () => min + Math.random() * (max - min); | |
} | |
/** | |
* Creates a strategy that combines multiple wait strategies by adding their wait times. | |
* This is asynchronous so that it can await each strategy's value. | |
* | |
* @param strategies - The wait strategies to combine | |
* @returns A wait strategy that returns the sum of all strategies | |
*/ | |
export function waitCombine<T = unknown, Args extends unknown[] = unknown[]>(...strategies: WaitStrategy<T, Args>[]): WaitStrategy<T, Args> { | |
return async (state: RetryCallState<T, Args>): Promise<number> => { | |
let sum = 0; | |
for (const strategy of strategies) { | |
const value = strategy(state); | |
sum += value instanceof Promise ? await value : value; | |
} | |
return sum; | |
}; | |
} | |
/** | |
* Creates a strategy that uses different wait strategies for different attempts. | |
* | |
* @param strategies - The wait strategies to use, indexed by attempt number | |
* @returns A wait strategy that selects the appropriate strategy based on attempt number | |
*/ | |
export function waitChain<T = unknown, Args extends unknown[] = unknown[]>(...strategies: WaitStrategy<T, Args>[]): WaitStrategy<T, Args> { | |
return (state: RetryCallState<T, Args>) => { | |
const index = Math.min(Math.max(state.attemptNumber - 1, 0), strategies.length - 1); | |
return strategies[index](state); | |
}; | |
} | |
/** | |
* Creates a strategy that increases the wait time linearly for each attempt. | |
* | |
* @param start - The starting wait time in seconds | |
* @param increment - The amount to increase the wait time by for each attempt | |
* @param max - The maximum wait time in seconds | |
* @returns A wait strategy that returns an incrementing wait time | |
*/ | |
export function waitIncrementing<T = unknown, Args extends unknown[] = unknown[]>( | |
start: number = 0, | |
increment: number = 1, | |
max: number = Infinity | |
): WaitStrategy<T, Args> { | |
return (state: RetryCallState<T, Args>) => { | |
const result = start + increment * (state.attemptNumber - 1); | |
return Math.min(Math.max(0, result), max); | |
}; | |
} | |
/** | |
* Creates a strategy that increases the wait time exponentially for each attempt. | |
* | |
* @param multiplier - The base multiplier for the wait time | |
* @param max - The maximum wait time in seconds | |
* @param expBase - The base for the exponential calculation | |
* @param min - The minimum wait time in seconds | |
* @returns A wait strategy that returns an exponentially increasing wait time | |
*/ | |
export function waitExponential<T = unknown, Args extends unknown[] = unknown[]>( | |
multiplier: number = 1, | |
max: number = Infinity, | |
expBase: number = 2, | |
min: number = 0 | |
): WaitStrategy<T, Args> { | |
return (state: RetryCallState<T, Args>) => { | |
let exp; | |
try { | |
exp = Math.pow(expBase, state.attemptNumber - 1); | |
} catch { | |
return max; | |
} | |
const result = multiplier * exp; | |
return Math.min(Math.max(result, min), max); | |
}; | |
} | |
/** | |
* Creates a strategy that waits a random amount of time between attempts, | |
* with the maximum possible wait time increasing exponentially. | |
* | |
* @param multiplier - The base multiplier for the wait time | |
* @param max - The maximum wait time in seconds | |
* @param expBase - The base for the exponential calculation | |
* @param min - The minimum wait time in seconds | |
* @returns A wait strategy that returns a random value between min and the exponential maximum | |
*/ | |
export function waitRandomExponential<T = unknown, Args extends unknown[] = unknown[]>( | |
multiplier: number = 1, | |
max: number = Infinity, | |
expBase: number = 2, | |
min: number = 0 | |
): WaitStrategy<T, Args> { | |
const expWait = waitExponential(multiplier, max, expBase, min); | |
return (state: RetryCallState<T, Args>) => { | |
// Use type assertion to make TypeScript happy | |
const high = expWait(state as any) as number; | |
return Math.random() * (high - min) + min; | |
}; | |
} | |
/** | |
* Creates a strategy that waits an exponentially increasing amount of time | |
* plus a small random jitter to prevent thundering herd problems. | |
* | |
* @param initial - The initial wait time in seconds | |
* @param max - The maximum wait time in seconds | |
* @param expBase - The base for the exponential calculation | |
* @param jitter - The maximum amount of random jitter to add | |
* @returns A wait strategy that returns an exponentially increasing wait time with jitter | |
*/ | |
export function waitExponentialJitter<T = unknown, Args extends unknown[] = unknown[]>( | |
initial: number = 1, | |
max: number = Infinity, | |
expBase: number = 2, | |
jitter: number = 1 | |
): WaitStrategy<T, Args> { | |
return (state: RetryCallState<T, Args>) => { | |
const j = Math.random() * jitter; | |
let exp; | |
try { | |
exp = Math.pow(expBase, state.attemptNumber - 1); | |
} catch { | |
return max; | |
} | |
const result = initial * exp + j; | |
return Math.min(Math.max(result, 0), max); | |
}; | |
} | |
///////////////////////////// | |
// retry() Decorator Overloads | |
///////////////////////////// | |
/** | |
* Options for configuring the retry behavior. | |
*/ | |
export interface RetryOptions { | |
/** Function to sleep between retry attempts */ | |
sleep?: SleepFunction; | |
/** Strategy to determine when to stop retrying */ | |
stop?: StopStrategy<any, any[]>; | |
/** Strategy to determine how long to wait between attempts */ | |
wait?: WaitStrategy<any, any[]>; | |
/** Strategy to determine whether to retry after an attempt */ | |
retry?: RetryStrategy<any, any[]>; | |
/** Callback to execute before each attempt */ | |
before?: Callback<any, any[]>; | |
/** Callback to execute after each attempt */ | |
after?: Callback<any, any[]>; | |
/** Optional callback to execute before sleeping between attempts */ | |
beforeSleep?: Callback<any, any[]>; | |
/** Whether to re-raise the original exception when retries are exhausted */ | |
reraise?: boolean; | |
/** Optional callback to execute when retries are exhausted */ | |
retryErrorCallback?: Callback<any, any[]>; | |
/** Error class to use when retries are exhausted */ | |
retryErrorCls?: typeof RetryError; | |
} | |
/** | |
* Overload: Called as retry(fn) | |
* Wraps a function with retry logic using default retry settings. | |
* | |
* @template T - The return type of the function | |
* @template Args - The argument types of the function | |
* @param fn - The function to retry | |
* @returns A wrapped function that retries on failure | |
*/ | |
export function retry<T, Args extends any[]>( | |
fn: (...args: Args) => T | |
): (...args: Args) => Promise<Awaited<T>>; | |
/** | |
* Overload: Called as retry(options)(fn) | |
* Creates a retry wrapper function with custom settings. | |
* | |
* @param options - The retry configuration options | |
* @returns A function that can wrap other functions with retry logic | |
*/ | |
export function retry( | |
options: RetryOptions | |
): <T, Args extends any[]>( | |
fn: (...args: Args) => T | |
) => (...args: Args) => Promise<Awaited<T>>; | |
/** | |
* Overload: Called as retry(options, fn) | |
* Wraps a function with retry logic using custom retry settings. | |
* | |
* @template T - The return type of the function | |
* @template Args - The argument types of the function | |
* @param options - The retry configuration options | |
* @param fn - The function to retry | |
* @returns A wrapped function that retries on failure | |
*/ | |
export function retry<T, Args extends any[]>( | |
options: RetryOptions, | |
fn: (...args: Args) => T | |
): (...args: Args) => Promise<Awaited<T>>; | |
/** | |
* Implementation of the retry decorator. | |
* Can be called in three different ways: | |
* 1. retry(fn) - Wraps a function with default retry settings | |
* 2. retry(options)(fn) - Creates a retry wrapper function with custom settings | |
* 3. retry(options, fn) - Wraps a function with custom retry settings | |
* | |
* @template T - The return type of the function | |
* @template Args - The argument types of the function | |
* @param optionsOrFn - Either a retry options object or the function to retry | |
* @param maybeFn - The function to retry (if options are provided separately) | |
* @returns Either a wrapped function or a wrapper function | |
*/ | |
export function retry<T, Args extends any[]>( | |
optionsOrFn: RetryOptions | ((...args: Args) => T), | |
maybeFn?: (...args: Args) => T | |
): ((...args: Args) => Promise<Awaited<T>>) | (<U, UArgs extends any[]>(fn: (...args: UArgs) => U) => (...args: UArgs) => Promise<Awaited<U>>) { | |
if (typeof optionsOrFn === "function") { | |
// Called as retry(fn) | |
const fn = optionsOrFn; | |
const ret = new Retrying<T, Args>({}); | |
return (...args: Args) => ret.run(fn, ...args); | |
} else { | |
const options: RetryOptions = optionsOrFn; | |
if (maybeFn) { | |
// Called as retry(options, fn) | |
const fn = maybeFn; | |
const ret = new Retrying<T, Args>(options); | |
return (...args: Args) => ret.run(fn, ...args); | |
} else { | |
// Called as retry(options)(fn) | |
return <U, UArgs extends any[]>( | |
fn: (...args: UArgs) => U | |
): (...args: UArgs) => Promise<Awaited<U>> => { | |
const ret = new Retrying<U, UArgs>(options); | |
return (...args: UArgs) => ret.run(fn, ...args); | |
}; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment