ttous
ttous

Reputation: 406

How to retry a Promise resolution N times, with a delay between the attempts?

I want some JavaScript code to take 3 things as parameters:

What I ended up doing is using a for loop. I did not want to use a recursive function : this way, even if there are 50 attempts the call stack isn't 50 lines longer.

Here is the typescript version of the code:

/**
 * @async
 * @function tryNTimes<T> Tries to resolve a {@link Promise<T>} N times, with a delay between each attempt.
 * @param {Object} options Options for the attempts.
 * @param {() => Promise<T>} options.toTry The {@link Promise<T>} to try to resolve.
 * @param {number} [options.times=5] The maximum number of attempts (must be greater than 0).
 * @param {number} [options.interval=1] The interval of time between each attempt in seconds.
 * @returns {Promise<T>} The resolution of the {@link Promise<T>}.
 */
export async function tryNTimes<T>(
    {
        toTry,
        times = 5,
        interval = 1,
    }:
        {
            toTry: () => Promise<T>,
            times?: number,
            interval?: number,
        }
): Promise<T> {
    if (times < 1) throw new Error(`Bad argument: 'times' must be greater than 0, but ${times} was received.`);
    let attemptCount: number;
    for (attemptCount = 1; attemptCount <= times; attemptCount++) {
        let error: boolean = false;
        const result = await toTry().catch((reason) => {
            error = true;
            return reason;
        });

        if (error) {
            if (attemptCount < times) await delay(interval);
            else return Promise.reject(result);
        }
        else return result;
    }
}

The delay function used above is a promisified timeout:

/**
 * @function delay Delays the execution of an action.
 * @param {number} time The time to wait in seconds.
 * @returns {Promise<void>}
 */
export function delay(time: number): Promise<void> {
    return new Promise<void>((resolve) => setTimeout(resolve, time * 1000));
}

To clarify: the code above works, I'm only wondering if this is a "good" way of doing it, and if not, how I could improve it.

Any suggestion? Thanks in advance for your help.

Upvotes: 7

Views: 14030

Answers (6)

ajimae
ajimae

Reputation: 107

Check this pure javascript async-retry library out.

Example

const { retry } = require('@ajimae/retry')

function exec() {
  // This will be any async or sync action that needs to be retried.
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ message: 'some async data' })
    }, 1500)
  })
}

// takes the response from the exec function and check if the condition/conditions are met
function predicate(response, retryCount) => {
  console.log(retryCount) // goes from 0 to maxRetries 

  // once this condition is met the retry exits
  return (response == 200)
}

(async function main() {
  // enable or disable an exponential backoff behaviour if needed.
  const result = await retry(exec, predicate, { maxRetries: 5, backoff: true })
  console.log(result) // { message: 'some async data' } 
})()

PS: I authored this library.

Upvotes: 0

Nicolas Hervy
Nicolas Hervy

Reputation: 31

Here is some code that works.

Helper

interface RetryProps {
  attempts?: number
  delay: number
  fn: () => boolean
  maxAttempts: number
}

function retry({ fn, maxAttempts = 1, delay = 1000, attempts = 5 }: RetryProps) {
  return new Promise((resolve, reject) => {
    if (fn()) resolve(true)
    else {
      if (attempts < maxAttempts) {
        setTimeout(
          () =>
            retry({ fn, maxAttempts, delay, attempts: attempts + 1 })
              .then(() => resolve(true))
              .catch((err) => reject(err)),
          delay
        )
      } else reject('Could not resolve function.')
    }
  })
}

Then pass it a function that returns true when successful.

Usage example

retry({
  fn: function () {
    // Whatever you want to test, return true upon success.
    const elExists = !document.getElementById('myRandomELement')
    console.log('Element present in DOM?', elExists)
    return elExists
  },
  maxAttempts: 4,
  delay: 2000,
})
  .then(() => console.log('Done'))
  .catch(() => console.log("Didn't pan out"))

Since it returns a promise you can await it or use then/catch to settle it.

Upvotes: 0

Pratyush
Pratyush

Reputation: 71

Using recursive functions with Promises won't be an issue with the callstack as the Promise is returned instantly and the then or catch function will be called after an asynchronous event.

A simple javascript function would be like:

function wait (ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}

function retry (fn, maxAttempts = 1, delay = 0, attempts = 0) {
  return Promise.resolve()
    .then(fn)
    .catch(err => {
      if (attempts < maxAttempts) {
        return retry (fn, maxAttempts, delay, attempts + 1)
      }
      throw err
    })
}

Upvotes: 2

Bergi
Bergi

Reputation: 664297

I did not want to use a recursive function: this way, even if there are 50 attempts the call stack isn't 50 lines longer.

That's not a good excuse. The call stack doesn't overflow from asynchronous calls, and when a recursive solution is more intuitive than an iterative one you should probably go for it.

What I ended up doing is using a for loop. Is this a "good" way of doing it, and if not, how I could improve it?

The for loop is fine. It's a bit weird that it starts at 1 though, 0-based loops are much more idiomatic.

What is not fine however is your weird error handling. That boolean error flag should have no place in your code. Using .catch() is fine, but try/catch would work just as well and should be preferred.

export async function tryNTimes<T>({ toTry, times = 5, interval = 1}) {
    if (times < 1) throw new Error(`Bad argument: 'times' must be greater than 0, but ${times} was received.`);
    let attemptCount = 0
    while (true) {
        try {
            const result = await toTry();
            return result;
        } catch(error) {
            if (++attemptCount >= times) throw error;
        }
        await delay(interval)
    }
}

Upvotes: 5

Golo Roden
Golo Roden

Reputation: 150624

You might want to have a look at async-retry, which does exactly what you need. This package lets you retry async operations, and you can configure (among other things) timeouts between retries (even with increasing factors), maximum number of retries, …

This way you don't have to reinvent the wheel, but can rely on a proven package that is being widely used in the community.

Upvotes: 3

Will Taylor
Will Taylor

Reputation: 1759

Have you considered RxJS?

It is excellent for implementing this sort of logic in async workflows.

Below is an example of how you would do this without breaking your public api (ie. converting from Promise to Observable and back). In practice you would probably want to use either RxJS or Promises in any given project rather than mixing them.

/**
 * @async
 * @function tryNTimes<T> Tries to resolve a {@link Promise<T>} N times, with a delay between each attempt.
 * @param {Object} options Options for the attempts.
 * @param {() => Promise<T>} options.toTry The {@link Promise<T>} to try to resolve.
 * @param {number} [options.times=5] The maximum number of attempts (must be greater than 0).
 * @param {number} [options.interval=1] The interval of time between each attempt in seconds.
 * @returns {Promise<T>} The resolution of the {@link Promise<T>}.
 */
export async function tryNTimes<T>(
    {
        toTry,
        times = 5,
        interval = 1,
    }:
        {
            toTry: () => Promise<T>,
            times?: number,
            interval?: number,
        }
): Promise<T> {
    if (times < 1) throw new Error(`Bad argument: 'times' must be greater than 0, but ${times} was received.`);
    let attemptCount: number;

    return from(toTry)
        .pipe(
            retryWhen(errors =>
                errors.pipe(
                    delay(interval * 1000),
                    take(times - 1)
                )
            )
        )
        .toPromise();
}

It may not be worth adding a whole library for this one piece of logic, but if your project involves a lot of complex async workflows such as this, then RxJS is great.

Upvotes: 0

Related Questions