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:

  1. Non-async functions are not retried:

    retry(() => {
      console.log('hi')
      throw new Error('fail')
    }, options)
    //=> logs 'hi' (initial try)
    //=> throws [Error: fail]
    
  2. 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:

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: