Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add total timeout configuration option #13

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
});
```
39 changes: 35 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type BeforeAttempt<T> = (context: AttemptContext, options: AttemptOptions
export type CalculateDelay<T> = (context: AttemptContext, options: AttemptOptions<T>) => number;
export type HandleError<T> = (err: any, context: AttemptContext, options: AttemptOptions<T>) => void;
export type HandleTimeout<T> = (context: AttemptContext, options: AttemptOptions<T>) => Promise<T>;
export type HandleTotalTimeout<T> = (options: AttemptOptions<T>) => Promise<T>;

export interface AttemptOptions<T> {
readonly delay: number;
Expand All @@ -19,9 +20,11 @@ export interface AttemptOptions<T> {
readonly factor: number;
readonly maxAttempts: number;
readonly timeout: number;
readonly totalTimeout: number;
readonly jitter: boolean;
readonly handleError: HandleError<T> | null;
readonly handleTimeout: HandleTimeout<T> | null;
readonly handleTotalTimeout: HandleTotalTimeout<T> | null;
readonly beforeAttempt: BeforeAttempt<T> | null;
readonly calculateDelay: CalculateDelay<T> | null;
}
Expand All @@ -43,9 +46,11 @@ function applyDefaults<T> (options?: PartialAttemptOptions<T>): AttemptOptions<T
factor: (options.factor === undefined) ? 0 : options.factor,
maxAttempts: (options.maxAttempts === undefined) ? 3 : options.maxAttempts,
timeout: (options.timeout === undefined) ? 0 : options.timeout,
totalTimeout: (options.totalTimeout === undefined) ? 0 : options.totalTimeout,
jitter: (options.jitter === true),
handleError: (options.handleError === undefined) ? null : options.handleError,
handleTimeout: (options.handleTimeout === undefined) ? null : options.handleTimeout,
handleTotalTimeout: (options.handleTotalTimeout === undefined) ? null : options.handleTotalTimeout,
beforeAttempt: (options.beforeAttempt === undefined) ? null : options.beforeAttempt,
calculateDelay: (options.calculateDelay === undefined) ? null : options.calculateDelay
};
Expand Down Expand Up @@ -88,7 +93,7 @@ export function defaultCalculateDelay<T> (context: AttemptContext, options: Atte

export async function retry<T> (
attemptFunc: AttemptFunction<T>,
attemptOptions?: PartialAttemptOptions<T>): Promise<T> {
attemptOptions?: PartialAttemptOptions<T>): Promise<any> {

const options = applyDefaults(attemptOptions);

Expand All @@ -98,7 +103,8 @@ export async function retry<T> (
'minDelay',
'maxDelay',
'maxAttempts',
'timeout'
'timeout',
'totalTimeout'
]) {
const value: any = (options as any)[prop];

Expand Down Expand Up @@ -161,7 +167,7 @@ export async function retry<T> (
context.attemptsRemaining--;
}

if (options.timeout) {
if (options.timeout > 0) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (options.handleTimeout) {
Expand Down Expand Up @@ -196,5 +202,30 @@ export async function retry<T> (
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();
}
}
72 changes: 72 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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
});
Expand Down Expand Up @@ -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<string>(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) => {
Expand Down