Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@api3/promise-utils

Package Overview
Dependencies
Maintainers
3
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@api3/promise-utils - npm Package Compare versions

Comparing version 0.1.0 to 0.2.0

24

build/cjs/index.d.ts

@@ -10,7 +10,21 @@ export declare type GoResultSuccess<T> = {

export declare type GoResult<T, E extends Error = Error> = GoResultSuccess<T> | GoResultError<E>;
export interface PromiseOptions {
readonly retries?: number;
readonly retryDelayMs?: number;
readonly timeoutMs?: number;
export interface StaticDelayOptions {
type: 'static';
delayMs: number;
}
export interface RandomDelayOptions {
type: 'random';
minDelayMs: number;
maxDelayMs: number;
}
export interface GoAsyncOptions {
retries?: number;
attemptTimeoutMs?: number;
totalTimeoutMs?: number;
delay?: StaticDelayOptions | RandomDelayOptions;
}
export declare class GoWrappedError extends Error {
reason: unknown;
constructor(reason: unknown);
}
export declare function assertGoSuccess<T>(result: GoResult<T>): asserts result is GoResultSuccess<T>;

@@ -21,2 +35,2 @@ export declare function assertGoError<E extends Error>(result: GoResult<any, E>): asserts result is GoResultError<E>;

export declare const goSync: <T, E extends Error>(fn: () => T) => GoResult<T, E>;
export declare const go: <T, E extends Error>(fn: Promise<T> | (() => Promise<T>), options?: PromiseOptions | undefined) => Promise<GoResult<T, E>>;
export declare const go: <T, E extends Error>(fn: () => Promise<T>, options?: GoAsyncOptions | undefined) => Promise<GoResult<T, E>>;

@@ -12,4 +12,10 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
exports.go = exports.goSync = exports.fail = exports.success = exports.assertGoError = exports.assertGoSuccess = void 0;
const attempt_1 = require("@lifeomic/attempt");
exports.go = exports.goSync = exports.fail = exports.success = exports.assertGoError = exports.assertGoSuccess = exports.GoWrappedError = void 0;
class GoWrappedError extends Error {
constructor(reason) {
super('' + reason);
this.reason = reason;
}
}
exports.GoWrappedError = GoWrappedError;
// NOTE: This needs to be written using 'function' syntax (cannot be arrow function)

@@ -44,3 +50,3 @@ // See: https://github.com/microsoft/TypeScript/issues/34523#issuecomment-542978853

return (0, exports.fail)(err);
return (0, exports.fail)(new Error('' + err));
return (0, exports.fail)(new GoWrappedError(err));
};

@@ -56,44 +62,15 @@ const goSync = (fn) => {

exports.goSync = goSync;
const retryFnWrapper = (fn, attemptOptions) => __awaiter(void 0, void 0, void 0, function* () {
if (typeof fn === 'function') {
return retryFn(fn, attemptOptions);
}
return retryFn(() => fn, attemptOptions);
});
const retryFn = (fn, attemptOptions) => __awaiter(void 0, void 0, void 0, function* () {
return (0, attempt_1.retry)(fn, attemptOptions)
.then(exports.success)
.catch((err) => createGoError(err));
});
const go = (fn, options) => __awaiter(void 0, void 0, void 0, function* () {
const attemptOptions = {
delay: (options === null || options === void 0 ? void 0 : options.retryDelayMs) || 200,
maxAttempts: ((options === null || options === void 0 ? void 0 : options.retries) || 0) + 1,
initialDelay: 0,
minDelay: 0,
maxDelay: 0,
factor: 0,
timeout: (options === null || options === void 0 ? void 0 : options.timeoutMs) || 0,
jitter: false,
handleError: null,
handleTimeout: (options === null || options === void 0 ? void 0 : options.timeoutMs)
? (context, options) => __awaiter(void 0, void 0, void 0, function* () {
if (context.attemptsRemaining > 0) {
const res = yield retryFnWrapper(fn, Object.assign(Object.assign({}, options), { maxAttempts: context.attemptsRemaining }));
if (res.success) {
return res.data;
}
else {
throw res.error;
}
}
throw new Error(`Operation timed out`);
})
: null,
beforeAttempt: null,
calculateDelay: null,
};
const getRandomInRange = (min, max) => {
return Math.random() * (max - min) + min;
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject('Operation timed out'), ms));
const attempt = (fn, attemptTimeoutMs) => __awaiter(void 0, void 0, void 0, function* () {
// We need try/catch because `fn` might throw sync errors as well
try {
return retryFnWrapper(fn, attemptOptions);
if (attemptTimeoutMs === undefined)
return (0, exports.success)(yield fn());
else {
return (0, exports.success)(yield Promise.race([fn(), timeout(attemptTimeoutMs)]));
}
}

@@ -104,3 +81,46 @@ catch (err) {

});
const go = (fn, options) => __awaiter(void 0, void 0, void 0, function* () {
if (!options)
return attempt(fn);
const { retries, attemptTimeoutMs, delay, totalTimeoutMs } = options;
let fullTimeoutExceeded = false;
let fullTimeoutPromise = new Promise((_resolve) => { }); // Never resolves
if (totalTimeoutMs !== undefined) {
// Start a "full" timeout that will stop all retries after it is exceeded
fullTimeoutPromise = sleep(totalTimeoutMs).then(() => {
fullTimeoutExceeded = true;
return (0, exports.fail)(new Error('Full timeout exceeded'));
});
}
const makeAttempts = () => __awaiter(void 0, void 0, void 0, function* () {
const attempts = retries ? retries + 1 : 1;
let lastFailedAttemptResult = null;
for (let i = 0; i < attempts; i++) {
// This is guaranteed to be false for the first attempt
if (fullTimeoutExceeded)
break;
const goRes = yield attempt(fn, attemptTimeoutMs);
if (goRes.success)
return goRes;
lastFailedAttemptResult = goRes;
if (delay) {
switch (delay.type) {
case 'random': {
const { minDelayMs, maxDelayMs } = delay;
yield sleep(getRandomInRange(minDelayMs, maxDelayMs));
break;
}
case 'static': {
const { delayMs } = delay;
yield sleep(delayMs);
break;
}
}
}
}
return lastFailedAttemptResult;
});
return Promise.race([makeAttempts(), fullTimeoutPromise]);
});
exports.go = go;
//# sourceMappingURL=index.js.map

@@ -10,7 +10,21 @@ export declare type GoResultSuccess<T> = {

export declare type GoResult<T, E extends Error = Error> = GoResultSuccess<T> | GoResultError<E>;
export interface PromiseOptions {
readonly retries?: number;
readonly retryDelayMs?: number;
readonly timeoutMs?: number;
export interface StaticDelayOptions {
type: 'static';
delayMs: number;
}
export interface RandomDelayOptions {
type: 'random';
minDelayMs: number;
maxDelayMs: number;
}
export interface GoAsyncOptions {
retries?: number;
attemptTimeoutMs?: number;
totalTimeoutMs?: number;
delay?: StaticDelayOptions | RandomDelayOptions;
}
export declare class GoWrappedError extends Error {
reason: unknown;
constructor(reason: unknown);
}
export declare function assertGoSuccess<T>(result: GoResult<T>): asserts result is GoResultSuccess<T>;

@@ -21,2 +35,2 @@ export declare function assertGoError<E extends Error>(result: GoResult<any, E>): asserts result is GoResultError<E>;

export declare const goSync: <T, E extends Error>(fn: () => T) => GoResult<T, E>;
export declare const go: <T, E extends Error>(fn: Promise<T> | (() => Promise<T>), options?: PromiseOptions | undefined) => Promise<GoResult<T, E>>;
export declare const go: <T, E extends Error>(fn: () => Promise<T>, options?: GoAsyncOptions | undefined) => Promise<GoResult<T, E>>;

@@ -10,3 +10,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {

};
import { retry } from '@lifeomic/attempt';
export class GoWrappedError extends Error {
constructor(reason) {
super('' + reason);
this.reason = reason;
}
}
// NOTE: This needs to be written using 'function' syntax (cannot be arrow function)

@@ -37,3 +42,3 @@ // See: https://github.com/microsoft/TypeScript/issues/34523#issuecomment-542978853

return fail(err);
return fail(new Error('' + err));
return fail(new GoWrappedError(err));
};

@@ -48,44 +53,15 @@ export const goSync = (fn) => {

};
const retryFnWrapper = (fn, attemptOptions) => __awaiter(void 0, void 0, void 0, function* () {
if (typeof fn === 'function') {
return retryFn(fn, attemptOptions);
}
return retryFn(() => fn, attemptOptions);
});
const retryFn = (fn, attemptOptions) => __awaiter(void 0, void 0, void 0, function* () {
return retry(fn, attemptOptions)
.then(success)
.catch((err) => createGoError(err));
});
export const go = (fn, options) => __awaiter(void 0, void 0, void 0, function* () {
const attemptOptions = {
delay: (options === null || options === void 0 ? void 0 : options.retryDelayMs) || 200,
maxAttempts: ((options === null || options === void 0 ? void 0 : options.retries) || 0) + 1,
initialDelay: 0,
minDelay: 0,
maxDelay: 0,
factor: 0,
timeout: (options === null || options === void 0 ? void 0 : options.timeoutMs) || 0,
jitter: false,
handleError: null,
handleTimeout: (options === null || options === void 0 ? void 0 : options.timeoutMs)
? (context, options) => __awaiter(void 0, void 0, void 0, function* () {
if (context.attemptsRemaining > 0) {
const res = yield retryFnWrapper(fn, Object.assign(Object.assign({}, options), { maxAttempts: context.attemptsRemaining }));
if (res.success) {
return res.data;
}
else {
throw res.error;
}
}
throw new Error(`Operation timed out`);
})
: null,
beforeAttempt: null,
calculateDelay: null,
};
const getRandomInRange = (min, max) => {
return Math.random() * (max - min) + min;
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject('Operation timed out'), ms));
const attempt = (fn, attemptTimeoutMs) => __awaiter(void 0, void 0, void 0, function* () {
// We need try/catch because `fn` might throw sync errors as well
try {
return retryFnWrapper(fn, attemptOptions);
if (attemptTimeoutMs === undefined)
return success(yield fn());
else {
return success(yield Promise.race([fn(), timeout(attemptTimeoutMs)]));
}
}

@@ -96,2 +72,45 @@ catch (err) {

});
export const go = (fn, options) => __awaiter(void 0, void 0, void 0, function* () {
if (!options)
return attempt(fn);
const { retries, attemptTimeoutMs, delay, totalTimeoutMs } = options;
let fullTimeoutExceeded = false;
let fullTimeoutPromise = new Promise((_resolve) => { }); // Never resolves
if (totalTimeoutMs !== undefined) {
// Start a "full" timeout that will stop all retries after it is exceeded
fullTimeoutPromise = sleep(totalTimeoutMs).then(() => {
fullTimeoutExceeded = true;
return fail(new Error('Full timeout exceeded'));
});
}
const makeAttempts = () => __awaiter(void 0, void 0, void 0, function* () {
const attempts = retries ? retries + 1 : 1;
let lastFailedAttemptResult = null;
for (let i = 0; i < attempts; i++) {
// This is guaranteed to be false for the first attempt
if (fullTimeoutExceeded)
break;
const goRes = yield attempt(fn, attemptTimeoutMs);
if (goRes.success)
return goRes;
lastFailedAttemptResult = goRes;
if (delay) {
switch (delay.type) {
case 'random': {
const { minDelayMs, maxDelayMs } = delay;
yield sleep(getRandomInRange(minDelayMs, maxDelayMs));
break;
}
case 'static': {
const { delayMs } = delay;
yield sleep(delayMs);
break;
}
}
}
}
return lastFailedAttemptResult;
});
return Promise.race([makeAttempts(), fullTimeoutPromise]);
});
//# sourceMappingURL=index.js.map

@@ -10,7 +10,21 @@ export declare type GoResultSuccess<T> = {

export declare type GoResult<T, E extends Error = Error> = GoResultSuccess<T> | GoResultError<E>;
export interface PromiseOptions {
readonly retries?: number;
readonly retryDelayMs?: number;
readonly timeoutMs?: number;
export interface StaticDelayOptions {
type: 'static';
delayMs: number;
}
export interface RandomDelayOptions {
type: 'random';
minDelayMs: number;
maxDelayMs: number;
}
export interface GoAsyncOptions {
retries?: number;
attemptTimeoutMs?: number;
totalTimeoutMs?: number;
delay?: StaticDelayOptions | RandomDelayOptions;
}
export declare class GoWrappedError extends Error {
reason: unknown;
constructor(reason: unknown);
}
export declare function assertGoSuccess<T>(result: GoResult<T>): asserts result is GoResultSuccess<T>;

@@ -21,2 +35,2 @@ export declare function assertGoError<E extends Error>(result: GoResult<any, E>): asserts result is GoResultError<E>;

export declare const goSync: <T, E extends Error>(fn: () => T) => GoResult<T, E>;
export declare const go: <T, E extends Error>(fn: Promise<T> | (() => Promise<T>), options?: PromiseOptions | undefined) => Promise<GoResult<T, E>>;
export declare const go: <T, E extends Error>(fn: () => Promise<T>, options?: GoAsyncOptions | undefined) => Promise<GoResult<T, E>>;

@@ -1,2 +0,8 @@

import { retry } from '@lifeomic/attempt';
export class GoWrappedError extends Error {
reason;
constructor(reason) {
super('' + reason);
this.reason = reason;
}
}
// NOTE: This needs to be written using 'function' syntax (cannot be arrow function)

@@ -27,3 +33,3 @@ // See: https://github.com/microsoft/TypeScript/issues/34523#issuecomment-542978853

return fail(err);
return fail(new Error('' + err));
return fail(new GoWrappedError(err));
};

@@ -38,47 +44,63 @@ export const goSync = (fn) => {

};
const retryFnWrapper = async (fn, attemptOptions) => {
if (typeof fn === 'function') {
return retryFn(fn, attemptOptions);
const getRandomInRange = (min, max) => {
return Math.random() * (max - min) + min;
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const timeout = (ms) => new Promise((_, reject) => setTimeout(() => reject('Operation timed out'), ms));
const attempt = async (fn, attemptTimeoutMs) => {
// We need try/catch because `fn` might throw sync errors as well
try {
if (attemptTimeoutMs === undefined)
return success(await fn());
else {
return success(await Promise.race([fn(), timeout(attemptTimeoutMs)]));
}
}
return retryFn(() => fn, attemptOptions);
catch (err) {
return createGoError(err);
}
};
const retryFn = async (fn, attemptOptions) => retry(fn, attemptOptions)
.then(success)
.catch((err) => createGoError(err));
export const go = async (fn, options) => {
const attemptOptions = {
delay: options?.retryDelayMs || 200,
maxAttempts: (options?.retries || 0) + 1,
initialDelay: 0,
minDelay: 0,
maxDelay: 0,
factor: 0,
timeout: options?.timeoutMs || 0,
jitter: false,
handleError: null,
handleTimeout: options?.timeoutMs
? async (context, options) => {
if (context.attemptsRemaining > 0) {
const res = await retryFnWrapper(fn, { ...options, maxAttempts: context.attemptsRemaining });
if (res.success) {
return res.data;
if (!options)
return attempt(fn);
const { retries, attemptTimeoutMs, delay, totalTimeoutMs } = options;
let fullTimeoutExceeded = false;
let fullTimeoutPromise = new Promise((_resolve) => { }); // Never resolves
if (totalTimeoutMs !== undefined) {
// Start a "full" timeout that will stop all retries after it is exceeded
fullTimeoutPromise = sleep(totalTimeoutMs).then(() => {
fullTimeoutExceeded = true;
return fail(new Error('Full timeout exceeded'));
});
}
const makeAttempts = async () => {
const attempts = retries ? retries + 1 : 1;
let lastFailedAttemptResult = null;
for (let i = 0; i < attempts; i++) {
// This is guaranteed to be false for the first attempt
if (fullTimeoutExceeded)
break;
const goRes = await attempt(fn, attemptTimeoutMs);
if (goRes.success)
return goRes;
lastFailedAttemptResult = goRes;
if (delay) {
switch (delay.type) {
case 'random': {
const { minDelayMs, maxDelayMs } = delay;
await sleep(getRandomInRange(minDelayMs, maxDelayMs));
break;
}
else {
throw res.error;
case 'static': {
const { delayMs } = delay;
await sleep(delayMs);
break;
}
}
throw new Error(`Operation timed out`);
}
: null,
beforeAttempt: null,
calculateDelay: null,
}
return lastFailedAttemptResult;
};
// We need try/catch because `fn` might throw sync errors as well
try {
return retryFnWrapper(fn, attemptOptions);
}
catch (err) {
return createGoError(err);
}
return Promise.race([makeAttempts(), fullTimeoutPromise]);
};
//# sourceMappingURL=index.js.map
{
"name": "@api3/promise-utils",
"version": "0.1.0",
"version": "0.2.0",
"main": "./build/cjs/index.js",

@@ -32,10 +32,8 @@ "module": "./build/esm/index.js",

"jest": "^27.5.1",
"prettier": "^2.5.1",
"ts-jest": "^27.1.3",
"type-plus": "^4.0.1",
"typescript": "^4.6.2"
"prettier": "^2.6.2",
"ts-jest": "^27.1.4",
"type-plus": "^4.7.0",
"typescript": "^4.6.3"
},
"dependencies": {
"@lifeomic/attempt": "^3.0.2"
}
"dependencies": {}
}
# promise-utils [![ContinuousBuild](https://github.com/api3dao/promise-utils/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/api3dao/promise-utils/actions/workflows/main.yml)
> A simple package for a functional and typesafe error handling
> A simple package for a functional and typesafe error handling with zero dependencies

@@ -11,3 +11,3 @@ ## Installation

or if you use npm
or if you use npm:

@@ -20,3 +20,3 @@ `npm install @api3/promise-utils --save`

package are `go` and `goSync` functions. They accept a function to execute, and additionally `go` accepts an optional
`PromiseOptions` object as the second parameter. If the function executes without an error, a success response with the
`GoAsyncOptions` object as the second parameter. If the function executes without an error, a success response with the
data is returned, otherwise an error response is returned.

@@ -49,7 +49,8 @@

and with `PromiseOptions`:
and with `GoAsyncOptions`:
```ts
// The go function will retry 2 times with a 500 ms delay if fetchData fails to finish within 5 seconds
const goFetchData = await go(fetchData('users'), { retries: 2, retryDelayMs: 500, timeoutMs: 5_000 });
// The `fetchData` function will be retried a maximum of 2 times on error, with each attempt having
// a timeout of 5 seconds and a total timeout 10 seconds (shared among all attempts and delays).
const goFetchData = await go(fetchData('users'), { retries: 2, attemptTimeoutMs: 5_000, totalTimeoutMs: 10_000 });
...

@@ -88,15 +89,40 @@ ```

- `GoResult<T> = { data: T; success: true }`
- `GoResultSuccess<E extends Error = Error> = { error: E; success: false }`
- `GoResultError<T, E extends Error = Error> = GoResultSuccess<T> | GoResultError<E>`
- `PromiseOptions { readonly retries?: number; readonly retryDelayMs?: number; readonly timeoutMs?: number; }`
- ```ts
type GoResult<T> = { data: T; success: true };
```
- ```ts
type GoResultSuccess<E extends Error = Error> = { error: E; success: false };
```
- ```ts
type GoResultError<T, E extends Error = Error> = GoResultSuccess<T> | GoResultError<E>;
```
- ```ts
interface GoAsyncOptions {
retries?: number; // Number of retries to attempt if the go callback is unsuccessful.
attemptTimeoutMs?: number; // The timeout for each attempt.
totalTimeoutMs?: number; // The maximum timeout for all attempts and delays. No more retries are performed after this timeout.
delay?: StaticDelayOptions | RandomDelayOptions; // Type of the delay before each attempt. There is no delay before the first request.
}
```
- ```ts
interface StaticDelayOptions {
type: 'static';
delayMs: number;
}
```
- ```ts
interface RandomDelayOptions {
type: 'random';
minDelayMs: number;
maxDelayMs: number;
}
```
The default values for `PromiseOptions` are:
Careful, the `attemptTimeoutMs` value of `0` means timeout of 0 ms. If you want to have infinite timeout omit the key or
set it to `undefined`.
```ts
{ retries: 0, retryDelay: 200, timeoutMs: 0 }
```
The last exported value is a `GoWrappedError` class which wraps an error which happens in go callback. The difference
between `GoWrappedError` and regular `Error` class is that you can access `GoWrappedError.reason` to get the original
value which was thrown by the function.
By default, the `timeoutMs` value of `0` means that there is no timeout limit.
Take a look at the [implementation](https://github.com/api3dao/promise-utils/blob/main/src/index.ts) and

@@ -144,2 +170,8 @@ [tests](https://github.com/api3dao/promise-utils/blob/main/src/index.test.ts) for detailed examples and usage.

### Intentionally limited feature set
The go utils by design offer only very basic timeout and retry capabilities as these are often application specific and
could quickly result in bloated configuration. If you are looking for more complex features, consider using one of the
alternatives, e.g. https://github.com/lifeomic/attempt
## Limitations

@@ -169,1 +201,12 @@

inside the `get` function is `undefined` which makes the `this._get()` throw an error.
## Developer documentation
### Release
To release a new version follow these steps:
1. `yarn && yarn build`
2. `yarn version` and choose the version to be released
3. `yarn publish --access public`
4. `git push --follow-tags`

@@ -1,2 +0,2 @@

import { go, goSync, success, fail, assertGoSuccess, assertGoError } from './index';
import { go, goSync, success, fail, assertGoSuccess, assertGoError, GoWrappedError } from './index';
import { assertType, Equal } from 'type-plus';

@@ -24,3 +24,3 @@

const successFn = new Promise((res) => res(2));
const res = await go(successFn);
const res = await go(() => successFn);
expect(res).toEqual(success(2));

@@ -32,3 +32,3 @@ });

const errorFn = new Promise((_res, rej) => rej(err));
const res = await go(errorFn);
const res = await go(() => errorFn);
expect(res).toEqual(fail(err));

@@ -42,3 +42,3 @@ });

});
const res = await go(errorFn);
const res = await go(() => errorFn);
expect(res).toEqual(fail(err));

@@ -86,14 +86,3 @@ });

it('retries and resolves successful asynchronous functions', async () => {
jest
.spyOn(operations, 'successFn')
.mockRejectedValueOnce(new Error('Error 1'))
.mockRejectedValueOnce(new Error('Error 2'));
const res = await go(operations.successFn, { retries: 3 });
expect(operations.successFn).toHaveBeenCalledTimes(3);
expect(res).toEqual(success(2));
});
it('retries and resolves unsuccessful asynchronous functions', async () => {
it('retries and resolves unsuccessful asynchronous functions with the error from last retry', async () => {
const attempts = 3;

@@ -131,3 +120,3 @@ jest

it('resolves successful asynchronous functions within the timout limit', async () => {
const res = await go(operations.successFn, { timeoutMs: 20 });
const res = await go(operations.successFn, { attemptTimeoutMs: 20 });
expect(res).toEqual(success(2));

@@ -137,3 +126,3 @@ });

it('resolves unsuccessful asynchronous functions within the timout limit', async () => {
const res = await go(operations.errorFn, { timeoutMs: 20 });
const res = await go(operations.errorFn, { attemptTimeoutMs: 20 });
expect(res).toEqual(fail(new Error('Computer says no')));

@@ -143,5 +132,23 @@ });

it('resolves timed out asynchronous functions', async () => {
const res = await go(operations.successFn, { timeoutMs: 5 });
const res = await go(operations.successFn, { attemptTimeoutMs: 5 });
expect(res).toEqual(fail(new Error('Operation timed out')));
});
it('shows difference between promise callback and promise value', async () => {
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
// Promise value tries to resolve THE SAME promise every attempt
const sleepPromise = sleep(50);
const goVal = await go(() => sleepPromise, { attemptTimeoutMs: 30, retries: 1 });
expect(goVal).toEqual(success(undefined));
// Promise callback tries to resolve NEW promise every attempt
const goFn = await go(() => sleep(50), { attemptTimeoutMs: 30, retries: 1 });
expect(goFn).toEqual(fail(new Error('Operation timed out')));
});
it('shows that timeout 0 means 0 ms (not infinity)', async () => {
const res = await go(operations.successFn, { attemptTimeoutMs: 0 });
expect(res).toEqual(fail(new Error('Operation timed out')));
});
});

@@ -161,3 +168,3 @@

it('resolves successful asynchronous functions', async () => {
const res = await go(operations.successFn, { timeoutMs: 50, retries: 3 });
const res = await go(operations.successFn, { attemptTimeoutMs: 50, retries: 3 });
expect(res).toEqual(success(2));

@@ -167,3 +174,3 @@ });

it('resolves unsuccessful asynchronous functions', async () => {
const res = await go(operations.errorFn, { timeoutMs: 50, retries: 3 });
const res = await go(operations.errorFn, { attemptTimeoutMs: 50, retries: 3 });
expect(res).toEqual(fail(new Error('Computer says no')));

@@ -178,3 +185,3 @@ });

const res = await go(operations.successFn, { timeoutMs: 100, retries: 3 });
const res = await go(operations.successFn, { attemptTimeoutMs: 100, retries: 3 });
expect(operations.successFn).toHaveBeenCalledTimes(3);

@@ -190,3 +197,3 @@ expect(res).toEqual(success(2));

const res = await go(operations.errorFn, { timeoutMs: 100, retries: 2 });
const res = await go(operations.errorFn, { attemptTimeoutMs: 100, retries: 2 });
expect(operations.errorFn).toHaveBeenCalledTimes(3);

@@ -200,3 +207,3 @@ expect(res).toEqual(fail(new Error('Computer says no')));

const res = await go(operations.successFn, { timeoutMs: 5, retries: 2 });
const res = await go(operations.successFn, { attemptTimeoutMs: 5, retries: 2 });
expect(operations.successFn).toHaveBeenCalledTimes(attempts);

@@ -376,2 +383,16 @@ expect(res).toEqual(fail(new Error('Operation timed out')));

it('has access to native error', async () => {
const throwingFn = async () => {
throw { message: 'an error', data: 'some data' };
};
const goRes = await go<never, GoWrappedError>(throwingFn);
assertGoError(goRes);
// The error message is the not very useful stringified data
expect(goRes.error).toEqual(new Error('[object Object]'));
expect(goRes.error instanceof GoWrappedError).toBeTruthy();
expect(goRes.error.reason).toEqual({ message: 'an error', data: 'some data' });
});
// NOTE: Keep in sync with README

@@ -395,3 +416,3 @@ describe('documentation snippets are valid', () => {

it('error usage', async () => {
const goFetchData = await go(fetchData('throw'));
const goFetchData = await go(() => fetchData('throw'));
if (!goFetchData.success) {

@@ -461,1 +482,101 @@ const error = goFetchData.error;

});
describe('delay', () => {
it('only delays on retries', async () => {
const goRes = await go(async () => 123, { delay: { type: 'static', delayMs: 2000 } });
expect(goRes).toEqual(success(123));
}, 20); // Make the test timeout smaller then the delay
describe('random', () => {
it('waits for a random period of time before retry', async () => {
const now = Date.now();
const ticks: number[] = [];
jest.spyOn(global.Math, 'random').mockReturnValueOnce(0.5);
jest.spyOn(global.Math, 'random').mockReturnValueOnce(1);
await go(
async () => {
ticks.push(Date.now() - now);
throw new Error();
},
{ delay: { type: 'random', minDelayMs: 0, maxDelayMs: 100 }, retries: 2 }
);
expect(ticks[0]).toBeGreaterThanOrEqual(0);
expect(ticks[1]).toBeGreaterThanOrEqual(50);
expect(ticks[2]).toBeGreaterThanOrEqual(150);
});
});
describe('static', () => {
it('waits for a fixed period of time before retry', async () => {
const now = Date.now();
const ticks: number[] = [];
await go(
async () => {
ticks.push(Date.now() - now);
throw new Error();
},
{ delay: { type: 'static', delayMs: 100 }, retries: 2 }
);
expect(ticks[0]).toBeGreaterThanOrEqual(0);
expect(ticks[1]).toBeGreaterThanOrEqual(100);
expect(ticks[2]).toBeGreaterThanOrEqual(200);
});
});
});
describe('totalTimeoutMs', () => {
it('stops retying after the full timeout is exceeded', async () => {
const now = Date.now();
const ticks: number[] = [];
await go(
async () => {
ticks.push(Date.now() - now);
throw new Error();
},
{ delay: { type: 'static', delayMs: 50 }, retries: 150, totalTimeoutMs: 150 }
);
expect(ticks.length).toBe(3);
expect(ticks[0]).toBeGreaterThanOrEqual(0);
expect(ticks[1]).toBeGreaterThanOrEqual(50);
expect(ticks[2]).toBeGreaterThanOrEqual(100);
});
it('runs the go callback at least once independently of full timeout', async () => {
const now = Date.now();
const ticks: number[] = [];
await go(
async () => {
ticks.push(Date.now() - now);
throw new Error();
},
{ delay: { type: 'static', delayMs: 50 }, retries: 10, totalTimeoutMs: 0 }
);
expect(ticks.length).toBe(1);
expect(ticks[0]).toBeGreaterThanOrEqual(0);
});
it('resolves the value immediately after the timeout has exceeded', async () => {
const now = Date.now();
const goRes = await go(
async () => {
await new Promise((res) => setTimeout(res, 50));
},
{ delay: { type: 'static', delayMs: 50 }, retries: 1, totalTimeoutMs: 20 }
);
const delta = Date.now() - now;
expect(delta).toBeGreaterThanOrEqual(20);
expect(delta).toBeLessThan(25); // The timeout is the minimum (not exact) time after which to stop a callback so there might be a few ms extra
expect(goRes).toEqual(fail(new Error('Full timeout exceeded')));
});
});

@@ -1,3 +0,1 @@

import { AttemptOptions, retry } from '@lifeomic/attempt';
// NOTE: We use discriminated unions over "success" property

@@ -8,8 +6,26 @@ export type GoResultSuccess<T> = { data: T; success: true };

export interface PromiseOptions {
readonly retries?: number;
readonly retryDelayMs?: number;
readonly timeoutMs?: number;
export interface StaticDelayOptions {
type: 'static';
delayMs: number;
}
export interface RandomDelayOptions {
type: 'random';
minDelayMs: number;
maxDelayMs: number;
}
export interface GoAsyncOptions {
retries?: number; // Number of retries to attempt if the go callback is unsuccessful.
attemptTimeoutMs?: number; // The timeout for each attempt.
totalTimeoutMs?: number; // The maximum timeout for all attempts and delays. No more retries are performed after this timeout.
delay?: StaticDelayOptions | RandomDelayOptions; // Type of the delay before each attempt. There is no delay before the first request.
}
export class GoWrappedError extends Error {
constructor(public reason: unknown) {
super('' + reason);
}
}
// NOTE: This needs to be written using 'function' syntax (cannot be arrow function)

@@ -43,3 +59,3 @@ // See: https://github.com/microsoft/TypeScript/issues/34523#issuecomment-542978853

if (err instanceof Error) return fail(err);
return fail(new Error('' + err));
return fail(new GoWrappedError(err));
};

@@ -55,58 +71,73 @@

const retryFnWrapper = async <T, E extends Error>(
fn: Promise<T> | (() => Promise<T>),
attemptOptions: AttemptOptions<T>
const getRandomInRange = (min: number, max: number) => {
return Math.random() * (max - min) + min;
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const timeout = (ms: number) => new Promise((_, reject) => setTimeout(() => reject('Operation timed out'), ms));
const attempt = async <T, E extends Error>(
fn: () => Promise<T>,
attemptTimeoutMs?: number
): Promise<GoResult<T, E>> => {
if (typeof fn === 'function') {
return retryFn(fn, attemptOptions);
// We need try/catch because `fn` might throw sync errors as well
try {
if (attemptTimeoutMs === undefined) return success(await fn());
else {
return success(await Promise.race([fn(), timeout(attemptTimeoutMs) as Promise<T>]));
}
} catch (err) {
return createGoError(err);
}
return retryFn(() => fn, attemptOptions);
};
const retryFn = async <T, E extends Error>(
export const go = async <T, E extends Error>(
fn: () => Promise<T>,
attemptOptions: AttemptOptions<T>
): Promise<GoResult<T, E>> =>
retry(fn, attemptOptions)
.then(success)
.catch((err) => createGoError(err));
export const go = async <T, E extends Error>(
fn: Promise<T> | (() => Promise<T>),
options?: PromiseOptions
options?: GoAsyncOptions
): Promise<GoResult<T, E>> => {
const attemptOptions: AttemptOptions<any> = {
delay: options?.retryDelayMs || 200,
maxAttempts: (options?.retries || 0) + 1,
initialDelay: 0,
minDelay: 0,
maxDelay: 0,
factor: 0,
timeout: options?.timeoutMs || 0,
jitter: false,
handleError: null,
handleTimeout: options?.timeoutMs
? async (context, options) => {
if (context.attemptsRemaining > 0) {
const res = await retryFnWrapper(fn, { ...options, maxAttempts: context.attemptsRemaining });
if (!options) return attempt(fn);
if (res.success) {
return res.data;
} else {
throw res.error;
}
const { retries, attemptTimeoutMs, delay, totalTimeoutMs } = options;
let fullTimeoutExceeded = false;
let fullTimeoutPromise = new Promise((_resolve) => {}); // Never resolves
if (totalTimeoutMs !== undefined) {
// Start a "full" timeout that will stop all retries after it is exceeded
fullTimeoutPromise = sleep(totalTimeoutMs).then(() => {
fullTimeoutExceeded = true;
return fail(new Error('Full timeout exceeded'));
});
}
const makeAttempts = async () => {
const attempts = retries ? retries + 1 : 1;
let lastFailedAttemptResult: GoResultError<E> | null = null;
for (let i = 0; i < attempts; i++) {
// This is guaranteed to be false for the first attempt
if (fullTimeoutExceeded) break;
const goRes = await attempt<T, E>(fn, attemptTimeoutMs);
if (goRes.success) return goRes;
lastFailedAttemptResult = goRes;
if (delay) {
switch (delay.type) {
case 'random': {
const { minDelayMs, maxDelayMs } = delay;
await sleep(getRandomInRange(minDelayMs, maxDelayMs));
break;
}
throw new Error(`Operation timed out`);
case 'static': {
const { delayMs } = delay;
await sleep(delayMs);
break;
}
}
: null,
beforeAttempt: null,
calculateDelay: null,
}
}
return lastFailedAttemptResult!;
};
// We need try/catch because `fn` might throw sync errors as well
try {
return retryFnWrapper(fn, attemptOptions);
} catch (err) {
return createGoError(err);
}
return Promise.race([makeAttempts(), fullTimeoutPromise]) as Promise<GoResult<T, E>>;
};

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc