Automatically retrying a failing function in JS/TS
Published on in JavaScript and TypeScript
Last updated on
A little helper function to retry a function automatically a limited number of times (or until success) and with a certain delay between retries. Useful e.g. when working with a flaky API.
Table of contents
Starting point
After a bit of googling, I found Jaromanda X's answer on Stack Overflow to be a good starting point:
Using a couple of helper functions I've used a lot, this becomes very easy
The "helpers"
Promise.wait = (time) => new Promise(resolve => setTimeout(resolve, time || 0)); Promise.retry = (cont, fn, delay) => fn().catch(err => cont > 0 ? Promise.wait(delay).then(() => Promise.retry(cont - 1, fn, delay)) : Promise.reject('failed'));
The code:
function myMainFuntion() { var delay = 100; var tries = 3; Promise.retry(tries, tryAsync, delay); }
Works well, but let's do better since the code wasn't immediately clear for me.
Refactoring step by step
Let's do an unsolicited code review and refactor the code along the way. You can also jump straight to the final result.
The general rule of code reviewing seems to apply here: the shorter the code, the more it receives comments.
Step 1: Prettier
The lines declaring the two helpers are very long: 80 and 161 characters.
Too long lines are difficult to read.
Especially the retry
method in this case.
Let's format the code with Prettier:
Promise.wait = (time) =>
new Promise((resolve) => setTimeout(resolve, time || 0))
Promise.retry = (cont, fn, delay) =>
fn().catch((err) =>
cont > 0
? Promise.wait(delay).then(() => Promise.retry(cont - 1, fn, delay))
: Promise.reject('failed')
)
Now the structure of the code is much easier to see.
Looks like Promise.retry
is a recursive function.
Step 2: no monkey patching
Modifying global prototypes is generally not a good idea,
so instead of adding the methods to the global Promise
object,
let's convert the methods to standalone functions.
Diff:
-Promise.wait = (time) =>
+const wait = (time) =>
new Promise((resolve) => setTimeout(resolve, time || 0))
-Promise.retry = (cont, fn, delay) =>
+export const retry = (cont, fn, delay) =>
fn().catch((err) =>
cont > 0
- ? Promise.wait(delay).then(() => Promise.retry(cont - 1, fn, delay))
+ ? wait(delay).then(() => retry(cont - 1, fn, delay))
: Promise.reject('failed')
)
Result (formatted with Prettier; wait
function moved to the end):
export const retry = (cont, fn, delay) =>
fn().catch((err) =>
cont > 0
? wait(delay).then(() => retry(cont - 1, fn, delay))
: Promise.reject('failed')
)
const wait = (time) => new Promise((resolve) => setTimeout(resolve, time || 0))
Step 3: what's "cont"?
What is the cont
parameter for –
what does "cont" mean?
I guess it means something like "continue/retry if this is greater than 0." In other words, it's the number of remaining retries.
Let's rename cont
to retries
:
-export const retry = (cont, fn, delay) =>
+export const retry = (retries, fn, delay) =>
// ...
Step 4: order of parameters
Is the order of the parameters ideal?
Now the function reads like
"retry
with x retries
the given fn
with a delay
of y milliseconds."
Sounds clumsy.
If the first two parameters are swapped:
-export const retry = (retries, fn, delay) => {}
+export const retry = (fn, retries, delay) => {}
...the function reads like
"retry
the given fn
with x retries
and a delay
of y milliseconds."
I think that's better –
the fn
parameter is the "main" parameter,
and the other two parameters are configurable options,
so it makes sense to pass the function as the first parameter.
Step 5: options parameter
Let's continue with the parameters.
When calling retry
, the purpose of the two option parameters is not clear:
retry(myFn, 2, 200) // What 2? What 200?
Condensing the options into a single object makes the call site clearer:
-export const retry = (fn, retries, delay) => {}
+export const retry = (fn, { retries, delay }) => {}
// Usage:
-retry(myFn, 2, 200) // huh?
+retry(myFn, { retries: 2, delay: 200 }) // oh
Step 6: delay parameter
Is the delay
option clear?
What does it delay – only the retries or also the initial function call?
Let's rename delay
to retryInterval
to make it clear that it's only about the retries,
not the initial function call.
Actually, let's use retryIntervalMs
to also clarify that the unit is milliseconds:
-export const retry = (fn, { retries, delay }) => {}
+export const retry = (fn, { retries, retryIntervalMs }) => {}
// Usage:
-retry(myFn, { retries: 2, delay: 200 })
+retry(myFn, { retries: 2, retryIntervalMs: 200 })
Another benefit: now the two options are sorted alphabetically. :D
Similarly, let's rename the wait
function's time
parameter to ms
:
-const wait = (time) => new Promise((resolve) => setTimeout(resolve, time || 0))
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms || 0))
Step 7: function name
More bike-shedding:
does the name retry
tell clearly what the function does?
Yes, the function retries another function – but only after the initial try, which is a try, not a re-try.
Something like tryAndRetry
or retryOnFail
or tryMultipleTimes
could be more descriptive.
Though these are quite wordy and still not great.
Apparently this whole thing is called "retry pattern" or "retry design pattern"
(more about this in the Further resources section),
so let's actually keep the retry
name.
But thinking about this was useful:
we can deduce that because the first fn
call is a try and not a retry,
the maximum number of fn
calls should be 1 + retries
.
And indeed it is.
(If the maximum number was something else,
the option name retries
could be confusing.)
Here's what we have so far:
export const retry = (fn, { retries, retryIntervalMs }) =>
fn().catch((err) =>
retries > 0
? wait(retryIntervalMs).then(() =>
retry(fn, { retries: retries - 1, retryIntervalMs })
)
: Promise.reject('failed')
)
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms || 0))
Step 8: better error
If the last retry also fails,
retry
throws the string 'failed'
.
Re-throwing the error thrown by fn
is likely more useful.
While at it, let's also rename err
to error
:
export const retry = (fn, { retries, retryIntervalMs }) =>
- fn().catch((err) =>
+ fn().catch((error) =>
retries > 0
? wait(retryIntervalMs).then(() =>
retry(fn, { retries: retries - 1, retryIntervalMs })
)
- : Promise.reject('failed')
+ : Promise.reject(error)
)
Step 9: TypeScript
export const retry = <T>(
fn: () => Promise<T>,
{ retries, retryIntervalMs }: { retries: number; retryIntervalMs: number }
): Promise<T> =>
fn().catch((error) =>
retries > 0
? wait(retryIntervalMs).then(() =>
retry(fn, { retries: retries - 1, retryIntervalMs })
)
: Promise.reject(error)
)
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms))
The generic parameter T
is used more than once in the function signature,
so this is a good use of generics;
see The Golden Rule of Generics.
Step 10: the need for async
/await
So far so good, but there's a caveat:
The provided function must be async (marked with async
or returns a Promise),
or the function won't be retried.
Async functions
Async functions work well:
const options = { retries: 2, retryIntervalMs: 200 }
retry(async () => 42, options)
//=> returns 42
retry(() => Promise.resolve(42), options)
//=> returns 42
retry(async () => {
console.log('hi')
throw new Error('fail')
}, options)
//=> logs 'hi' (initial try)
//=> logs 'hi' (1st retry)
//=> logs 'hi' (2nd retry)
//=> throws [Error: fail]
retry(() => {
console.log('hi')
return Promise.reject(new Error('fail'))
}, options)
//=> logs 'hi' (initial try)
//=> logs 'hi' (1st retry)
//=> logs 'hi' (2nd retry)
//=> throws [Error: fail]
Non-async functions
Non-async functions don't work:
-
Non-async functions are not retried:
retry(() => { console.log('hi') throw new Error('fail') }, options) //=> logs 'hi' (initial try) //=> throws [Error: fail]
-
If a non-async function succeeds, a TypeError will still be thrown:
retry(() => { console.log('hi') return 42 }, options) //=> logs 'hi' (initial try) //=> throws [TypeError: fn(...).catch is not a function]
async
/await
vs Promises
TypeScript won't allow passing a non-async function to retry
,
but I don't like that retry
breaks at runtime if it's given a non-async function.
Let's eliminate the caveat by refactoring to async
/await
.
An alternative would be to make retry
always return a Promise.
The code wouldn't be as clear in my opinion,
and there would be the extra caveat of having to avoid the pitfalls of the
explicit Promise constructor anti-pattern.
Step 11: async
/await
Diff:
-export const retry = <T>(
- fn: () => Promise<T>,
+export const retry = async <T>(
+ fn: () => Promise<T> | T,
{ retries, retryIntervalMs }: { retries: number; retryIntervalMs: number }
-): Promise<T> =>
- fn().catch((error) =>
- retries > 0
- ? wait(retryIntervalMs).then(() =>
- retry(fn, { retries: retries - 1, retryIntervalMs })
- )
- : Promise.reject(error)
- )
+): Promise<T> => {
+ try {
+ return await fn()
+ } catch (error) {
+ if (retries <= 0) {
+ throw error
+ }
+ await sleep(retryIntervalMs)
+ return retry(fn, { retries: retries - 1, retryIntervalMs })
+ }
+}
-const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms))
+const sleep = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms))
(Heh, only one non-empty line remained unchanged.)
Details:
- I renamed
wait
tosleep
becauseawait wait()
would look awkward. - This version works also with non-async functions
because the
await
operator converts non-Promise values to resolved Promises. return
vsreturn await
: "Outside of try/catch blocks,return await
is redundant."
Final result
/**
* Runs the function `fn`
* and retries automatically if it fails.
*
* Tries max `1 + retries` times
* with `retryIntervalMs` milliseconds between retries.
*
* From https://mtsknn.fi/blog/js-retry-on-fail/
*/
export const retry = async <T>(
fn: () => Promise<T> | T,
{ retries, retryIntervalMs }: { retries: number; retryIntervalMs: number }
): Promise<T> => {
try {
return await fn()
} catch (error) {
if (retries <= 0) {
throw error
}
await sleep(retryIntervalMs)
return retry(fn, { retries: retries - 1, retryIntervalMs })
}
}
const sleep = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms))
Looks quite good to me!
Usage
Happy cases:
const options = { retries: 2, retryIntervalMs: 200 }
retry(() => 42, options)
//=> 42
retry(() => Promise.resolve(42), options)
//=> 42
retry(async () => 42, options)
//=> 42
Error cases:
const options = { retries: 2, retryIntervalMs: 200 }
retry(() => {
console.log('hi')
throw new Error('fail')
}, options)
//=> logs 'hi' (initial try)
//=> logs 'hi' (1st retry)
//=> logs 'hi' (2nd retry)
//=> throws [Error: fail]
retry(() => {
console.log('hi')
return Promise.reject(new Error('fail'))
}, options)
//=> same as above
retry(async () => {
console.log('hi')
throw new Error('fail')
}, options)
//=> same as above
Further resources
This blog post was almost ready when I found Promise Retry Design Patterns on Stack Overflow. These three answers are interesting:
- Red Mercury's answer uses the exponential backoff algorithm. This led me to find p-retry, a library that also uses the exponential backoff algorithm.
- Bryan McGrane's answer is almost identical to my final result.
- Nacho Coloma's answer
is also similar, but uses a
for
loop instead of recursion. Though this version retries only if the given function returnsundefined
, not if it throws.