povtor
Version:
Repeat function call depending on the previous call result and specified conditions.
311 lines (285 loc) • 10.7 kB
text/typescript
export type RetryAction = (...args: unknown[]) => Promise<unknown> | unknown;
export type GetRetryTimeout = (result?: RetryResult) => unknown;
export type RetryTimeout = number | GetRetryTimeout;
export type RetryTest = (value?: unknown, result?: RetryResult) => boolean;
/** Settings of {@link retry} function. */
export interface RetrySettings {
/** A function that should be called. */
action: RetryAction;
/** An object that should be used as `this` when calling the action function. */
actionContext?: unknown;
/** An array of parameters that should be passed into the action function. */
actionParams?: unknown[];
/**
* An amount of milliseconds before first call of the action function.
* When the value is not specified or is negative, the action function will be called immediately first time.
*/
delay?: number;
/**
* An array specifying amount and timeouts between repeated calls of the action function.
* Each item can be a number or a function (see [[`retryTimeout`]] setting for details).
* Has priority over [[`retryQty`]] and [[`retryTimeout`]] settings.
*/
retryAttempts?: RetryTimeout[];
/**
* Maximum number of repeated calls of the action function. A negative value means no restriction.
* Default value is `-1`.
*/
retryQty?: number;
/**
* A timeout between repeated calls of the action function, or a function that returns such timeout.
* A negative or non-number value means the repeat call will be made without delay (this is applied by default).
* If specified function returns `false` then retry process will be finished and result promise
* will be fulfilled or rejected depending on result of the last action's call.
*/
retryTimeout?: RetryTimeout;
/**
* A boolean value or a function returning boolean value that specifies whether the action function
* should be called again when the action function throws an error or returned promise is rejected.
* When not specified the call of the action function will not be repeated on an error.
*/
retryOnError?: boolean | RetryTest;
/**
* A boolean value or a function returning boolean value that specifies whether the action function
* should be called again after a made call. When not specified the action call will not be repeated.
*/
retryTest?: boolean | RetryTest;
/**
* Time in milliseconds specifying how long retry process can last
* starting from call of `retry` function.
* Elapsed time is checked before each retry attempt and when the time exceeds the given limit
* process will be finished and result promise will be fulfilled or rejected
* depending on result of the last action's call.
* `0` or negative value means no limit.
*/
timeLimit?: number;
[field: string]: unknown;
}
export interface ValueResult {
/** Time in milliseconds when value was saved. */
time: number;
/** Result of action's call or value of promise fulfillment. */
value: unknown;
}
export interface ErrorResult {
/** Error of action's call or value of promise rejection. */
error: unknown;
/** Time in milliseconds when error was saved. */
time: number;
}
export type ActionCallResult = ValueResult | ErrorResult;
export interface WithPromiseField {
promise: Promise<unknown>;
}
export interface RetryResult extends WithPromiseField {
/** Number of calls of the action function that have already made. */
attempt: number;
/** Last error or value of promise rejection. */
error: unknown;
/** Whether the last call of the action function is ended with error. */
isError: boolean;
/** Contains result of each call of the action function. */
result: ActionCallResult[];
/** Settings that were passed to [[`retry`]] function. */
settings: RetrySettings;
/** Time in milliseconds when [[`retry`]] function was called. */
startTime: number;
/** Function that can be used to stop the process of calls repeating. Returns value of `promise` field. */
stop: () => Promise<unknown>;
/** A boolean value that indicates whether the process of calls repeating is stopped. */
stopped: boolean;
/**
* A value of last successfull call of the action function. When the action function returns a promise,
* the value will be result of the promise fulfillment.
*/
value: unknown;
/**
* A boolean value that indicates whether the action function is producing a result.
* Useful only when the action function returns a promise. Is set to `true` when the promise is pending.
*/
valueWait: boolean;
/**
* A boolean value that indicates waiting of the next call of the action function.
* Is set to `true` during a timeout between calls.
*/
wait: boolean;
}
/**
* Call specified function and repeat calls depending on settings.
*
* @param settings
* Operation settings.
* @return
* Object that can be used to observe and control the process of calls repeating.
* @author Denis Sikuler
*/
export function retry(settings: RetrySettings): RetryResult {
let actionResult, resultReject, resultResolve, timeoutId;
// eslint-disable-next-line func-names, prefer-arrow-callback
const resultPromise = new Promise(function(resolve, reject) {
resultResolve = resolve;
resultReject = reject;
});
const callResultList: ActionCallResult[] = [];
const { retryTimeout } = settings;
let index = 0;
let stopped = false;
let attempts: number;
let { retryAttempts, timeLimit } = settings;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/prefer-optional-chain
if (retryAttempts && retryAttempts.length) {
attempts = retryAttempts.length + 1;
}
else {
retryAttempts = null;
const { retryQty } = settings;
if (typeof retryQty === 'number' && retryQty >= 0) {
attempts = retryQty + 1;
}
else {
attempts = -1;
}
}
if (typeof timeLimit !== 'number' || timeLimit < 0) {
timeLimit = 0;
}
function stopRetry(): Promise<unknown> {
if (! stopped) {
/* eslint-disable @typescript-eslint/no-use-before-define */
if (timeoutId) {
clearTimeout(timeoutId);
retryResult.wait = false;
}
stopped = retryResult.stopped = true;
if (! retryResult.valueWait) {
resultResolve(retryResult.value);
}
/* eslint-enable @typescript-eslint/no-use-before-define */
}
return resultPromise;
}
const startTime = new Date().getTime();
const retryResult: RetryResult = {
attempt: index,
error: actionResult,
isError: false,
promise: resultPromise,
result: callResultList,
settings,
startTime,
stop: stopRetry,
stopped: false,
value: actionResult,
valueWait: false,
wait: false
};
function retryAction(): void {
retryResult.attempt = ++index;
retryResult.wait = false;
retryResult.valueWait = true;
try {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
actionResult = settings.action.apply(settings.actionContext || null, settings.actionParams || []);
if (actionResult && typeof actionResult === 'object' && typeof actionResult.then === 'function') {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
actionResult.then(onActionEnd, onActionError);
}
else {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
onActionEnd(actionResult);
}
}
catch (e) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
onActionError(e);
}
}
function end(): void {
if (retryResult.isError) {
resultReject(retryResult.error);
}
else {
resultResolve(retryResult.value);
}
}
// eslint-disable-next-line consistent-return
function repeat(): void {
let timeout;
if (index) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
timeout = retryAttempts
? retryAttempts.shift()
: retryTimeout;
if (typeof timeout === 'function') {
timeout = timeout(retryResult);
if (timeout === false) {
return end();
}
}
}
else {
timeout = settings.delay;
}
if (attempts > 0) {
attempts--;
}
if (typeof timeout !== 'number' || timeout < 0) {
retryAction();
}
else {
retryResult.wait = true;
timeoutId = setTimeout(retryAction, timeout);
}
}
function next(test: unknown): void {
let proceed = test;
const result = {
time: new Date().getTime()
} as ActionCallResult;
let value;
if (retryResult.isError) {
value = (result as ErrorResult).error = retryResult.error;
}
else {
value = (result as ValueResult).value = retryResult.value;
}
retryResult.result.push(result);
retryResult.valueWait = false;
if (stopped || ! attempts) {
proceed = false;
}
else if (typeof proceed === 'function') {
proceed = proceed(value, retryResult);
}
if (proceed && (! timeLimit || new Date().getTime() - startTime <= timeLimit)) {
repeat();
}
else {
end();
}
}
function onActionEnd(value: unknown): void {
retryResult.value = value;
retryResult.isError = false;
next(settings.retryTest);
}
function onActionError(reason: unknown): void {
retryResult.error = reason;
retryResult.isError = true;
next(settings.retryOnError);
}
repeat();
return retryResult;
}
/**
* Return value of `promise` field of the passed object.
*
* @param obj
* Object whose field should be returned.
* @return
* Value of `promise` field of the passed object.
* @author Denis Sikuler
*/
export function getPromiseField(obj: WithPromiseField): Promise<unknown> {
return obj.promise;
}