Skip to content

Commit

Permalink
Implement retry()
Browse files Browse the repository at this point in the history
  • Loading branch information
AviVahl committed Oct 15, 2018
1 parent 7ef588a commit ece8a86
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 1 deletion.
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ async function myOperation() {

Creates a deferred Promise, where `resolve`/`reject` are exposed to the place that holds the promise.

Generally bad practice, but there are use-cases where one mixes callback-based API with Promise API and this is helpful.
Generally a bad practice, but there are use-cases, such as mixing callback-based and Promise-based APIs, where this is helpful.
```ts
import { deferred } from 'promise-assist'

Expand All @@ -56,6 +56,36 @@ resolve('some text')
// 'some text' is printed to console
```

### retry

Executes provided `action` (sync or async) and returns its value.
If `action` throws or rejects, it will retry execution several times before failing.

Defaults are:
- 3 retries
- no delay between retries
- no timeout to stop trying

These can be customized via a second optional `options` parameter.

```ts
import { retry } from 'promise-assist'

retry(() => fetch('http://some-url/asset.json'))
.then(value => value.json())
.then(console.log)
.catch(e => console.error(e))

retry(() => fetch('http://some-url/asset.json'), {
retries: -1, // infinite number of retries
delay: 10 * 1000, // 10 seconds delay between retries
timeout: 2 * 60 * 1000 // 2 minutes timeout to stop trying
})
.then(value => value.json())
.then(console.log)
.catch(e => console.error(e))
```

## License

MIT
1 change: 1 addition & 0 deletions src/index.ts
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'
84 changes: 84 additions & 0 deletions src/retry.ts
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 })
}
115 changes: 115 additions & 0 deletions test/retry.spec.ts
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
})
})
29 changes: 29 additions & 0 deletions test/stub.ts
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
}

0 comments on commit ece8a86

Please sign in to comment.