Skip to content

Commit

Permalink
🗞️ Retry helpers (#330)
Browse files Browse the repository at this point in the history
  • Loading branch information
janjakubnanista authored Feb 2, 2024
1 parent 77f576e commit 4258ef3
Show file tree
Hide file tree
Showing 5 changed files with 294 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/witty-elephants-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@layerzerolabs/devtools": patch
---

Add retry helpers
3 changes: 3 additions & 0 deletions packages/devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
129 changes: 129 additions & 0 deletions packages/devtools/src/common/promise.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -72,3 +73,131 @@ export const firstFactory =
<TInput extends unknown[], TOutput>(...factories: Factory<TInput, TOutput>[]): Factory<TInput, TOutput> =>
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<void> => { ... }
*
* // 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<TInput extends unknown[]> = 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<void> => { ... }
*
* // 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<TInput>} [strategy] `RetryStrategy` to use. Defaults to a simple strategy that retries three times
* @returns {<TOutput>(task: Factory<TInput, TOutput>) => Factory<TInput, TOutput>}
*/
export const createRetryFactory =
<TInput extends unknown[]>(strategy: RetryStrategy<TInput> = createSimpleRetryStrategy(3)) =>
<TOutput>(task: Factory<TInput, TOutput>): Factory<TInput, TOutput> =>
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<TInput>} [wrappedStrategy] Strategy to use if the number of attempts has not been reached yet
* @returns {RetryStrategy<TInput>}
*/
export const createSimpleRetryStrategy = <TInput extends unknown[]>(
numAttempts: number,
wrappedStrategy?: RetryStrategy<TInput>
): RetryStrategy<TInput> => {
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)
}
}
149 changes: 148 additions & 1 deletion packages/devtools/test/common/promise.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="jest-extended" />

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()
Expand Down Expand Up @@ -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')
})
})
})
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

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

0 comments on commit 4258ef3

Please sign in to comment.