diff --git a/.changeset/witty-elephants-sing.md b/.changeset/witty-elephants-sing.md new file mode 100644 index 000000000..8dba1891a --- /dev/null +++ b/.changeset/witty-elephants-sing.md @@ -0,0 +1,5 @@ +--- +"@layerzerolabs/devtools": patch +--- + +Add retry helpers diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 400fb07b5..05448cae5 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -29,6 +29,9 @@ "lint": "$npm_execpath eslint '**/*.{js,ts,json}'", "test": "jest --ci --forceExit" }, + "dependencies": { + "exponential-backoff": "~3.1.1" + }, "devDependencies": { "@ethersproject/bytes": "~5.7.0", "@ethersproject/constants": "~5.7.0", diff --git a/packages/devtools/src/common/promise.ts b/packages/devtools/src/common/promise.ts index 4dda47a63..845c0c4de 100644 --- a/packages/devtools/src/common/promise.ts +++ b/packages/devtools/src/common/promise.ts @@ -1,5 +1,6 @@ import { Factory } from '@/types' import assert from 'assert' +import { backOff } from 'exponential-backoff' /** * Helper type for argumentless factories a.k.a. tasks @@ -72,3 +73,131 @@ export const firstFactory = (...factories: Factory[]): Factory => async (...input) => await first(factories.map((factory) => () => factory(...input))) + +/** + * RetryStrategy represents a function that, when passed to `createRetryFactory`, + * controls the execution of a retried function. + * + * It will be executed on every failed attempt and has the ability to modify the + * input originally passed to the retried function. + * + * In its simplest form, it will either return `true` (to retry again) or `false` (stop retrying). + * + * In its advanced form, it can use the parameters passed to it to create + * a new set of arguments passed to the function being retried: + * + * ``` + * // As a simple example let's consider a function + * // whose argument is the amount of money we want to pay for a service + * const functionThatCanFail = (money: number): Promise => { ... } + * + * // We can create a strategy that will keep adding 1 to the amount of money + * const strategy: RetryStrategy<[money: number]> = (attempt, error, [previousMoney], [originalMoney]) => [previousMoney + 1] + * + * // Or we can create a strategy that will adjust the money based on the initial value + * // + * // In this made up case it will take the original amount and will add 2 for every failed attempt + * const strategy: RetryStrategy<[money: number]> = (attempt, error, [previousMoney], [originalMoney]) => [originalMoney + attempt * 2] + * + * // Or we can go insane with our logic and can, because without objective morality + * // everything is permissible, update the amount on every other attempt + * const strategy: RetryStrategy<[money: number]> = (attempt, error, [previousMoney], [originalMoney]) => attempt % 2 ? [previousMoney + 1] : true + * ``` + * + * @param {number} attempt The 0-indexed attempt that the retry function is performing + * @param {unknown} error The error thrown from the previous execution of the retried function + * @param {TInput} previousInput The input passed to the previous execution of the retried function + * @param {TInput} originalInput The input passed to the first execution of the retried function + */ +type RetryStrategy = Factory< + [attempt: number, error: unknown, previousInput: TInput, originalInput: TInput], + TInput | boolean +> + +/** + * Uses the retry strategy to create a function that can wrap any function with retry logic. + * + * ``` + * // As a simple example let's consider a function + * // whose argument is the amount of money we want to pay for a service + * const functionThatCanFail = (money: number): Promise => { ... } + * + * // By default, it will use a three-times-and-fail retry strategy + * const retry = createRetryFactory() + * + * // It can wrap any function (sync or async) that can throw or reject + * const retriedFunctionThatCanFail = retry(functionThatCanFail) + * + * // The function can then be called just like the original, wrapped function + * retriedFunctionThatCanFail(1_000_000) + * + * // For advanced cases, you can use your own strategy + * const strategy: RetryStrategy<[money: number]> = () => { ... } + * const retry = createRetryFactory(strategy) + * ``` + * + * @see {@link createSimpleRetryStrategy} + * @see {@link RetryStrategy} + * + * @param {RetryStrategy} [strategy] `RetryStrategy` to use. Defaults to a simple strategy that retries three times + * @returns {(task: Factory) => Factory} + */ +export const createRetryFactory = + (strategy: RetryStrategy = createSimpleRetryStrategy(3)) => + (task: Factory): Factory => + async (...input) => { + // We'll store the last used input in this variable + let currentInput = input + + return backOff(async () => task(...currentInput), { + // We'll effectively disable the numOfAttempts for exponential backoff + // since we want the behavior to be completely controlled by the strategy + numOfAttempts: Number.POSITIVE_INFINITY, + // The retry callback is called after an unsuccessful attemp + // + // It allows us to decide whether we want to keep trying or give up + // (we can give up by returning false) + // + // We'll use this callback to allow the strategy to effectively make changes + // to the input, thus allowing it to accommodate for things such as gas price increase + // for transactions + async retry(error, attempt) { + // We will evaluate the strategy first + const strategyOutput = await strategy(attempt, error, currentInput, input) + + // The strategy can simply return true/false, in which case we'll not be adjusting the input at all + if (typeof strategyOutput === 'boolean') return strategyOutput + + // If we got an input back, we'll adjust it and keep trying + return (currentInput = strategyOutput), true + }, + }) + } + +/** + * Creates a simple `RetryStrategy` that will retry N times. + * + * If you want to compose this strategy, you can pass `wrappedStrategy`: + * + * ``` + * const myVeryAdvancedStrategy: RetryStrategy<[string, number]> = () => { ... } + * const myVeryAdvancedStrategyThatWillRetryThreeTimesOnly = createSimpleRetryStrategy(3, myVeryAdvancedStrategy) + * ``` + * + * @param {number} numAttempts Must be larger than 0 + * @param {RetryStrategy} [wrappedStrategy] Strategy to use if the number of attempts has not been reached yet + * @returns {RetryStrategy} + */ +export const createSimpleRetryStrategy = ( + numAttempts: number, + wrappedStrategy?: RetryStrategy +): RetryStrategy => { + assert(numAttempts > 0, `Number of attempts for a strategy must be larger than 0`) + + return (attempt, error, previousInput, originalInput) => { + if (attempt > numAttempts) return false + if (wrappedStrategy == null) return true + + return wrappedStrategy(attempt, error, previousInput, originalInput) + } +} diff --git a/packages/devtools/test/common/promise.test.ts b/packages/devtools/test/common/promise.test.ts index 8f958507f..c96805f35 100644 --- a/packages/devtools/test/common/promise.test.ts +++ b/packages/devtools/test/common/promise.test.ts @@ -1,7 +1,7 @@ /// import fc from 'fast-check' -import { first, firstFactory, sequence } from '@/common/promise' +import { createSimpleRetryStrategy, createRetryFactory, first, firstFactory, sequence } from '@/common/promise' describe('common/promise', () => { const valueArbitrary = fc.anything() @@ -191,4 +191,151 @@ describe('common/promise', () => { ) }) }) + + describe('createSimpleRetryStrategy', () => { + describe('if numAttempts is lower than 1', () => { + const numAttemptsArbitrary = fc.integer({ max: 0 }) + + it('should throw', () => { + fc.assert( + fc.property(numAttemptsArbitrary, (numAttempts) => { + expect(() => createSimpleRetryStrategy(numAttempts)).toThrow() + }) + ) + }) + }) + + describe('if numAttempts is greater or equal than 1', () => { + const numAttemptsArbitrary = fc.integer({ min: 1, max: 20 }) + + describe('without wrapped strategy', () => { + it('should return a function that returns true until numAttempts is reached', () => { + fc.assert( + fc.property(numAttemptsArbitrary, (numAttempts) => { + const strategy = createSimpleRetryStrategy(numAttempts) + + // The first N attempts should return true since we want to retry them + for (let attempt = 1; attempt <= numAttempts; attempt++) { + expect(strategy(attempt, 'error', [], [])).toBeTruthy() + } + + // The N+1th attempt should return false + expect(strategy(numAttempts + 1, 'error', [], [])).toBeFalsy() + }) + ) + }) + }) + + describe('with wrapped strategy', () => { + it('should return false if the amount of attempts has been reached, the wrapped strategy value otherwise', () => { + fc.assert( + fc.property(numAttemptsArbitrary, (numAttempts) => { + // We'll create a simple wrapped strategy + const wrappedStrategy = (attempt: number) => [attempt] + const strategy = createSimpleRetryStrategy(numAttempts, wrappedStrategy) + + // The first N attempts should return the return value of the wrapped strategy + for (let attempt = 1; attempt <= numAttempts; attempt++) { + expect(strategy(attempt, 'error', [0], [0])).toEqual(wrappedStrategy(attempt)) + } + + // The N+1th attempt should return false + expect(strategy(numAttempts + 1, 'error', [0], [0])).toBeFalsy() + }) + ) + }) + }) + }) + }) + + describe('createRetryFactory', () => { + it('should retry three times by default', async () => { + const failingFunction = jest + .fn() + .mockRejectedValueOnce('fail 1') + .mockRejectedValueOnce('fail 2') + .mockRejectedValueOnce('fail 3') + .mockResolvedValue('success') + + const retryFailingFunction = createRetryFactory()(failingFunction) + + await expect(retryFailingFunction()).resolves.toBe('success') + }) + + it('should not adjust the input if the strategy returns a boolean', async () => { + const strategy = jest.fn().mockReturnValueOnce(true).mockReturnValueOnce(false) + const failingFunction = jest.fn().mockRejectedValueOnce('fail').mockResolvedValue('success') + + const retryFailingFunction = createRetryFactory(strategy)(failingFunction) + + await expect(retryFailingFunction('some', 'input')).resolves.toBe('success') + + expect(failingFunction).toHaveBeenCalledTimes(2) + expect(failingFunction).toHaveBeenNthCalledWith(1, 'some', 'input') + expect(failingFunction).toHaveBeenNthCalledWith(2, 'some', 'input') + }) + + it('should adjust the input if the strategy returns a new input', async () => { + const strategy = jest + .fn() + .mockReturnValueOnce(['other', 'input']) + .mockReturnValueOnce(['different', 'input']) + + const failingFunction = jest + .fn() + .mockRejectedValueOnce('fail') + .mockRejectedValueOnce('fail') + .mockResolvedValue('success') + + const retryFailingFunction = createRetryFactory(strategy)(failingFunction) + + await expect(retryFailingFunction('some', 'input')).resolves.toBe('success') + + expect(failingFunction).toHaveBeenCalledTimes(3) + expect(failingFunction).toHaveBeenNthCalledWith(1, 'some', 'input') + expect(failingFunction).toHaveBeenNthCalledWith(2, 'other', 'input') + expect(failingFunction).toHaveBeenNthCalledWith(3, 'different', 'input') + + // Now we check that the strategy has been called correctly + expect(strategy).toHaveBeenCalledTimes(2) + // On the first call the original input and the adjusted input should be the same + expect(strategy).toHaveBeenNthCalledWith(1, 1, 'fail', ['some', 'input'], ['some', 'input']) + // On the second call the original inout should stay the same and previous input should have been adjusted + expect(strategy).toHaveBeenNthCalledWith(2, 2, 'fail', ['other', 'input'], ['some', 'input']) + }) + + it('should use the last updated input if strategy returns boolean after adjusting input', async () => { + // In this case we'll create a super weird strategy + // that only updates the input every now and then + const strategy = jest + .fn() + // It will update the inputs on the first call + .mockReturnValueOnce(['other', 'input']) + // Then it will return true to mark that we should keep retrying + .mockReturnValueOnce(true) + // Then it will update the input again + .mockReturnValueOnce(['different', 'input']) + + const failingFunction = jest + .fn() + .mockRejectedValueOnce('fail') + .mockRejectedValueOnce('fail') + .mockRejectedValueOnce('fail') + .mockResolvedValue('success') + + const retryFailingFunction = createRetryFactory(strategy)(failingFunction) + + await expect(retryFailingFunction('some', 'input')).resolves.toBe('success') + + expect(failingFunction).toHaveBeenCalledTimes(4) + // First the failing function should have been called with the original input + expect(failingFunction).toHaveBeenNthCalledWith(1, 'some', 'input') + // On the second attempt the input should have been updated + expect(failingFunction).toHaveBeenNthCalledWith(2, 'other', 'input') + // On the third attempt the strategy just returned true so the output will be reused + expect(failingFunction).toHaveBeenNthCalledWith(3, 'other', 'input') + // On the fourth attempt the strategy updated the input again + expect(failingFunction).toHaveBeenNthCalledWith(4, 'different', 'input') + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a46ce0fe..0c1642b84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -390,6 +390,10 @@ importers: version: 4.0.0 packages/devtools: + dependencies: + exponential-backoff: + specifier: ~3.1.1 + version: 3.1.1 devDependencies: '@ethersproject/bytes': specifier: ~5.7.0 @@ -6526,6 +6530,7 @@ packages: dependencies: is-hex-prefixed: 1.0.0 strip-hex-prefix: 1.0.0 + bundledDependencies: false /eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -6586,6 +6591,10 @@ packages: jest-util: 29.7.0 dev: true + /exponential-backoff@3.1.1: + resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} + dev: false + /extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} dev: true