diff --git a/src/utils/async-utils.ts b/src/utils/async-utils.ts new file mode 100644 index 0000000..686b069 --- /dev/null +++ b/src/utils/async-utils.ts @@ -0,0 +1,160 @@ +import { isError } from "@/utils/errors"; +import { Awaitable } from "@/utils/types"; + +/** + * Checks if the given object is a {@link Promise}. + * + * @template T - The type of value that the `Promise` would return upon resolution. + * + * @param obj - The object to check. + * + * @returns `true` if the object is a `Promise`; otherwise, `false`. + */ +export function isPromise<T>(obj: unknown): obj is Promise<T> { + return typeof (obj as Promise<T>)?.then === "function"; +} + +/** + * Sleep for the specified amount of time in milliseconds. + * + * @param ms - The time in milliseconds to sleep. + * + * @returns A {@link Promise} that resolves after the specified time. + */ +export function sleep(ms: number): Promise<void> { + // Technically, it's the HTML Standard, + // but this rule is also **mostly** true for the NodeJS environment. + // https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers + const MIN_DELAY = 4; + if (ms < MIN_DELAY) { + return Promise.resolve(); + } + + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Runs a function asynchronously and returns its result. + * + * @template T - The type of value returned by the function. + * + * @param func - A function to execute asynchronously. + * + * @returns A promise that resolves with the return value of the executed function. + */ +export async function run<T>(func: () => Awaitable<T>): Promise<T> { + return await func(); +} + +/** + * Safely executes the provided function, returning both the result and error as a tuple. + * + * @template T - The type of value returned by the function. + * @template E - The type of the returned error. + * + * @param func - A function or async function to execute safely. + * + * @returns A promise resolving to a tuple containing the result and error. + * + * - The result is at index 0 and the error is at index 1. + * - If the function succeeds, the error will be `undefined`. + * - If the function fails, the result will be `undefined`. + */ +export async function runSafely<T, E = unknown>(func: () => Awaitable<T>): Promise<[T, E]> { + return await run(func) + .then(value => [value, undefined] as [T, E]) + .catch(error => [undefined, error] as [T, E]); +} + +/** + * Options for the `retry` function. + */ +interface RetryOptions { + /** + * The time in milliseconds to wait before retrying. + * + * Default is `0`. + */ + delay?: number; + + /** + * The maximum number of attempts. If negative, will retry indefinitely. + * + * Default is `-1`. + */ + maxAttempts?: number; + + /** + * A callback function that can be used to log errors and/or determine if the error is recoverable. + * + * - If `onError` returns `true` or nothing at all, the error is considered recoverable. + * - If `onError` returns `false`, the error is considered unrecoverable and the retry loop will terminate. + * + * @param error - The error that was thrown during the execution of the `func` in `retry`. + */ + onError?: ErrorHandler; +} + +/** + * Represents an error handler. + */ +interface ErrorHandler { + /** + * Handles the given error. + * + * @param error - The error that should be handled. + * + * @returns A boolean or a `Promise` resolving to a boolean that represents if the error was successfully handled. + */ + (error: Error): Awaitable<boolean | void>; +} + +/** + * Executes a given function `func` and retries it if an error occurs. + * + * @template T - The type of value returned by the function. + * + * @param func - The function to execute and potentially retry. + * @param options - The options for the retry function. + * + * @returns The result of a successful execution of `func`. + */ +export async function retry<T>(func: () => Awaitable<T>, options?: RetryOptions): Promise<T> { + const delay = options?.delay ?? 0; + const maxAttempts = options?.maxAttempts ?? -1; + const onError = options?.onError; + + let attempts = 0; + while (true) { + ++attempts; + + try { + return await func(); + } catch (e: unknown) { + const isNumberOfAttemptsExceeded = maxAttempts >= 0 && attempts >= maxAttempts; + const isRecoverable = !isNumberOfAttemptsExceeded && await isErrorHandled(e, onError); + if (!isRecoverable) { + throw e; + } + } + + await sleep(delay); + } +} + +/** + * Checks if an error was handled by the provided error handler function. + * + * @param error - The error to check if it's handled. + * @param handler - The error handler function. + * + * @returns A `Promise` resolving to a boolean that represents if the error was handled. + */ +async function isErrorHandled(error: unknown, handler?: ErrorHandler): Promise<boolean> { + if (!isError(error)) { + return false; + } + + const handlerOutput = await handler?.(error); + return handlerOutput || handlerOutput === undefined; +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts deleted file mode 100644 index f920ed7..0000000 --- a/src/utils/retry.ts +++ /dev/null @@ -1,19 +0,0 @@ -import sleep from "./sleep"; - -export default async function retry<T>({ func, delay = 0, maxAttempts = -1, softErrorPredicate, errorCallback }: { func: () => T | Promise<T>, delay?: number, maxAttempts?: number, softErrorPredicate?: (error: unknown) => boolean, errorCallback?: (error: unknown) => void }): Promise<T> { - let attempts = 0; - while (true) { - try { - return await func(); - } catch (e) { - const isSoft = softErrorPredicate ? softErrorPredicate(e) : e?.soft; - if (!isSoft || maxAttempts >= 0 && ++attempts >= maxAttempts ) { - throw e; - } - if (errorCallback) { - errorCallback(e); - } - } - await sleep(delay); - } -} diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts deleted file mode 100644 index ffbc47d..0000000 --- a/src/utils/sleep.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function sleep(ms: number): Promise<void> { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/tests/unit/utils/async-utils.spec.ts b/tests/unit/utils/async-utils.spec.ts new file mode 100644 index 0000000..b4b825a --- /dev/null +++ b/tests/unit/utils/async-utils.spec.ts @@ -0,0 +1,98 @@ +import { isPromise, sleep, run, runSafely, retry } from "@/utils/async-utils"; + +describe("isPromise", () => { + test("returns true when input is a Promise", () => { + const promise = new Promise(resolve => resolve(undefined)); + expect(isPromise(promise)).toBe(true); + }); + + test("returns false when input is not a Promise", () => { + const notAPromise = {}; + expect(isPromise(notAPromise)).toBe(false); + }); +}); + +describe("sleep", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test("resolves after specified time", async () => { + const start = Date.now(); + const promise = sleep(1000); + + await jest.advanceTimersByTimeAsync(1000); + await promise; + + const elapsed = Date.now() - start; + return expect(elapsed).toBe(1000); + }); + + test("resolves immediately if time is less than or equal to 0", async () => { + const start = Date.now(); + const promise = sleep(-10); + + await promise; + + const elapsed = Date.now() - start; + return expect(elapsed).toBe(0); + }); +}); + +describe("run", () => { + test("runs the provided function asynchronously", async () => { + const func = jest.fn().mockResolvedValue(42); + const result = await run(func); + + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenCalledWith(); + expect(result).toBe(42); + }); +}); + +describe("runSafely", () => { + test("returns result and undefined error when function succeeds", async () => { + const func = jest.fn().mockResolvedValue(42); + const [result, error] = await runSafely(func); + + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenCalledWith(); + expect(result).toBe(42); + expect(error).toBeUndefined(); + }); + + test("returns undefined result and error when function fails", async () => { + const func = jest.fn().mockRejectedValue(new Error("Error occurred")); + const [result, error] = await runSafely(func); + + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenCalledWith(); + expect(result).toBeUndefined(); + expect(error).toEqual(new Error("Error occurred")); + }); +}); + +describe("retry", () => { + test("retries the function when it fails", async () => { + const func = jest.fn().mockRejectedValueOnce(new Error("Error occurred")).mockResolvedValue(42); + const result = await retry(func, { maxAttempts: 2 }); + + expect(func).toHaveBeenCalledTimes(2); + expect(result).toBe(42); + }); + + test("does not retry the function when error is not recoverable", async () => { + const func = jest.fn().mockRejectedValue(new Error("Error occurred")); + const onError = jest.fn().mockResolvedValue(false); + + await expect(retry(func, { onError, maxAttempts: 2 })).rejects.toThrow("Error occurred"); + expect(func).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + }); + + test("stops retrying after max attempts", async () => { + const func = jest.fn().mockRejectedValue(new Error("Error occurred")); + + await expect(retry(func, { maxAttempts: 2 })).rejects.toThrow("Error occurred"); + expect(func).toHaveBeenCalledTimes(2); + }); +});