Skip to content

Commit

Permalink
Merge 6bce542 into a4971c1
Browse files Browse the repository at this point in the history
  • Loading branch information
mironov committed May 11, 2019
2 parents a4971c1 + 6bce542 commit 6a6fa3c
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 4 deletions.
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);
resolve(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

0 comments on commit 6a6fa3c

Please sign in to comment.