Skip to content

Instantly share code, notes, and snippets.

@felixjb
Last active December 8, 2023 13:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save felixjb/16a64bd9a0ff338f651d264bca927549 to your computer and use it in GitHub Desktop.
Save felixjb/16a64bd9a0ff338f651d264bca927549 to your computer and use it in GitHub Desktop.
async retry function that accepts a callback and execution options as parameters
/**
* Waits for a number of milliseconds.
* @param timeInMilliseconds the time to wait in milliseconds
*/
export const wait = async (timeInMilliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, timeInMilliseconds));
/**
* Retries an async callback function a number of times with a delay between retries.
* @template T - The type of the result of the callback.
* @param callback The async function to retry
* @param options The options for the retry
* @param options.predicate A function to be executed on each result and error to determine if the callback
* should be retried. Returns true if the callback should be retried and false otherwise.
* @param options.maxRetries The maximum number of retries to be executed. Defaults to 5.
* @param options.delayInMilliseconds The starting delay between retries in milliseconds.
* It will be exponentially increased on each retry. Defaults to 100.
* @param options.backoffFactor The factor by which to increase the delay on each retry.
* If speecified, it must be greater than 1 to apply the exponential backoff. Defaults to 2.
* @returns a promise that resolves to the result of the callback. T is the type of the result of the callback.
*/
export const retry = async <T>(
callback: () => Promise<T>,
{
predicate,
maxRetries = 5,
delayInMilliseconds = 100,
backoffFactor = 2,
}: {
predicate?: ({ result, error }: { result?: T; error?: unknown }) => boolean;
maxRetries?: number;
delayInMilliseconds?: number;
backoffFactor?: number;
} = {}
): Promise<T> => {
const execute = async (retries = 0): Promise<T> => {
try {
const result = await callback();
const shouldStop = retries > maxRetries || !predicate || !predicate({ result });
if (shouldStop) {
return result;
}
} catch (error) {
const shouldStop = retries > maxRetries || (!!predicate && !predicate({ error }));
if (shouldStop) {
return Promise.reject(error);
}
}
const exponentialBackoff = delayInMilliseconds * backoffFactor ** retries;
await wait(exponentialBackoff);
return execute(retries + 1);
};
return execute();
};
// Unit Tests
describe('retry module', () => {
describe('wait function', () => {
it('should wait for the specified time', async () => {
const oneSecondInMilliseconds = 1000;
const start = Date.now();
await wait(oneSecondInMilliseconds);
const end = Date.now();
const elapsed = end - start;
expect(elapsed).toBeGreaterThanOrEqual(oneSecondInMilliseconds);
});
});
describe('retry function', () => {
it('should not retry if the callback succeeds on the first attempt', async () => {
const mockCallback = jest.fn(async () => 'Success');
const result = await retry(mockCallback);
expect(result).toBe('Success');
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should retry when the predicate returns true on a result', async () => {
const mockCallback = jest.fn().mockResolvedValueOnce('Almost a success').mockResolvedValueOnce('Success');
const mockPredicate = jest.fn().mockReturnValueOnce(true).mockReturnValueOnce(false);
const result = await retry(mockCallback, {
predicate: mockPredicate,
maxRetries: 5,
delayInMilliseconds: 10,
});
expect(result).toBe('Success');
expect(mockCallback).toHaveBeenCalledTimes(2);
});
it('should not retry when the predicate returns false on a result', async () => {
const mockCallback = jest.fn().mockResolvedValueOnce('Almost a success').mockResolvedValueOnce('Success');
const mockPredicate = jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(false);
const result = await retry(mockCallback, {
predicate: mockPredicate,
maxRetries: 5,
delayInMilliseconds: 10,
});
expect(result).toBe('Almost a success');
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should retry the specified number of times', async () => {
const mockCallback = jest
.fn()
.mockRejectedValueOnce(new Error('Attempt 1'))
.mockRejectedValueOnce(new Error('Attempt 2'))
.mockRejectedValueOnce(new Error('Attempt 3'))
.mockResolvedValue('Success');
const result = await retry(mockCallback, { maxRetries: 3 });
expect(result).toBe('Success');
expect(mockCallback).toHaveBeenCalledTimes(4);
});
it('should stop retrying when the predicate returns false on an error', async () => {
const mockCallback = jest
.fn()
.mockRejectedValueOnce(new Error('Attempt 1'))
.mockRejectedValueOnce(new Error('Attempt 2'))
.mockRejectedValueOnce(new Error('Attempt 3'))
.mockRejectedValueOnce(new Error('Attempt 4'))
.mockRejectedValueOnce(new Error('Attempt 5'));
const mockPredicate = jest.fn().mockReturnValueOnce(true).mockReturnValueOnce(false);
await expect(
retry(mockCallback, {
predicate: mockPredicate,
maxRetries: 5,
delayInMilliseconds: 10,
})
).rejects.toThrow('Attempt 2');
expect(mockCallback).toHaveBeenCalledTimes(2);
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment