mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
Compare commits
2 Commits
renovate/a
...
feat/stdli
| Author | SHA1 | Date | |
|---|---|---|---|
| a61fb85088 | |||
| 01b13d6a65 |
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './retry';
|
||||||
export * from './sleep';
|
export * from './sleep';
|
||||||
export * from './tryIt';
|
export * from './tryIt';
|
||||||
|
|||||||
250
core/stdlib/src/async/retry/index.test.ts
Normal file
250
core/stdlib/src/async/retry/index.test.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { retry } from '.';
|
||||||
|
|
||||||
|
describe('retry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('return the result on first successful attempt', async () => {
|
||||||
|
const successFn = vi.fn().mockResolvedValue('success');
|
||||||
|
|
||||||
|
const result = await retry(successFn);
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(successFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(successFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use default times value of 2', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
await expect(retry(failingFn)).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry the specified number of times on failure', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
await expect(retry(failingFn, { times: 3 })).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
expect(failingFn).toHaveBeenNthCalledWith(1, { count: 1, stop: expect.any(Function) });
|
||||||
|
expect(failingFn).toHaveBeenNthCalledWith(2, { count: 2, stop: expect.any(Function) });
|
||||||
|
expect(failingFn).toHaveBeenNthCalledWith(3, { count: 3, stop: expect.any(Function) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('succeed on the last attempt', async () => {
|
||||||
|
const partiallyFailingFn = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('First failure'))
|
||||||
|
.mockRejectedValueOnce(new Error('Second failure'))
|
||||||
|
.mockResolvedValue('success');
|
||||||
|
|
||||||
|
const result = await retry(partiallyFailingFn, { times: 3 });
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(partiallyFailingFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use custom shouldRetry function', async () => {
|
||||||
|
const networkError = new Error('Network failed');
|
||||||
|
networkError.name = 'NetworkError';
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(networkError);
|
||||||
|
|
||||||
|
await expect(retry(failingFn, {
|
||||||
|
times: 3,
|
||||||
|
shouldRetry: (error) => error.name !== 'NetworkError'
|
||||||
|
})).rejects.toThrow('Network failed');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry with custom shouldRetry based on count', async () => {
|
||||||
|
const testError = new Error('Test error');
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(testError);
|
||||||
|
|
||||||
|
await expect(retry(failingFn, {
|
||||||
|
times: 5,
|
||||||
|
shouldRetry: (error, count) => count < 3 // Only retry first 2 attempts
|
||||||
|
})).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retry specific error types with custom shouldRetry', async () => {
|
||||||
|
const temporaryError = new Error('Temporary failure');
|
||||||
|
temporaryError.name = 'TemporaryError';
|
||||||
|
const permanentError = new Error('Permanent failure');
|
||||||
|
permanentError.name = 'PermanentError';
|
||||||
|
|
||||||
|
const failingFn = vi.fn()
|
||||||
|
.mockRejectedValueOnce(temporaryError)
|
||||||
|
.mockRejectedValueOnce(temporaryError)
|
||||||
|
.mockRejectedValueOnce(permanentError);
|
||||||
|
|
||||||
|
await expect(retry(failingFn, {
|
||||||
|
times: 5,
|
||||||
|
shouldRetry: (error) => error.name === 'TemporaryError'
|
||||||
|
})).rejects.toThrow('Permanent failure');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wait for the specified delay between retries', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
const retryPromise = retry(failingFn, { times: 3, delay: 1000 });
|
||||||
|
|
||||||
|
// First call should happen immediately
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time to trigger first retry
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Advance time to trigger second retry
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
await expect(retryPromise).rejects.toThrow('Test error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use dynamic delay function', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
const delayFn = vi.fn((count: number) => count * 500);
|
||||||
|
|
||||||
|
const retryPromise = retry(failingFn, { times: 3, delay: delayFn });
|
||||||
|
|
||||||
|
// First call should happen immediately
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// First retry should wait for delay(2) = 1000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(delayFn).toHaveBeenCalledWith(2);
|
||||||
|
|
||||||
|
// Second retry should wait for delay(3) = 1500ms
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
expect(delayFn).toHaveBeenCalledWith(3);
|
||||||
|
|
||||||
|
await expect(retryPromise).rejects.toThrow('Test error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('not delay after the last attempt', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
const retryPromise = retry(failingFn, { times: 2, delay: 1000 });
|
||||||
|
|
||||||
|
// Wait for the first retry delay
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
// Should complete without further delays
|
||||||
|
await expect(retryPromise).rejects.toThrow('Test error');
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle zero delay', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
await expect(retry(failingFn, { times: 3, delay: 0 })).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pass the count parameter to the function', async () => {
|
||||||
|
const countingFn = vi.fn(async ({ count }: { count: number }) => {
|
||||||
|
if (count < 3) {
|
||||||
|
throw new Error(`Attempt ${count} failed`);
|
||||||
|
}
|
||||||
|
return `Success on attempt ${count}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await retry(countingFn, { times: 3 });
|
||||||
|
|
||||||
|
expect(result).toBe('Success on attempt 3');
|
||||||
|
expect(countingFn).toHaveBeenCalledWith({ count: 1, stop: expect.any(Function) });
|
||||||
|
expect(countingFn).toHaveBeenCalledWith({ count: 2, stop: expect.any(Function) });
|
||||||
|
expect(countingFn).toHaveBeenCalledWith({ count: 3, stop: expect.any(Function) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw the last error when all attempts fail', async () => {
|
||||||
|
const firstError = new Error('First error');
|
||||||
|
const lastError = new Error('Last error');
|
||||||
|
const failingFn = vi.fn()
|
||||||
|
.mockRejectedValueOnce(firstError)
|
||||||
|
.mockRejectedValueOnce(lastError);
|
||||||
|
|
||||||
|
await expect(retry(failingFn, { times: 2 })).rejects.toThrow('Last error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle times value of 1', async () => {
|
||||||
|
const failingFn = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||||
|
|
||||||
|
await expect(retry(failingFn, { times: 1 })).rejects.toThrow('Test error');
|
||||||
|
|
||||||
|
expect(failingFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle function that returns non-promise values', async () => {
|
||||||
|
const syncFn = vi.fn(async ({ count }: { count: number }) => {
|
||||||
|
if (count === 1) {
|
||||||
|
throw new Error('First attempt failed');
|
||||||
|
}
|
||||||
|
return 'success';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await retry(syncFn, { times: 2 });
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(syncFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handle complex return types', async () => {
|
||||||
|
const complexFn = vi.fn().mockResolvedValue({
|
||||||
|
data: [1, 2, 3],
|
||||||
|
status: 'ok',
|
||||||
|
metadata: { timestamp: 123456 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await retry(complexFn);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
data: [1, 2, 3],
|
||||||
|
status: 'ok',
|
||||||
|
metadata: { timestamp: 123456 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop retrying when stop function is called', async () => {
|
||||||
|
const customError = new Error('Custom stop error');
|
||||||
|
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error: any) => void }) => {
|
||||||
|
if (count === 2) {
|
||||||
|
stop(customError);
|
||||||
|
}
|
||||||
|
throw new Error(`Attempt ${count} failed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(retry(stopFn, { times: 5 })).rejects.toThrow('Custom stop error');
|
||||||
|
|
||||||
|
expect(stopFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop retrying with undefined error when stop is called without argument', async () => {
|
||||||
|
const stopFn = vi.fn(async ({ count, stop }: { count: number, stop: (error?: any) => void }) => {
|
||||||
|
if (count === 2) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
throw new Error(`Attempt ${count} failed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(retry(stopFn, { times: 5 })).rejects.toBeUndefined();
|
||||||
|
|
||||||
|
expect(stopFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
|
import { tryIt } from '../tryIt';
|
||||||
|
import { sleep } from '../sleep';
|
||||||
|
import { isFunction } from '../../types';
|
||||||
|
|
||||||
export interface RetryOptions {
|
export interface RetryOptions {
|
||||||
times?: number;
|
times?: number;
|
||||||
delay?: number;
|
delay?: number | ((count: number) => number);
|
||||||
backoff: (options: RetryOptions & { count: number }) => number;
|
shouldRetry?: (error: Error, count: number) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RetryFunction<Return> = (
|
||||||
|
args: {
|
||||||
|
count: number;
|
||||||
|
stop: (error: any) => void;
|
||||||
|
},
|
||||||
|
) => Promise<Return>;
|
||||||
|
|
||||||
|
const RetryEarlyExit = Symbol('RetryEarlyExit');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name retry
|
* @name retry
|
||||||
* @category Async
|
* @category Async
|
||||||
@@ -25,14 +38,51 @@ export interface RetryOptions {
|
|||||||
* .then(response => response.json())
|
* .then(response => response.json())
|
||||||
* }, { times: 3, delay: 1000 });
|
* }, { times: 3, delay: 1000 });
|
||||||
*
|
*
|
||||||
|
* @since 0.0.8
|
||||||
*/
|
*/
|
||||||
export async function retry<Return>(
|
export async function retry<Return>(
|
||||||
fn: () => Promise<Return>,
|
fn: RetryFunction<Return>,
|
||||||
options: RetryOptions
|
options: RetryOptions = {},
|
||||||
) {
|
): Promise<Return> {
|
||||||
const {
|
const {
|
||||||
times = 3,
|
times = 2,
|
||||||
|
delay = 0,
|
||||||
|
shouldRetry,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let count = 0;
|
let count = 1;
|
||||||
|
let lastError: Error = new Error('Retry failed');
|
||||||
|
|
||||||
|
while (count <= times) {
|
||||||
|
const metadata = {
|
||||||
|
count,
|
||||||
|
stop: (error?: any) => {
|
||||||
|
throw { [RetryEarlyExit]: error };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error, data } = await tryIt(fn)(metadata);
|
||||||
|
|
||||||
|
if (!error)
|
||||||
|
return data;
|
||||||
|
|
||||||
|
if (RetryEarlyExit in error)
|
||||||
|
throw error[RetryEarlyExit];
|
||||||
|
|
||||||
|
if (shouldRetry && !shouldRetry(error, count))
|
||||||
|
throw error;
|
||||||
|
|
||||||
|
lastError = error;
|
||||||
|
count++;
|
||||||
|
|
||||||
|
// Don't delay after the last attempt
|
||||||
|
if (count <= times) {
|
||||||
|
const delayMs = isFunction(delay) ? delay(count) : delay;
|
||||||
|
|
||||||
|
if (delayMs > 0)
|
||||||
|
await sleep(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,62 +6,62 @@ describe('tryIt', () => {
|
|||||||
const syncFn = (x: number) => x * 2;
|
const syncFn = (x: number) => x * 2;
|
||||||
const wrappedSyncFn = tryIt(syncFn);
|
const wrappedSyncFn = tryIt(syncFn);
|
||||||
|
|
||||||
const [error, result] = wrappedSyncFn(2);
|
const { error, data } = wrappedSyncFn(2);
|
||||||
|
|
||||||
expect(error).toBeUndefined();
|
expect(error).toBeUndefined();
|
||||||
expect(result).toBe(4);
|
expect(data).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle synchronous functions with errors', () => {
|
it('handle synchronous functions with errors', () => {
|
||||||
const syncFn = (): void => { throw new Error('Test error') };
|
const syncFn = (): void => { throw new Error('Test error') };
|
||||||
const wrappedSyncFn = tryIt(syncFn);
|
const wrappedSyncFn = tryIt(syncFn);
|
||||||
|
|
||||||
const [error, result] = wrappedSyncFn();
|
const { error, data } = wrappedSyncFn();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error?.message).toBe('Test error');
|
expect(error?.message).toBe('Test error');
|
||||||
expect(result).toBeUndefined();
|
expect(data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle asynchronous functions without errors', async () => {
|
it('handle asynchronous functions without errors', async () => {
|
||||||
const asyncFn = async (x: number) => x * 2;
|
const asyncFn = async (x: number) => x * 2;
|
||||||
const wrappedAsyncFn = tryIt(asyncFn);
|
const wrappedAsyncFn = tryIt(asyncFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedAsyncFn(2);
|
const { error, data } = await wrappedAsyncFn(2);
|
||||||
|
|
||||||
expect(error).toBeUndefined();
|
expect(error).toBeUndefined();
|
||||||
expect(result).toBe(4);
|
expect(data).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle asynchronous functions with errors', async () => {
|
it('handle asynchronous functions with errors', async () => {
|
||||||
const asyncFn = async () => { throw new Error('Test error') };
|
const asyncFn = async () => { throw new Error('Test error') };
|
||||||
const wrappedAsyncFn = tryIt(asyncFn);
|
const wrappedAsyncFn = tryIt(asyncFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedAsyncFn();
|
const { error, data } = await wrappedAsyncFn();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error?.message).toBe('Test error');
|
expect(error?.message).toBe('Test error');
|
||||||
expect(result).toBeUndefined();
|
expect(data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle promise-based functions without errors', async () => {
|
it('handle promise-based functions without errors', async () => {
|
||||||
const promiseFn = (x: number) => Promise.resolve(x * 2);
|
const promiseFn = (x: number) => Promise.resolve(x * 2);
|
||||||
const wrappedPromiseFn = tryIt(promiseFn);
|
const wrappedPromiseFn = tryIt(promiseFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedPromiseFn(2);
|
const { error, data } = await wrappedPromiseFn(2);
|
||||||
|
|
||||||
expect(error).toBeUndefined();
|
expect(error).toBeUndefined();
|
||||||
expect(result).toBe(4);
|
expect(data).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handle promise-based functions with errors', async () => {
|
it('handle promise-based functions with errors', async () => {
|
||||||
const promiseFn = () => Promise.reject(new Error('Test error'));
|
const promiseFn = () => Promise.reject(new Error('Test error'));
|
||||||
const wrappedPromiseFn = tryIt(promiseFn);
|
const wrappedPromiseFn = tryIt(promiseFn);
|
||||||
|
|
||||||
const [error, result] = await wrappedPromiseFn();
|
const { error, data } = await wrappedPromiseFn();
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect(error?.message).toBe('Test error');
|
expect(error?.message).toBe('Test error');
|
||||||
expect(result).toBeUndefined();
|
expect(data).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { isPromise } from '../../types';
|
import { isPromise } from '../../types';
|
||||||
|
|
||||||
export type TryItReturn<Return> = Return extends Promise<any>
|
export type TryItReturn<Return> = Return extends Promise<any>
|
||||||
? Promise<[Error, undefined] | [undefined, Awaited<Return>]>
|
? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }>
|
||||||
: [Error, undefined] | [undefined, Return];
|
: { error: Error; data: undefined } | { error: undefined; data: Return };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name tryIt
|
* @name tryIt
|
||||||
@@ -14,10 +14,10 @@ export type TryItReturn<Return> = Return extends Promise<any>
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const wrappedFetch = tryIt(fetch);
|
* const wrappedFetch = tryIt(fetch);
|
||||||
* const [error, result] = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
|
* const { error, data } = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const [error, result] = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
|
* const { error, data } = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
|
||||||
*
|
*
|
||||||
* @since 0.0.3
|
* @since 0.0.3
|
||||||
*/
|
*/
|
||||||
@@ -30,12 +30,12 @@ export function tryIt<Args extends any[], Return>(
|
|||||||
|
|
||||||
if (isPromise(result))
|
if (isPromise(result))
|
||||||
return result
|
return result
|
||||||
.then((value) => [undefined, value])
|
.then((value) => ({ error: undefined, data: value }))
|
||||||
.catch((error) => [error, undefined]) as TryItReturn<Return>;
|
.catch((error) => ({ error, data: undefined })) as TryItReturn<Return>;
|
||||||
|
|
||||||
return [undefined, result] as TryItReturn<Return>;
|
return { error: undefined, data: result } as TryItReturn<Return>;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [error, undefined] as TryItReturn<Return>;
|
return { error, data: undefined } as TryItReturn<Return>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user