Skip to content

Instantly share code, notes, and snippets.

@xantiagoma
Created April 5, 2025 01:09
Show Gist options
  • Save xantiagoma/3d6438141ffcdbcd0984abdf1bd791c1 to your computer and use it in GitHub Desktop.
Save xantiagoma/3d6438141ffcdbcd0984abdf1bd791c1 to your computer and use it in GitHub Desktop.
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);
});
});
// 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