diff --git a/README.md b/README.md index 608bcc3..8d6a299 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,11 @@ The following object shows the default options: maxDelay: 0, factor: 0, timeout: 0, + totalTimeout: 0, jitter: false, handleError: null, handleTimeout: null, + handleTotalTimeout: null, beforeAttempt: null, calculateDelay: null } @@ -143,6 +145,17 @@ to your target environment. (default: `0`) +- **`totalTimeout`**: `Number` + + A total timeout for all attempts in milliseconds. If `totalTimeout` is + non-zero then a timer is set using `setTimeout`. If the timeout is + triggered then future attempts will be aborted. + + The `handleTotalTimeout` function can be used to implement fallback + functionality. + + (default: `0`) + - **`jitter`**: `Boolean` If `jitter` is `true` then the calculated delay will @@ -175,6 +188,12 @@ to your target environment. `timeout`. The `handleTimeout` function should return a `Promise` that will be the return value of the `retry()` function. +- **`handleTotalTimeout`**: `(options) => Promise | void` + + `handleTotalTimeout` is invoked if a timeout occurs when using a non-zero + `totalTimeout`. The `handleTotalTimeout` function should return a `Promise` + that will be the return value of the `retry()` function. + - **`beforeAttempt`**: `(context, options) => void` The `beforeAttempt` function is invoked before each attempt. @@ -339,3 +358,43 @@ const result = await retry(async function() { } }); ``` + +### Stop retrying if there is a total timeout + +```js +// Try the given operation up to 5 times. The initial delay will be 0 +// and subsequent delays will be 200, 400, 800, 1600. +// +// If the given async function fails to complete after 1 second then the +// retries are aborted and error with `code` `TOTAL_TIMEOUT` is thrown. +const result = await retry(async function() { + // do something that returns a promise +}, { + delay: 200, + factor: 2, + maxAttempts: 5, + totalTimeout: 1000 +}); +``` + +### Stop retrying if there is a total timeout but provide a fallback + +```js +// Try the given operation up to 5 times. The initial delay will be 0 +// and subsequent delays will be 200, 400, 800, 1600. +// +// If the given async function fails to complete after 1 second then the +// retries are aborted and the `handleTotalTimeout` implements some fallback +// logic. +const result = await retry(async function() { + // do something that returns a promise +}, { + delay: 200, + factor: 2, + maxAttempts: 5, + totalTimeout: 1000, + async handleTotalTimeout (options) { + // do something that returns a promise or throw your own error + } +}); +``` diff --git a/src/index.ts b/src/index.ts index 42fcd27..d972c94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export type BeforeAttempt = (context: AttemptContext, options: AttemptOptions export type CalculateDelay = (context: AttemptContext, options: AttemptOptions) => number; export type HandleError = (err: any, context: AttemptContext, options: AttemptOptions) => void; export type HandleTimeout = (context: AttemptContext, options: AttemptOptions) => Promise; +export type HandleTotalTimeout = (options: AttemptOptions) => Promise; export interface AttemptOptions { readonly delay: number; @@ -19,9 +20,11 @@ export interface AttemptOptions { readonly factor: number; readonly maxAttempts: number; readonly timeout: number; + readonly totalTimeout: number; readonly jitter: boolean; readonly handleError: HandleError | null; readonly handleTimeout: HandleTimeout | null; + readonly handleTotalTimeout: HandleTotalTimeout | null; readonly beforeAttempt: BeforeAttempt | null; readonly calculateDelay: CalculateDelay | null; } @@ -43,9 +46,11 @@ function applyDefaults (options?: PartialAttemptOptions): AttemptOptions (context: AttemptContext, options: Atte export async function retry ( attemptFunc: AttemptFunction, - attemptOptions?: PartialAttemptOptions): Promise { + attemptOptions?: PartialAttemptOptions): Promise { const options = applyDefaults(attemptOptions); @@ -98,7 +103,8 @@ export async function retry ( 'minDelay', 'maxDelay', 'maxAttempts', - 'timeout' + 'timeout', + 'totalTimeout' ]) { const value: any = (options as any)[prop]; @@ -161,7 +167,7 @@ export async function retry ( context.attemptsRemaining--; } - if (options.timeout) { + if (options.timeout > 0) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (options.handleTimeout) { @@ -196,5 +202,30 @@ export async function retry ( await sleep(initialDelay); } - return makeAttempt(); + if (options.totalTimeout > 0) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + context.abort(); + if (options.handleTotalTimeout) { + resolve(options.handleTotalTimeout(options)); + } else { + const err: any = new Error(`Total timeout (totalTimeout: ${options.totalTimeout})`); + err.code = 'TOTAL_TIMEOUT'; + reject(err); + } + }, options.totalTimeout); + + makeAttempt().then((result: T) => { + clearTimeout(timer); + resolve(result); + }).catch((err: any) => { + clearTimeout(timer); + reject(err); + }); + }); + } else { + // No totalTimeout provided so wait indefinitely for the returned promise + // to be resolved. + return makeAttempt(); + } } diff --git a/test/index.test.ts b/test/index.test.ts index 51344fd..ce24e59 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -19,9 +19,11 @@ test('should be able to calculate delays', (t) => { factor: 0, maxAttempts: 0, timeout: 0, + totalTimeout: 0, jitter: false, handleError: null, handleTimeout: null, + handleTotalTimeout: null, beforeAttempt: null, calculateDelay: null }; @@ -88,9 +90,11 @@ test('should default to 3 attempts with 200 delay', async (t) => { factor: 0, maxAttempts: 3, timeout: 0, + totalTimeout: 0, jitter: false, handleError: null, handleTimeout: null, + handleTotalTimeout: null, beforeAttempt: null, calculateDelay: null }); @@ -220,6 +224,74 @@ test('should support timeout for multiple attempts', async (t) => { t.is(err.code, 'ATTEMPT_TIMEOUT'); }); +test('should support totalTimeout on first attempt', async (t) => { + const err = await t.throws(retry(async () => { + await sleep(500); + }, { + delay: 0, + totalTimeout: 50, + maxAttempts: 3 + })); + + t.is(err.code, 'TOTAL_TIMEOUT'); +}); + +test('should support totalTimeout and handleTotalTimeout', async (t) => { + async function fallback () { + await sleep(100); + return 'used fallback'; + } + + const result = await retry(async () => { + await sleep(500); + return 'did not use fallback'; + }, { + delay: 0, + totalTimeout: 50, + maxAttempts: 2, + handleTotalTimeout: fallback + }); + + t.is(result, 'used fallback'); +}); + +test('should allow handleTotalTimeout to throw an error', async (t) => { + const err = await t.throws(retry(async () => { + await sleep(500); + }, { + delay: 0, + totalTimeout: 50, + maxAttempts: 2, + handleTotalTimeout: async (context) => { + throw new Error('timeout occurred'); + } + })); + + t.is(err.message, 'timeout occurred'); +}); + +test('should support totalTimeout that happens between attempts', async (t) => { + let attemptCount = 0; + const err = await t.throws(retry(async (context) => { + attemptCount++; + + if (context.attemptNum > 2) { + return 'did not timeout'; + } else { + await sleep(20); + throw new Error('fake error'); + } + }, { + delay: 0, + totalTimeout: 50, + maxAttempts: 5 + })); + + // third attempt should timeout + t.is(attemptCount, 3); + t.is(err.code, 'TOTAL_TIMEOUT'); +}); + test('should support retries', async (t) => { const resultMessage = 'hello'; const result = await retry(async (context) => {