From ad9249fb116bd68465a66152775f5503e966ba26 Mon Sep 17 00:00:00 2001 From: Jordy Cabannes Date: Tue, 18 Jul 2023 19:46:11 +0200 Subject: [PATCH] feat: promise with retry strategy package --- packages/retry-promise/README.md | 62 ++++++ packages/retry-promise/jest.config.js | 15 ++ packages/retry-promise/package.json | 45 ++++ packages/retry-promise/rollup.config.js | 3 + packages/retry-promise/src/index.test.ts | 271 +++++++++++++++++++++++ packages/retry-promise/src/index.ts | 69 ++++++ packages/retry-promise/tsconfig.json | 7 + 7 files changed, 472 insertions(+) create mode 100644 packages/retry-promise/README.md create mode 100644 packages/retry-promise/jest.config.js create mode 100644 packages/retry-promise/package.json create mode 100644 packages/retry-promise/rollup.config.js create mode 100644 packages/retry-promise/src/index.test.ts create mode 100644 packages/retry-promise/src/index.ts create mode 100644 packages/retry-promise/tsconfig.json diff --git a/packages/retry-promise/README.md b/packages/retry-promise/README.md new file mode 100644 index 00000000..e16ab820 --- /dev/null +++ b/packages/retry-promise/README.md @@ -0,0 +1,62 @@ +# @twake/retry-promise + +Simple module extending javascript Promise with retry strategy + +## Synopsis + +```js +import RetryPromise from '@twake/retry-promise' + +const promise = new RetryPromise((resolve, reject) => { + fetch(URL) + .then(val => resolve()) + .catch(reject) +}) + +const promiseWithOption = new RetryPromise((resolve, reject) => { + fetch(URL) + .then(val => resolve()) + .catch(reject) + },{ + retries: 6 + minTimeout: 10 // The number of milliseconds before starting the first retry + maxTimeout: 100 // The maximum number of milliseconds between two retries + } +) + +const allPromises = RetryPromise.all([ + new RetryPromise((resolve, reject) => { + fetch(URL_1) + .then(val => resolve()) + .catch(reject) + }), + new RetryPromise((resolve, reject) => { + fetch(URL_2) + .then(val => resolve()) + .catch(reject) + }) + ]) + +const allSettledtPromises = RetryPromise.allSettled([ + new RetryPromise((resolve, reject) => { + fetch(URL_1) + .then(val => resolve()) + .catch(reject) + }), + new RetryPromise((resolve, reject) => { + fetch(URL_2) + .then(val => resolve()) + .catch(reject) + }) + ]) +``` + +## How it works + +`RetryPromise` allows to build promises that can be re-triggered 3 times or a custom number of times. We can also redefine `minTimeout` option and `maxTimeout` option values, by default it is 100 ms and 1000 ms. Finally, `all` and `allSettled` are very similar to the native Promise methods, but they do not accept Promises instances as parameters. + +## Copyright and license + +Copyright (c) 2023-present Linagora + +License: [GNU AFFERO GENERAL PUBLIC LICENSE](https://ci.linagora.com/publicgroup/oss/twake/tom-server/-/blob/master/LICENSE) diff --git a/packages/retry-promise/jest.config.js b/packages/retry-promise/jest.config.js new file mode 100644 index 00000000..c8962710 --- /dev/null +++ b/packages/retry-promise/jest.config.js @@ -0,0 +1,15 @@ +export default { + testTimeout: 10000, + testEnvironment: 'node', + preset: 'ts-jest', + collectCoverage: true, + collectCoverageFrom: ['./src/**/*.ts'], + coverageThreshold: { + global: { + branches: 80, + functions: 50, + lines: 90, + statements: 90 + } + }, +} diff --git a/packages/retry-promise/package.json b/packages/retry-promise/package.json new file mode 100644 index 00000000..05d42fe4 --- /dev/null +++ b/packages/retry-promise/package.json @@ -0,0 +1,45 @@ +{ + "name": "@twake/retry-promise", + "version": "0.0.1", + "description": "Promise with retry strategy module", + "main": "dist/index.js", + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + "import": "./dist/index.js" + }, + "scripts": { + "build": "rollup -c", + "test": "jest" + }, + "repository": { + "type": "git", + "url": "https://ci.linagora.com/publicgroup/oss/twake/tom-server.git" + }, + "files": [ + "package.json", + "dist", + "*.md" + ], + "keywords": [ + "matrix", + "twake" + ], + "author": [ + { + "name": "Jordy Cabannes", + "email": "jcabannes@linagora.com" + } + ], + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://ci.linagora.com/publicgroup/oss/twake/tom-server/-/issues" + }, + "homepage": "https://ci.linagora.com/publicgroup/oss/twake/tom-server", + "dependencies": { + "retry": "^0.13.1" + }, + "devDependencies": { + "@types/retry": "^0.12.2" + } +} diff --git a/packages/retry-promise/rollup.config.js b/packages/retry-promise/rollup.config.js new file mode 100644 index 00000000..7bdd9f5b --- /dev/null +++ b/packages/retry-promise/rollup.config.js @@ -0,0 +1,3 @@ +import config from '../../rollup-template.js' + +export default config([]) diff --git a/packages/retry-promise/src/index.test.ts b/packages/retry-promise/src/index.test.ts new file mode 100644 index 00000000..a893a610 --- /dev/null +++ b/packages/retry-promise/src/index.test.ts @@ -0,0 +1,271 @@ +import RetryPromise from '.' + +describe('Retry promise', () => { + const promiseTest = { + default: async () => await Promise.resolve(1) + } + + const promiseError = new Error('error on resolving promise') + + describe('Constructor', () => { + test('should retry the promise three times on create', async () => { + jest + .spyOn(promiseTest, 'default') + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockResolvedValueOnce(1) + + await expect( + new RetryPromise((resolve, reject) => { + promiseTest + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }) + ).resolves.toEqual(1) + }) + + test('should retry the promise a custom number of times on create', async () => { + jest + .spyOn(promiseTest, 'default') + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockResolvedValueOnce(1) + + await expect( + new RetryPromise( + (resolve, reject) => { + promiseTest + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }, + { retries: 6 } + ) + ).resolves.toEqual(1) + }) + + test('should reject the promise if it fails four times', async () => { + jest + .spyOn(promiseTest, 'default') + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockResolvedValueOnce(1) + + await expect( + new RetryPromise((resolve, reject) => { + promiseTest + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }) + ).rejects.toEqual(promiseError) + }) + }) + + describe('Static methods', () => { + const promiseTest1 = { + default: async () => await Promise.resolve(1) + } + + const promiseTest2 = { + default: async () => await Promise.resolve(2) + } + + describe('All method', () => { + test('should retry the rejected promise three times', async () => { + jest + .spyOn(promiseTest2, 'default') + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockResolvedValueOnce(2) + + await expect( + RetryPromise.all([ + new RetryPromise((resolve, reject) => { + promiseTest1 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }), + new RetryPromise((resolve, reject) => { + promiseTest2 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }) + ]) + ).resolves.toEqual([1, 2]) + }) + + test('should reject if one promise fails three times', async () => { + jest + .spyOn(promiseTest2, 'default') + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockResolvedValueOnce(2) + + await expect( + RetryPromise.all([ + new RetryPromise((resolve, reject) => { + promiseTest1 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }), + new RetryPromise((resolve, reject) => { + promiseTest2 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }) + ]) + ).rejects.toEqual(promiseError) + }) + + test('should reject if one parameter is a promise', async () => { + // eslint-disable-next-line @typescript-eslint/promise-function-async + expect(() => + RetryPromise.all([ + new RetryPromise((resolve, reject) => { + promiseTest1 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }), + new RetryPromise((resolve, reject) => { + promiseTest2 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }), + Promise.resolve(2) + ]) + ).toThrowError('Cannot pass an instance of Promise to this method') + }) + }) + + describe('AllSettled method', () => { + test('should retry the reject promise three times', async () => { + jest + .spyOn(promiseTest2, 'default') + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockResolvedValueOnce(2) + + await expect( + RetryPromise.allSettled([ + new RetryPromise((resolve, reject) => { + promiseTest1 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }), + new RetryPromise((resolve, reject) => { + promiseTest2 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }) + ]) + ).resolves.toEqual([ + { status: 'fulfilled', value: 1 }, + { status: 'fulfilled', value: 2 } + ]) + }) + + test('should resolve if one promise fails three times', async () => { + jest + .spyOn(promiseTest2, 'default') + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockRejectedValueOnce(promiseError) + .mockResolvedValueOnce(2) + + await expect( + RetryPromise.allSettled([ + new RetryPromise((resolve, reject) => { + promiseTest1 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }), + new RetryPromise((resolve, reject) => { + promiseTest2 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }) + ]) + ).resolves.toEqual([ + { status: 'fulfilled', value: 1 }, + { status: 'rejected', reason: promiseError } + ]) + }) + + test('should reject if one parameter is a promise', async () => { + expect( + // eslint-disable-next-line @typescript-eslint/promise-function-async + () => + RetryPromise.allSettled([ + new RetryPromise((resolve, reject) => { + promiseTest1 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }), + new RetryPromise((resolve, reject) => { + promiseTest2 + .default() + .then((value) => { + resolve(value) + }) + .catch(reject) + }), + Promise.resolve(1) + ]) + ).toThrowError('Cannot pass an instance of Promise to this method') + }) + }) + }) +}) diff --git a/packages/retry-promise/src/index.ts b/packages/retry-promise/src/index.ts new file mode 100644 index 00000000..808c6dcb --- /dev/null +++ b/packages/retry-promise/src/index.ts @@ -0,0 +1,69 @@ +import { operation } from 'retry' + +interface IRetryOptions { + retries?: number + minTimeout?: number + maxTimeout?: number +} + +class RetryPromise extends Promise { + static readonly defaultOptions = { + retries: 3, + minTimeout: 100, + maxTimeout: 1000 + } + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void + ) => void, + options: IRetryOptions = {} + ) { + const constructorOptions = { ...RetryPromise.defaultOptions, ...options } + const op = operation(constructorOptions) + super((resolve, reject) => { + op.attempt(() => { + executor(resolve, (e) => { + // console.log(e) + if (!op.retry(e)) { + reject(op.mainError()) + } + }) + }) + }) + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/class-literal-property-style + static get [Symbol.species]() { + return Promise + } + + // eslint-disable-next-line @typescript-eslint/promise-function-async + static all( + promises: Iterable> + ): Promise>> { + const argsContainPromises = [...promises].some( + (item) => item instanceof Promise && !(item instanceof RetryPromise) + ) + if (argsContainPromises) { + throw new Error('Cannot pass an instance of Promise to this method') + } + return Promise.all(promises) + } + + // eslint-disable-next-line @typescript-eslint/promise-function-async + static allSettled( + promises: Iterable> + ): Promise>>> { + const argsContainPromises = [...promises].some( + (item) => item instanceof Promise && !(item instanceof RetryPromise) + ) + if (argsContainPromises) { + throw new Error('Cannot pass an instance of Promise to this method') + } + return Promise.allSettled(promises) + } +} + +export default RetryPromise diff --git a/packages/retry-promise/tsconfig.json b/packages/retry-promise/tsconfig.json new file mode 100644 index 00000000..5ddb593d --- /dev/null +++ b/packages/retry-promise/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig-build.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"] +}