Skip to content

Commit

Permalink
New RetryError class, add maximumBackoff (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
carlansley authored Apr 8, 2024
1 parent a5e6bef commit 717c0a9
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 50 deletions.
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ This logic matches the
[AWS recommended algorithm](https://docs.aws.amazon.com/general/latest/gr/api-retries.html) and
[AWS exponential backoff and jitter doc](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/).

However, both the default waitRatio (100), retries (8), and jitter (true) can be overridden. For
test scenarios, it is useful to set the waitRatio to `0` to force immediate retries.
However, both the default `waitRatio` (100), `retries` (8),
`jitter` (true) and `maximumBackoff` (+Infinity) can be overridden.
For test scenarios, it is useful to set the `waitRatio` to `0` to force immediate retries.

If the number of allowable retries is exceeded, a RetryError is thrown with `retries` and `lastError` properties.
If the number of allowable retries is exceeded,
a `RetryError` is thrown with the `options` property set to the input options,
and `cause` set to the error thrown on the last retry attempt.

**NOTE: this module assumes all work is idempotent, and can be retried multiple times without consequence. If that is
not the case, do not use this module.**
Expand Down Expand Up @@ -39,8 +42,8 @@ try {
await errorWorker(someTestInput);
} catch (error) {
if (error instanceof RetryError) {
console.log("failed after " + error.retries);
console.log(error.lastError);
console.log("failed after " + error.options.retries);
console.log(error.cause);
}
}
Expand Down
64 changes: 32 additions & 32 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@checkdigit/retry",
"version": "5.0.0",
"version": "6.0.0",
"description": "Implementation of the standard Check Digit retry algorithm",
"type": "module",
"sideEffects": false,
Expand Down
8 changes: 5 additions & 3 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
* This code is licensed under the MIT license (see LICENSE.txt for details).
*/

import type { RetryOptions } from './options';

export class RetryError extends Error {
constructor(
public retries: number,
public lastError: Error,
public options: Required<RetryOptions>,
lastError: unknown,
) {
super(`Maximum retries (${retries}) exceeded`);
super(`Maximum retries (${options.retries}) exceeded`, { cause: lastError });
}
}
1 change: 1 addition & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface RetryOptions {
waitRatio?: number;
retries?: number;
jitter?: boolean;
maximumBackoff?: number;
}
31 changes: 29 additions & 2 deletions src/retry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,13 @@ describe('retry', () => {
thrown = error;
}
assert.ok(thrown instanceof RetryError);
assert.deepEqual(thrown.retries, 8);
assert.deepEqual(thrown.lastError.message, 'Error 9/Infinity');
assert.deepEqual(thrown.options, {
retries: 8,
waitRatio: 0,
jitter: true,
maximumBackoff: Number.POSITIVE_INFINITY,
});
assert.equal((thrown.cause as Error).message, 'Error 9/Infinity');
});

it('number of retries can be selected', async () => {
Expand Down Expand Up @@ -146,4 +151,26 @@ describe('retry', () => {
assert.ok(time >= 2550);
assert.ok(time <= 2600);
});

it('takes expected amount of time retrying with maximumBackoff set, taking into account jitter', async () => {
const start = Date.now();
for (const _ of Array.from({ length: 22 }).keys()) {
assert.equal(await retry(work(nextTick, 8), { waitRatio: 1, maximumBackoff: 23 })('abc'), 'abc');
}
const time = Date.now() - start;

// precise timing is impossible due to jitter, but this should take around 1.75 seconds on average (+- 20%)
assert.ok(time >= 1450, `time ${time} should be >= 1450`);
assert.ok(time <= 2100, `time ${time} should be >= 2100`);
});

it('takes expected amount of time retrying with maximumBackoff set, no jitter', async () => {
const start = Date.now();
assert.equal(await retry(work(nextTick, 8), { waitRatio: 10, maximumBackoff: 10, jitter: false })('abc'), 'abc');
const time = Date.now() - start;

// this should take a little under 90ms, allow 50ms overhead for nextTick etc
assert.ok(time >= 80, `time ${time} should be >= 80`);
assert.ok(time <= 150, `time ${time} should be <= 150`);
});
});
32 changes: 25 additions & 7 deletions src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ const MAXIMUM_WAIT_RATIO = 60_000;
const MINIMUM_RETRIES = 0;
const MAXIMUM_RETRIES = 64;

const MINIMUM_BACKOFF = 0;

const DEFAULT_OPTIONS: Required<RetryOptions> = {
waitRatio: 100,
retries: 8,
jitter: true,
maximumBackoff: Number.POSITIVE_INFINITY,
};

/**
* Implementation of recommended Check Digit retry algorithm. For more details, see AWS documentation for background:
* - https://docs.aws.amazon.com/general/latest/gr/api-retries.html
* - https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
* Note: unlike the basic algorithm outlined by AWS, this implementation does not cap the retry sleep time.
*
* @param retryable
* @param waitRatio how much to multiply 2^attempts by
Expand All @@ -42,6 +44,7 @@ export default function <Input, Output>(
waitRatio = DEFAULT_OPTIONS.waitRatio,
retries = DEFAULT_OPTIONS.retries,
jitter = DEFAULT_OPTIONS.jitter,
maximumBackoff = DEFAULT_OPTIONS.maximumBackoff,
}: RetryOptions = DEFAULT_OPTIONS,
): (item?: Input) => Promise<Output> {
if (waitRatio < MINIMUM_WAIT_RATIO || waitRatio > MAXIMUM_WAIT_RATIO) {
Expand All @@ -50,15 +53,22 @@ export default function <Input, Output>(
if (retries < MINIMUM_RETRIES || retries > MAXIMUM_RETRIES) {
throw new RangeError(`retries must be >= ${MINIMUM_RETRIES} and <= ${MAXIMUM_RETRIES}`);
}
if (maximumBackoff < MINIMUM_BACKOFF) {
throw new RangeError(`maximumBackoff must be >= ${MINIMUM_BACKOFF}`);
}

return (item) =>
(async function work(attempts = 0): Promise<Output> {
if (attempts > 0) {
const waitTime = jitter
? // wait for (2^retries * waitRatio) milliseconds with full jitter (per AWS recommendation)
Math.ceil(Math.random() * (2 ** (attempts - 1) * waitRatio))
: // wait for (2^retries * waitRatio) milliseconds (per AWS recommendation)
2 ** (attempts - 1) * waitRatio;
const waitTime = Math.min(
jitter
? // wait for (2^retries * waitRatio) milliseconds with full jitter
Math.ceil(Math.random() * (2 ** (attempts - 1) * waitRatio))
: // wait for (2^retries * waitRatio) milliseconds
2 ** (attempts - 1) * waitRatio,
// cap the maximum wait time
maximumBackoff,
);
log(`attempt ${attempts}, waiting for ${waitTime}ms, jitter: ${jitter.toString()}`);
await new Promise((resolve) => {
setTimeout(resolve, waitTime);
Expand All @@ -71,7 +81,15 @@ export default function <Input, Output>(
} catch (error: unknown) {
if (attempts >= retries) {
log(`retries (${retries}) exceeded`);
throw new RetryError(retries, error as Error);
throw new RetryError(
{
retries,
waitRatio,
jitter,
maximumBackoff,
},
error,
);
}
log(`attempt ${attempts} (fail in ${Date.now() - startTime}ms)`);
return work(attempts + 1);
Expand Down

0 comments on commit 717c0a9

Please sign in to comment.