-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
260 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export * from './deferred' | ||
export * from './retry' | ||
export * from './sleep' | ||
export * from './timeout' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import { sleep } from './sleep' | ||
|
||
export interface IRetryOptions { | ||
/** | ||
* Number of times to retry action if it rejects. | ||
* Pass -1 for infinite retries. | ||
* | ||
* @default 3 | ||
*/ | ||
retries?: number | ||
|
||
/** | ||
* Delay in ms between trials. | ||
* | ||
* @default 0 (no delay) | ||
*/ | ||
delay?: number | ||
|
||
/** | ||
* Timeout in ms to stop trying. | ||
* | ||
* @default 0 (no timeout) | ||
*/ | ||
timeout?: number | ||
} | ||
|
||
const defaultOptions: Required<IRetryOptions> = { | ||
retries: 3, | ||
delay: 0, | ||
timeout: 0 | ||
} | ||
|
||
/** | ||
* Executes provided `action` and returns its value. | ||
* If `action` throws or rejects, it will retry execution | ||
* several times before failing. | ||
* | ||
* @param action sync or async callback | ||
* @param options customize behavior | ||
*/ | ||
export async function retry<T>( | ||
action: () => T | Promise<T>, | ||
options?: IRetryOptions, | ||
): Promise<T> { | ||
const { retries, delay, timeout } = { ...defaultOptions, ...options } | ||
|
||
let timedOut = false | ||
let timeoutId: number | undefined // so we can cancel the timeout rejection | ||
|
||
const timeoutPromise = new Promise<T>((_res, rej) => { | ||
if (timeout > 0) { | ||
timeoutId = setTimeout(() => { | ||
timedOut = true | ||
rej(new Error(`timed out after ${timeout}ms`)) | ||
}) | ||
} | ||
}) | ||
|
||
const maxAttempts = retries === -1 ? Infinity : retries + 1 | ||
let attemptCount = 0 | ||
let lastError: Error | ||
|
||
do { | ||
attemptCount++ | ||
try { | ||
const result = await Promise.race([action(), timeoutPromise]) | ||
clearTimeout(timeoutId) | ||
return result | ||
} catch (e) { | ||
lastError = e | ||
await Promise.race([sleep(delay), timeoutPromise]) | ||
} | ||
} while (!timedOut && (attemptCount < maxAttempts)) | ||
|
||
clearTimeout(timeoutId) | ||
throw (lastError || new Error(`failed after ${attemptCount} tries`)) | ||
} | ||
|
||
export function waitFor<T>( | ||
action: () => T | Promise<T>, | ||
options?: IRetryOptions | ||
): Promise<T> { | ||
return retry(action, { delay: 10, timeout: 500, retries: -1, ...options }) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
import chai, { expect } from 'chai' | ||
import chaiAsPromised from 'chai-as-promised' | ||
import { retry, sleep } from '../src' | ||
import { stub } from './stub' | ||
|
||
chai.use(chaiAsPromised) | ||
const NO_ADDITIONAL_CALLS_GRACE = 200 | ||
|
||
describe('retry', () => { | ||
it('resolves value returned by a resolved action', async () => { | ||
const alwaysResolve = stub(() => Promise.resolve('OK')) | ||
|
||
await expect(retry(alwaysResolve)).to.eventually.become('OK') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(alwaysResolve.calls.length).to.equal(1) | ||
}) | ||
|
||
it('rejects if action is always rejecting', async () => { | ||
const alwaysReject = stub(() => Promise.reject('FAIL')) | ||
|
||
await expect(retry(alwaysReject)).to.eventually.be.rejectedWith('FAIL') | ||
}) | ||
|
||
it('retries 3 times by default', async () => { | ||
const resolveOnFour = stub( | ||
callNum => callNum >= 4 ? Promise.resolve('OK') : Promise.reject('FAIL') | ||
) | ||
|
||
const resolveOnFive = stub( | ||
callNum => callNum >= 5 ? Promise.resolve('OK') : Promise.reject('FAIL') | ||
) | ||
|
||
await expect(retry(resolveOnFour)).to.eventually.become('OK') | ||
await expect(retry(resolveOnFive)).to.eventually.be.rejectedWith('FAIL') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(resolveOnFour.calls.length).to.equal(4) // first try and then 3 additional re-tries | ||
expect(resolveOnFive.calls.length).to.equal(4) | ||
}) | ||
|
||
it('allows specifying number of retries', async () => { | ||
const resolveOnFour = stub( | ||
callNum => callNum >= 4 ? Promise.resolve('OK') : Promise.reject('FAIL') | ||
) | ||
|
||
await expect(retry(resolveOnFour, { retries: 2 })).to.eventually.be.rejectedWith('FAIL') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(resolveOnFour.calls.length).to.equal(3) // first try and then 2 additional re-tries | ||
}) | ||
|
||
it('retries infinite number of times when passed -1 ', async () => { | ||
const resolveOnThousand = stub( | ||
callNum => callNum >= 100 ? Promise.resolve('OK') : Promise.reject('FAIL') | ||
) | ||
|
||
await expect(retry(resolveOnThousand, { retries: -1 })).to.eventually.become('OK') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(resolveOnThousand.calls.length).to.equal(100) | ||
}) | ||
|
||
it('rejects with custom message if no/empty error message', async () => { | ||
const alwaysReject = stub(() => Promise.reject()) | ||
|
||
await expect(retry(alwaysReject)).to.eventually.be.rejectedWith('failed after 4 tries') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(alwaysReject.calls.length).to.equal(4) // first try and then 3 additional re-tries | ||
}) | ||
|
||
it('allows delaying re-tries', async () => { | ||
const resolveOnThree = stub(callNum => callNum >= 3 ? Promise.resolve('OK') : Promise.reject()) | ||
const delay = 100 | ||
|
||
await expect(retry(resolveOnThree, { delay })).to.eventually.become('OK') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(resolveOnThree.calls.length).to.equal(3) // first try and then 2 additional re-tries | ||
|
||
const [firstCall, secondCall, thirdCall] = resolveOnThree.calls | ||
expect(secondCall.calledAt - firstCall.calledAt).to.be.gte(delay) | ||
expect(thirdCall.calledAt - secondCall.calledAt).to.be.gte(delay) | ||
}) | ||
|
||
it('allows setting a timeout', async () => { | ||
const neverFulfill = stub(() => new Promise(() => { /* never fulfills */ })) | ||
const timeout = 100 | ||
|
||
await expect(retry(neverFulfill, { timeout })).to.eventually.be.rejectedWith('timed out after 100ms') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(neverFulfill.calls.length).to.equal(1) // timeout while first try | ||
}) | ||
|
||
it('allows setting a timeout with a delay', async () => { | ||
const alwaysReject = stub(() => Promise.reject()) | ||
const delay = 200 | ||
const timeout = 100 | ||
|
||
await expect(retry(alwaysReject, { delay, timeout })).to.eventually.be.rejectedWith('timed out after 100ms') | ||
await sleep(delay * 2) | ||
expect(alwaysReject.calls.length).to.equal(1) // first try and then timeout while delay | ||
}) | ||
|
||
it('resolves values of sync actions', async () => { | ||
const syncReturn = stub(() => 'OK') | ||
|
||
await expect(retry(syncReturn)).to.eventually.become('OK') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(syncReturn.calls.length).to.equal(1) // first try | ||
}) | ||
|
||
it('handles sync exceptions from action', async () => { | ||
const syncException = stub(() => { throw new Error('FAIL') }) | ||
|
||
await expect(retry(syncException)).to.eventually.be.rejectedWith('FAIL') | ||
await sleep(NO_ADDITIONAL_CALLS_GRACE) | ||
expect(syncException.calls.length).to.equal(4) // first try and then three more re-tries | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
/** | ||
* Test helper. | ||
* Returns a new function that proxies return value of `callback`, | ||
* while recording calls and time of their execution. | ||
* It provides `callback` with the call number to help mock different | ||
* return values for different calls. | ||
*/ | ||
export function stub<T>( | ||
callback: (callNumber: number) => T | Promise<T> | ||
): IStub<T> { | ||
|
||
function actualStub() { | ||
actualStub.calls.push({ calledAt: Date.now() }) | ||
return callback(actualStub.calls.length) | ||
} | ||
|
||
actualStub.calls = [] as IStubCall[] | ||
|
||
return actualStub | ||
} | ||
|
||
export interface IStub<T> { | ||
(): T | Promise<T> | ||
calls: IStubCall[] | ||
} | ||
|
||
export interface IStubCall { | ||
calledAt: number | ||
} |