Skip to content

Commit

Permalink
feat: add option to introduce jitter (#26)
Browse files Browse the repository at this point in the history
* add includeJitter option

* allow specifying equal vs full jitter backoff strategy

* Update README
  • Loading branch information
smcroskey authored May 25, 2023
1 parent 29d9c35 commit 6311d3e
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 15 deletions.
55 changes: 44 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,25 @@ Import and use it. Retry for `Promise` is supported as long as the `runtime` has
> npm install typescript-retry-decorator
### Options
| Option Name | Type | Required? | Default | Description |
|:-----------------:|:------:|:---------:|:---------------------------------------:|:--------------------------------------------------------------------------------------------------------------------------------:|
| maxAttempts | number | Yes | - | The max attempts to try |
| backOff | number | No | 0 | number in `ms` to back off. If not set, then no wait |
| backOffPolicy | enum | No | FixedBackOffPolicy | can be fixed or exponential |
| exponentialOption | object | No | { maxInterval: 2000, multiplier: 2 } | This is for the `ExponentialBackOffPolicy` <br/> The max interval each wait and the multiplier for the `backOff`. |
| doRetry | (e: any) => boolean | No | - | Function with error parameter to decide if repetition is necessary. |
| value | Error/Exception class | No | [ ] | An array of Exception types that are retryable. |
| useConsoleLogger | boolean | No | true | Print errors on console. |
| useOriginalError | throw original exception| No | false | `MaxAttemptsError` by default. if this is set to *true*, the `original` exception would be thrown instead. |
| Option Name | Type | Required? | Default | Description |
|:-----------------:|:------------------------:|:---------:|:---------------------------------------:|:-----------------------------------------------------------------------------------------------------------------:|
| maxAttempts | number | Yes | - | The max attempts to try |
| backOff | number | No | 0 | number in `ms` to back off. If not set, then no wait |
| backOffPolicy | enum | No | FixedBackOffPolicy | can be fixed or exponential |
| exponentialOption | object | No | `{ maxInterval: 2000, multiplier: 2 }` | This is for the `ExponentialBackOffPolicy` <br/> The max interval each wait and the multiplier for the `backOff`. |
| doRetry | (e: any) => boolean | No | - | Function with error parameter to decide if repetition is necessary. |
| value | Error/Exception class | No | [ ] | An array of Exception types that are retryable. |
| useConsoleLogger | boolean | No | true | Print errors on console. |
| useOriginalError | throw original exception | No | false | `MaxAttemptsError` by default. if this is set to *true*, the `original` exception would be thrown instead. |

#### Exponential options

The `exponentialOption` allows you to fine-tune the exponential backoff strategy using several options:
- `maxInterval`: The maximum interval between two retries. The default value is 2000 ms.
- `multiplier`: The multiplier to use to generate the next backoff interval from the last one. The default value is 2.
- `backoffStrategy`: Optional. If specified, determines the strategy used to introduce "jitter" between retry intervals. For an explanation of the available strategies and why you might select one over the other, check out [this article](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/).
- `ExponentialBackoffStrategy.FullJitter`: The base backoff interval is multiplied by a random number between 0 and 1.
- `ExponentialBackoffStrategy.EqualJitter`: The backoff interval is (base interval / 2) + (random value between 0 and base interval / 2).

### Example
```typescript
Expand Down Expand Up @@ -85,6 +94,17 @@ class RetryExample {
console.info(`Calling ExponentialBackOffRetry backOff 1s, multiplier=3 for the ${count++} time at ${new Date().toLocaleTimeString()}`);
throw new Error('I failed!');
}

@Retryable({
maxAttempts: 3,
backOffPolicy: BackOffPolicy.ExponentialBackOffPolicy,
backOff: 1000,
exponentialOption: { maxInterval: 4000, multiplier: 2, backoffStrategy: ExponentialBackoffStrategy.EqualJitter }
})
static async ExponentialBackOffWithJitterRetry() {
console.info(`Calling ExponentialBackOffWithJitterRetry backOff 1s, multiplier=2 for the ${count++} time at ${new Date().toLocaleTimeString()}`);
throw new Error('I failed!');
}
}

(async () => {
Expand Down Expand Up @@ -122,7 +142,14 @@ class RetryExample {
} catch (e) {
console.info(`All retry done as expected, final message: '${e.message}'`);
}


try {
resetCount();
await RetryExample.ExponentialBackOffWithJitterRetry();
} catch (e) {
console.info(`All retry done as expected, final message: '${e.message}'`);
}

})();

function resetCount() {
Expand Down Expand Up @@ -164,4 +191,10 @@ Calling ExponentialBackOffRetry backOff 1s, multiplier=3 for the 3 time at 4:12:
Calling ExponentialBackOffRetry backOff 1s, multiplier=3 for the 4 time at 4:13:03 PM
I failed!
All retry done as expected, final message: 'Failed for 'ExponentialBackOffRetry' for 3 times.'
Calling ExponentialBackOffWithJitterRetry backOff 1s, multiplier=2 for the 1 time at 4:13:03 PM
Calling ExponentialBackOffWithJitterRetry backOff 1s, multiplier=2 for the 2 time at 4:13:03 PM
Calling ExponentialBackOffWithJitterRetry backOff 1s, multiplier=2 for the 3 time at 4:13:05 PM
Calling ExponentialBackOffWithJitterRetry backOff 1s, multiplier=2 for the 4 time at 4:13:09 PM
I failed!
All retry done as expected, final message: 'Failed for 'ExponentialBackOffWithJitterRetry' for 3 times.'
```
23 changes: 21 additions & 2 deletions src/retry.decorator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import exp = require('constants');
import { BackOffPolicy, MaxAttemptsError, Retryable } from './retry.decorator';
import {BackOffPolicy, ExponentialBackoffStrategy, MaxAttemptsError, Retryable} from './retry.decorator';

class TestClass {
count: number;
Expand Down Expand Up @@ -49,6 +48,16 @@ class TestClass {
await this.called();
}

@Retryable({
maxAttempts: 3,
backOffPolicy: BackOffPolicy.ExponentialBackOffPolicy,
exponentialOption: { maxInterval: 4000, multiplier: 2, backoffStrategy: ExponentialBackoffStrategy.FullJitter },
})
async exponentialBackOffWithJitterRetry(): Promise<void> {
console.info(`Calling ExponentialBackOffRetry backOff 1s, multiplier=2 for the ${++this.count} time at ${new Date().toLocaleTimeString()}`);
await this.called();
}

@Retryable({ maxAttempts: 2, useConsoleLogger: false })
async noLog(): Promise<void> {
console.log(`test method is called for ${++this.count} time`);
Expand Down Expand Up @@ -166,6 +175,16 @@ describe('Retry Test', () => {
expect(calledSpy).toHaveBeenCalledTimes(4);
});

test('exponential backOff policy with jitter', async () => {
jest.setTimeout(60000);
const calledSpy = jest.spyOn(testClass, 'called');
calledSpy.mockImplementation(() => { throw new Error(); });
try {
await testClass.exponentialBackOffWithJitterRetry();
} catch (e) {}
expect(calledSpy).toHaveBeenCalledTimes(4);
});

test('no log', async () => {
const calledSpy = jest.spyOn(testClass, 'called');
const errorSpy = jest.spyOn(console, 'error');
Expand Down
43 changes: 41 additions & 2 deletions src/retry.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function Retryable(options: RetryOptions): DecoratorFunction {
if (!canRetry(e)) {
throw e;
}
backOff && (await sleep(backOff));
backOff && (await sleep(applyBackoffStrategy(backOff)));
if (options.backOffPolicy === BackOffPolicy.ExponentialBackOffPolicy) {
backOff = Math.min(backOff * options.exponentialOption.multiplier, options.exponentialOption.maxInterval);
}
Expand All @@ -78,6 +78,22 @@ export function Retryable(options: RetryOptions): DecoratorFunction {
...options.exponentialOption,
};
}

/**
* Calculate the actual backoff using the specified backoff strategy, if any
* @see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
* @param baseBackoff - base backoff time in ms
*/
function applyBackoffStrategy(baseBackoff: number): number {
const { backoffStrategy } = options.exponentialOption ?? {};
if (backoffStrategy === ExponentialBackoffStrategy.EqualJitter) {
return baseBackoff / 2 + (Math.random() * baseBackoff / 2);
}
if (backoffStrategy === ExponentialBackoffStrategy.FullJitter) {
return Math.random() * baseBackoff;
}
return baseBackoff;
}
}

export class MaxAttemptsError extends Error {
Expand All @@ -93,7 +109,15 @@ export interface RetryOptions {
backOffPolicy?: BackOffPolicy;
backOff?: number;
doRetry?: (e: any) => boolean;
exponentialOption?: { maxInterval: number; multiplier: number };
exponentialOption?: {
maxInterval: number;
multiplier: number;
/**
* Optional. If provided, the backoff time will include jitter using the desired strategy.
* For more information, see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
*/
backoffStrategy?: ExponentialBackoffStrategy;
};
maxAttempts: number;
value?: ErrorConstructor[];
useConsoleLogger?: boolean;
Expand All @@ -105,5 +129,20 @@ export enum BackOffPolicy {
ExponentialBackOffPolicy = 'ExponentialBackOffPolicy'
}

/**
* Represents different strategies for applying jitter to backoff times.
* @see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
*/
export enum ExponentialBackoffStrategy {
/**
* The backoff time will be (base backoff time) * (random number between 0 and 1).
*/
FullJitter = 'FullJitter',
/**
* The backoff time will be (base backoff time / 2) + (random number between 0 and (base backoff time / 2)).
*/
EqualJitter = 'EqualJitter',
}

export type DecoratorFunction = (target: Record<string, any>, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) => TypedPropertyDescriptor<any>;

0 comments on commit 6311d3e

Please sign in to comment.