mirror of
https://github.com/Kir-Antipov/mc-publish.git
synced 2025-03-14 18:14:46 +01:00
Moved sleep
and retry
to async-utils
Also, made a few more helper methods
This commit is contained in:
parent
9ca1e9f0d9
commit
9d7bf0f627
4 changed files with 258 additions and 22 deletions
160
src/utils/async-utils.ts
Normal file
160
src/utils/async-utils.ts
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
export default function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
98
tests/unit/utils/async-utils.spec.ts
Normal file
98
tests/unit/utils/async-utils.spec.ts
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue