Skip to content

Commit 66863e8

Browse files
committed
Add total timeout configuration option
1 parent a4971c1 commit 66863e8

File tree

3 files changed

+166
-4
lines changed

3 files changed

+166
-4
lines changed

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ The following object shows the default options:
7272
maxDelay: 0,
7373
factor: 0,
7474
timeout: 0,
75+
totalTimeout: 0,
7576
jitter: false,
7677
handleError: null,
7778
handleTimeout: null,
79+
handleTotalTimeout: null,
7880
beforeAttempt: null,
7981
calculateDelay: null
8082
}
@@ -143,6 +145,17 @@ to your target environment.
143145

144146
(default: `0`)
145147

148+
- **`totalTimeout`**: `Number`
149+
150+
A total timeout for all attempts in milliseconds. If `totalTimeout` is
151+
non-zero then a timer is set using `setTimeout`. If the timeout is
152+
triggered then future attempts will be aborted.
153+
154+
The `handleTotalTimeout` function can be used to implement fallback
155+
functionality.
156+
157+
(default: `0`)
158+
146159
- **`jitter`**: `Boolean`
147160

148161
If `jitter` is `true` then the calculated delay will
@@ -175,6 +188,12 @@ to your target environment.
175188
`timeout`. The `handleTimeout` function should return a `Promise`
176189
that will be the return value of the `retry()` function.
177190

191+
- **`handleTotalTimeout`**: `(options) => Promise | void`
192+
193+
`handleTotalTimeout` is invoked if a timeout occurs when using a non-zero
194+
`totalTimeout`. The `handleTotalTimeout` function should return a `Promise`
195+
that will be the return value of the `retry()` function.
196+
178197
- **`beforeAttempt`**: `(context, options) => void`
179198

180199
The `beforeAttempt` function is invoked before each attempt.
@@ -339,3 +358,43 @@ const result = await retry(async function() {
339358
}
340359
});
341360
```
361+
362+
### Stop retrying if there is a total timeout
363+
364+
```js
365+
// Try the given operation up to 5 times. The initial delay will be 0
366+
// and subsequent delays will be 200, 400, 800, 1600.
367+
//
368+
// If the given async function fails to complete after 1 second then the
369+
// retries are aborted and error with `code` `TOTAL_TIMEOUT` is thrown.
370+
const result = await retry(async function() {
371+
// do something that returns a promise
372+
}, {
373+
delay: 200,
374+
factor: 2,
375+
maxAttempts: 5,
376+
totalTimeout: 1000
377+
});
378+
```
379+
380+
### Stop retrying if there is a total timeout but provide a fallback
381+
382+
```js
383+
// Try the given operation up to 5 times. The initial delay will be 0
384+
// and subsequent delays will be 200, 400, 800, 1600.
385+
//
386+
// If the given async function fails to complete after 1 second then the
387+
// retries are aborted and the `handleTotalTimeout` implements some fallback
388+
// logic.
389+
const result = await retry(async function() {
390+
// do something that returns a promise
391+
}, {
392+
delay: 200,
393+
factor: 2,
394+
maxAttempts: 5,
395+
totalTimeout: 1000,
396+
async handleTotalTimeout (options) {
397+
// do something that returns a promise or throw your own error
398+
}
399+
});
400+
```

src/index.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type BeforeAttempt<T> = (context: AttemptContext, options: AttemptOptions
1010
export type CalculateDelay<T> = (context: AttemptContext, options: AttemptOptions<T>) => number;
1111
export type HandleError<T> = (err: any, context: AttemptContext, options: AttemptOptions<T>) => void;
1212
export type HandleTimeout<T> = (context: AttemptContext, options: AttemptOptions<T>) => Promise<T>;
13+
export type HandleTotalTimeout<T> = (options: AttemptOptions<T>) => Promise<T>;
1314

1415
export interface AttemptOptions<T> {
1516
readonly delay: number;
@@ -19,9 +20,11 @@ export interface AttemptOptions<T> {
1920
readonly factor: number;
2021
readonly maxAttempts: number;
2122
readonly timeout: number;
23+
readonly totalTimeout: number;
2224
readonly jitter: boolean;
2325
readonly handleError: HandleError<T> | null;
2426
readonly handleTimeout: HandleTimeout<T> | null;
27+
readonly handleTotalTimeout: HandleTotalTimeout<T> | null;
2528
readonly beforeAttempt: BeforeAttempt<T> | null;
2629
readonly calculateDelay: CalculateDelay<T> | null;
2730
}
@@ -43,9 +46,11 @@ function applyDefaults<T> (options?: PartialAttemptOptions<T>): AttemptOptions<T
4346
factor: (options.factor === undefined) ? 0 : options.factor,
4447
maxAttempts: (options.maxAttempts === undefined) ? 3 : options.maxAttempts,
4548
timeout: (options.timeout === undefined) ? 0 : options.timeout,
49+
totalTimeout: (options.totalTimeout === undefined) ? 0 : options.totalTimeout,
4650
jitter: (options.jitter === true),
4751
handleError: (options.handleError === undefined) ? null : options.handleError,
4852
handleTimeout: (options.handleTimeout === undefined) ? null : options.handleTimeout,
53+
handleTotalTimeout: (options.handleTotalTimeout === undefined) ? null : options.handleTotalTimeout,
4954
beforeAttempt: (options.beforeAttempt === undefined) ? null : options.beforeAttempt,
5055
calculateDelay: (options.calculateDelay === undefined) ? null : options.calculateDelay
5156
};
@@ -88,7 +93,7 @@ export function defaultCalculateDelay<T> (context: AttemptContext, options: Atte
8893

8994
export async function retry<T> (
9095
attemptFunc: AttemptFunction<T>,
91-
attemptOptions?: PartialAttemptOptions<T>): Promise<T> {
96+
attemptOptions?: PartialAttemptOptions<T>): Promise<any> {
9297

9398
const options = applyDefaults(attemptOptions);
9499

@@ -98,7 +103,8 @@ export async function retry<T> (
98103
'minDelay',
99104
'maxDelay',
100105
'maxAttempts',
101-
'timeout'
106+
'timeout',
107+
'totalTimeout'
102108
]) {
103109
const value: any = (options as any)[prop];
104110

@@ -161,7 +167,7 @@ export async function retry<T> (
161167
context.attemptsRemaining--;
162168
}
163169

164-
if (options.timeout) {
170+
if (options.timeout > 0) {
165171
return new Promise((resolve, reject) => {
166172
const timer = setTimeout(() => {
167173
if (options.handleTimeout) {
@@ -196,5 +202,30 @@ export async function retry<T> (
196202
await sleep(initialDelay);
197203
}
198204

199-
return makeAttempt();
205+
if (options.totalTimeout > 0) {
206+
return new Promise((resolve, reject) => {
207+
const timer = setTimeout(() => {
208+
context.abort();
209+
if (options.handleTotalTimeout) {
210+
resolve(options.handleTotalTimeout(options));
211+
} else {
212+
const err: any = new Error(`Total timeout (totalTimeout: ${options.totalTimeout})`);
213+
err.code = 'TOTAL_TIMEOUT';
214+
reject(err);
215+
}
216+
}, options.totalTimeout);
217+
218+
makeAttempt().then((result: T) => {
219+
clearTimeout(timer);
220+
resolve(result);
221+
}).catch((err: any) => {
222+
clearTimeout(timer);
223+
reject(err);
224+
});
225+
});
226+
} else {
227+
// No totalTimeout provided so wait indefinitely for the returned promise
228+
// to be resolved.
229+
return makeAttempt();
230+
}
200231
}

test/index.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ test('should be able to calculate delays', (t) => {
1919
factor: 0,
2020
maxAttempts: 0,
2121
timeout: 0,
22+
totalTimeout: 0,
2223
jitter: false,
2324
handleError: null,
2425
handleTimeout: null,
26+
handleTotalTimeout: null,
2527
beforeAttempt: null,
2628
calculateDelay: null
2729
};
@@ -88,9 +90,11 @@ test('should default to 3 attempts with 200 delay', async (t) => {
8890
factor: 0,
8991
maxAttempts: 3,
9092
timeout: 0,
93+
totalTimeout: 0,
9194
jitter: false,
9295
handleError: null,
9396
handleTimeout: null,
97+
handleTotalTimeout: null,
9498
beforeAttempt: null,
9599
calculateDelay: null
96100
});
@@ -220,6 +224,74 @@ test('should support timeout for multiple attempts', async (t) => {
220224
t.is(err.code, 'ATTEMPT_TIMEOUT');
221225
});
222226

227+
test('should support totalTimeout on first attempt', async (t) => {
228+
const err = await t.throws(retry(async () => {
229+
await sleep(500);
230+
}, {
231+
delay: 0,
232+
totalTimeout: 50,
233+
maxAttempts: 3
234+
}));
235+
236+
t.is(err.code, 'TOTAL_TIMEOUT');
237+
});
238+
239+
test('should support totalTimeout and handleTotalTimeout', async (t) => {
240+
async function fallback () {
241+
await sleep(100);
242+
return 'used fallback';
243+
}
244+
245+
const result = await retry<string>(async () => {
246+
await sleep(500);
247+
return 'did not use fallback';
248+
}, {
249+
delay: 0,
250+
totalTimeout: 50,
251+
maxAttempts: 2,
252+
handleTotalTimeout: fallback
253+
});
254+
255+
t.is(result, 'used fallback');
256+
});
257+
258+
test('should allow handleTotalTimeout to throw an error', async (t) => {
259+
const err = await t.throws(retry(async () => {
260+
await sleep(500);
261+
}, {
262+
delay: 0,
263+
totalTimeout: 50,
264+
maxAttempts: 2,
265+
handleTotalTimeout: async (context) => {
266+
throw new Error('timeout occurred');
267+
}
268+
}));
269+
270+
t.is(err.message, 'timeout occurred');
271+
});
272+
273+
test('should support totalTimeout that happens between attempts', async (t) => {
274+
let attemptCount = 0;
275+
const err = await t.throws(retry(async (context) => {
276+
attemptCount++;
277+
278+
if (context.attemptNum > 2) {
279+
return 'did not timeout';
280+
} else {
281+
await sleep(20);
282+
throw new Error('fake error');
283+
}
284+
}, {
285+
delay: 0,
286+
totalTimeout: 50,
287+
maxAttempts: 5
288+
}));
289+
290+
// third attempt should timeout
291+
t.is(attemptCount, 3);
292+
t.is(err.code, 'TOTAL_TIMEOUT');
293+
});
294+
223295
test('should support retries', async (t) => {
224296
const resultMessage = 'hello';
225297
const result = await retry(async (context) => {

0 commit comments

Comments
 (0)