Skip to content

Commit

Permalink
Merge pull request #78 from parzh/develop
Browse files Browse the repository at this point in the history
Release 1.6.x
  • Loading branch information
parzhitsky authored Jul 19, 2020
2 parents 583143b + 8bc3a07 commit ab212a6
Show file tree
Hide file tree
Showing 21 changed files with 378 additions and 121 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const content = await retryable((resolve, reject, retry) => {

else
// retrying after exponential backoff (see https://en.wikipedia.org/wiki/Exponential_backoff)
retry.after(2 ** retry.count * 100);
retry.after("exponential"); // same as: retry.after(2 ** retry.count * 100);
});
});
```
8 changes: 4 additions & 4 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@parzh/retryable",
"version": "1.5.3",
"version": "1.6.0",
"description": "Convenience function to retry an action",
"author": "Dima Parzhitsky <parzhitsky@gmail.com>",
"license": "MIT",
Expand Down Expand Up @@ -45,7 +45,7 @@
"transform": {
"\\.ts$": "ts-jest"
},
"restoreMocks": true,
"resetMocks": true,
"errorOnDeprecated": true,
"cacheDirectory": "<rootDir>/.cache/jest"
},
Expand Down
23 changes: 23 additions & 0 deletions src/delays.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** @public */
const delays = {
/*
Notes to self:
- beware of returning NaNs and negative numbers
- number-like strings are forbidden, as in: `delays["100"]()` // ❌
- functions in this namespace must have similar argument structure
*/

/** @see https://en.wikipedia.org/wiki/Exponential_backoff */
exponential(retryCount: number): number {
return 2 ** retryCount * 100;
},
} as const;

export type DelayNamed = keyof typeof delays;
export type Delay = number | DelayNamed;

export function isNamed(delay: Delay): delay is DelayNamed {
return delay in delays;
}

export default delays;
133 changes: 92 additions & 41 deletions src/retryable.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type Action from "./typings/action";
import type Retryer from "./typings/retryer";
import type { RetryerProps } from "./typings/retryer";

import assertNatural from "./assert-natural.impl";
import assertNonNegative from "./assert-non-negative.impl";
import delays, { isNamed } from "./delays";

/** @private */
type Maybe<Value> = Value | null;
type Maybe<Value> = Value | null | undefined;

/** @private */
const RETRY_COUNT_DEFAULT = 0;
Expand All @@ -29,21 +32,23 @@ const RETRY_COUNT_DEFAULT = 0;
* retry();
*
* else
* retry.after(2 ** retry.count * 100);
* retry.after("exponential");
* });
* });
*/
export default function retryable<Value = unknown>(action: Action<Value>): Promise<Value> {
/** @private */
let _retryCount: number = RETRY_COUNT_DEFAULT;
let _retryTimeoutId: Maybe<NodeJS.Timer>;

let _retryCount = RETRY_COUNT_DEFAULT;
let _nextRetryCount: Maybe<number>;

/** @private */
let _nextRetryCount: Maybe<number> = null;
let _maxRetryCount = Infinity;
let _maxRetryCountSet = false; // set by user that is
let _onMaxRetryCountExceeded: Maybe<() => unknown>;

/** @private */
let _retryTimeoutId: Maybe<NodeJS.Timer> = null;
let _resolved = false;
let _rejected = false;

/** @private */
function updateRetryCount(): void {
if (_nextRetryCount != null) {
_retryCount = _nextRetryCount;
Expand All @@ -54,47 +59,85 @@ export default function retryable<Value = unknown>(action: Action<Value>): Promi
}

function resetRetryCount(argumentRequired: boolean, retryCountExplicit = RETRY_COUNT_DEFAULT): void {
if (!argumentRequired)
retryCountExplicit = retryCountExplicit ?? RETRY_COUNT_DEFAULT;
if (!argumentRequired && retryCountExplicit == null)
retryCountExplicit = RETRY_COUNT_DEFAULT;

if (retryCountExplicit !== RETRY_COUNT_DEFAULT)
assertNatural(retryCountExplicit, "new value of retryCount");
assertNatural(retryCountExplicit, "new value of retry.count");

_nextRetryCount = retryCountExplicit;
}

return new Promise<Value>((resolve, reject) => {
/** @private */
function execute(): void {
action(
resolve,
reject,
// explicitly relying on hoisting here
// eslint-disable-next-line @typescript-eslint/no-use-before-define
retry as Retryer,
return new Promise<Value>((res, rej) => {
const resolve: typeof res = (...args) => {
_resolved = true;
res(...args);
};

// arguments below are deprecated,
// left for backwards compatibility

_retryCount,
resetRetryCount.bind(null, false),
);
}
const reject: typeof rej = (...args) => {
_rejected = true;
rej(...args);
};

function retry(): void {
const retry: Retryer = Object.assign(() => {
updateRetryCount();

if (_retryCount > _maxRetryCount)
if (_onMaxRetryCountExceeded != null)
_onMaxRetryCountExceeded();

else
return reject(`Retry limit exceeded after ${ _maxRetryCount } retries (${ _maxRetryCount + 1 } attempts total)`);

if (_resolved || _rejected)
return;

// explicitly relying on hoisting here
// eslint-disable-next-line @typescript-eslint/no-use-before-define
execute();
}
}, {
count: _retryCount, // temporary

function retryAfter(msec: number): void {
assertNonNegative(msec, "retry delay");
_retryTimeoutId = setTimeout(retry, msec);
}
setCount: resetRetryCount.bind(null, true),

function retryCancel(): void {
if (_retryTimeoutId != null)
clearTimeout(_retryTimeoutId);
}
setMaxCount(value, onExceeded?) {
if (_maxRetryCountSet)
return; // ignore subsequent calls

assertNatural(value, "max value of retry.count");

_maxRetryCount = value;
_maxRetryCountSet = true;

if (onExceeded == null)
return;

if (onExceeded === "resolve")
_onMaxRetryCountExceeded = resolve;

else if (onExceeded !== "reject")
_onMaxRetryCountExceeded = onExceeded;
},

after(delay) {
let msec: number;

if (isNamed(delay))
msec = delays[delay](_retryCount);

else
msec = +delay;

assertNonNegative(msec, "retry delay");

_retryTimeoutId = setTimeout(retry, msec);
},

cancel() {
if (_retryTimeoutId != null)
clearTimeout(_retryTimeoutId);
},
} as RetryerProps);

Object.defineProperty(retry, "count", {
get(): number {
Expand All @@ -106,11 +149,19 @@ export default function retryable<Value = unknown>(action: Action<Value>): Promi
},
});

retry.after = retryAfter;
function execute(): void {
action(
resolve,
reject,
retry,

retry.setCount = resetRetryCount.bind(null, true);
// arguments below are deprecated,
// left for backwards compatibility

retry.cancel = retryCancel;
_retryCount,
resetRetryCount.bind(null, false),
);
}

execute();
});
Expand Down
2 changes: 1 addition & 1 deletion src/typings/retry-count-resetter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** @deprecated */
/** @deprecated Use `Retryer["setCount"]` from _/typings/retryer.ts_ instead */
export default interface RetryCountResetter {
(newValue?: number): void;
}
103 changes: 99 additions & 4 deletions src/typings/retryer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,106 @@
export default interface Retryer {
(): void;
import type { Delay } from "../delays";

readonly count: number;
export type OnMaxRetryCountExceeded = "resolve" | "reject" | (() => unknown);

after(msec: number): void;
export interface RetryerProps {
/**
* Readonly number of retries that occurred so far.
* Always 1 less than the number of total attempts.
* @example
* console.log(retry.count);
* // [attempt #1] logs 0
* // [attempt #2] logs 1
* // [attempt #3] logs 2
* // ...
*
* retry();
*/
readonly count: number;

/**
* Set value of `retry.count` for the next attempt
* @example
* retry.setCount(42);
*
* console.log(retry.count);
* // [attempt #1] logs 0
* // [attempt #2] logs 42
* // [attempt #3] logs 42
* // ...
*
* retry();
*/
setCount(newValue: number): void;

/**
* Set upper limit for the value of `retry.count`
* @param value Value of `retry.count`
* @param onExceeded (defaults to `"reject"`) Limit exceed action
* @example
* // (assuming that every attempt ends with retry)
*
* retry.setMaxCount(0);
* retry.setMaxCount(0, "reject");
* // [attempt #1] reject with "Retry limit exceeded …"
*
* retry.setMaxCount(1);
* // [attempt #1] retry
* // [attempt #2] reject with "Retry limit exceeded …"
*
* retry.setMaxCount(1, "resolve");
* retry.setMaxCount(1, resolve);
* // [attempt #1] retry
* // [attempt #2] resolve to `undefined`
*
* retry.setMaxCount(1, () => resolve(42));
* // [attempt #1] retry
* // [attempt #2] resolve to `42`
*
* retry.setMaxCount(1, () => retry.setCount(0));
* // [attempt #1] retry
* // [attempt #2] retry (update count)
* // [attempt #3] retry
* // [attempt #4] retry (update count)
* // [attempt #5] retry
* // ...
*
* retry.setMaxCount(1, () => retry.setCount(1));
* // [attempt #1] retry
* // [attempt #2] retry (update count)
* // [attempt #3] retry (update count)
* // [attempt #4] retry (update count)
* // [attempt #5] retry (update count)
* // ...
*/
setMaxCount(value: number, onExceeded?: OnMaxRetryCountExceeded): void;

/**
* Delays retry(s) by a given strategy or timer
* @param delay The delay
* @example
* retry.after(300);
* // retry after 300 milliseconds
*
* retry.after(1000);
* // retry after 1 second
*
* retry.after("exponential");
* // use "Exponential backoff" retry strategy
*
* retry.after(0);
* // retry asynchronously
*/
after(delay: Delay): void;

/**
* Cancel the delayed retry
* @example
* retry.after(1000); // won't retry
* retry.cancel();
*/
cancel(): void;
}

export default interface Retryer extends RetryerProps {
(): void;
}
2 changes: 1 addition & 1 deletion src/wait.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Сreate a promise that resolves after a given number of milliseconds
* Create a promise that resolves after a given number of milliseconds
* @param {number} msec
*/
export default function wait(msec: number): Promise<void> {
Expand Down
Loading

0 comments on commit ab212a6

Please sign in to comment.