diff --git a/libs/async/src/index.ts b/libs/async/src/index.ts index 1ebcdf4..37d65d5 100644 --- a/libs/async/src/index.ts +++ b/libs/async/src/index.ts @@ -1,3 +1,4 @@ import Collector from './Collector.js'; +import { retry } from './retry.js'; -export { Collector }; +export { Collector, retry }; diff --git a/libs/async/src/retry.test.ts b/libs/async/src/retry.test.ts new file mode 100644 index 0000000..6e27d4b --- /dev/null +++ b/libs/async/src/retry.test.ts @@ -0,0 +1,58 @@ +import { retry } from './retry.js'; + +test('retry - 1', async () => { + let numCalled = 0; + const fn = () => + new Promise((resolve, reject) => { + numCalled++; + if (numCalled < 2) { + reject('error'); + return; + } + resolve('success'); + }); + + const start = performance.now(); + await expect(retry(fn)).resolves.toBe('success'); + const end = performance.now(); + expect(end - start).toBeGreaterThan(100); + expect(numCalled).toBe(2); +}); + +test('retry - 2', async () => { + let numCalled = 0; + const fn = () => + new Promise((resolve, reject) => { + numCalled++; + if (numCalled < 4) { + reject('error'); + return; + } + resolve('success'); + }); + + const start = performance.now(); + await expect( + retry(fn, { maxRetries: 5, minDelay: 10, delayFactor: 1 }) + ).resolves.toBe('success'); + const end = performance.now(); + expect(end - start).toBeGreaterThan(30); + expect(numCalled).toBe(4); +}); + +test('retry - 3', async () => { + let numCalled = 0; + const fn = () => + new Promise(() => { + numCalled++; + throw new Error('error'); + }); + + const start = performance.now(); + await expect( + retry(fn, { maxRetries: 5, minDelay: 10, delayFactor: 2 }) + ).rejects.toThrow('error'); + const end = performance.now(); + expect(end - start).toBeGreaterThan(10 + 20 + 40 + 80 + 160); + expect(numCalled).toBe(6); +}); diff --git a/libs/async/src/retry.ts b/libs/async/src/retry.ts new file mode 100644 index 0000000..9305e0d --- /dev/null +++ b/libs/async/src/retry.ts @@ -0,0 +1,48 @@ +export type RetryOptions = { + /** + * Maximum number of retries. Defaults to 3. + */ + maxRetries: number; + /** + * Minimum delay in milliseconds between retries. Defaults to 100 (ms). + */ + minDelay?: number; + /** + * Factor by which to multiply the delay after each retry. Defaults to 2. + */ + delayFactor?: number; +}; + +/** + * Retry a async function up to `maxRetries` times with exponential backoff. + */ +export const retry = ( + /** + * The async function to retry. + */ + fn: () => Promise, + /** + * Options. + */ + options?: RetryOptions +): Promise => { + // retry `promise` up to `maxRetries` times with exponential backoff + const { maxRetries = 3, minDelay = 100, delayFactor = 2 } = options ?? {}; + return new Promise((resolve, reject) => { + let retries = 0; + const tryPromise = () => { + fn() + .then(resolve) + .catch((error) => { + if (retries < maxRetries) { + retries++; + const delay = minDelay * Math.pow(delayFactor, retries); + setTimeout(tryPromise, delay); + } else { + reject(error); + } + }); + }; + tryPromise(); + }); +};