4

I have seen a number of questions around retrying Promises, however what I'm looking to do is slightly different in that I'd like to manage the retrying/rejecting of promises conditionally until the max retries have been reached.

To give a simple example, imagine we wrap a promise around an XMLHttpRequest. When the request loads with a status of...

  • 200: resolve the Promise
  • 299: retry immediately
  • 399: reject immediately
  • 499: fetch something from server, then retry

Notice that there is scope here for asynchronous behavior to be executed before retries.

The solution I have been looking into involves two Promises.

  • The first is a wrapper around each attempt and does a simple resolve/reject based on the result of that attempt.
  • The second is a wrapper around the set of attempts, which handles rejections of the individual Promises conditionally.

Bringing this back to the example I mentioned...

  • The first Promise manages each XmlHttpRequest, resolving on status 200 and rejecting otherwise.
  • The second Promise resolves itself when any of the attempts are resolved. Whenever an attempt is rejected, it decides on the next action (retry, reject, fetch then retry etc.) based on that attempt's status code.

I think I'm going in the right direction with this, but can't seem to get a concrete solution in place. I'm looking to create a generic wrapper for this kind of 'conditionally retrying promise.'


Edit:

Here is a solution in progress:

async function tryAtMost(maxAttempts, asyncCall, handleError)
{
    for (let i = 0; i < maxAttempts; i++)
    {
        try 
        { 
            return await asyncCall(); 
        }
        catch (error)
        {
            const nextAction = await handleError(error); // await some async request (if available) before proceeding
            const actionError = new Error(nextAction.error);

            switch (nextAction.type)
            {
                case ACTIONS.ABORT:
                    throw actionError;
                case ACTIONS.RETRY:
                    if (i === maxAttempts - 1) { throw actionError; }
                    else { continue; }
            }
        }
    }
}
sookie
  • 2,437
  • 3
  • 29
  • 48
  • 1
    It's a good question. Could you post an attempt or general direction you intend to go in code? – JJWesterkamp Mar 06 '18 at 18:16
  • 1
    Yes, that's exactly the way to go, and it's exactly what all the other questions about retrying actions (not promises!) were solved with. Please give us some concrete code with a problem, or otherwise this is just an exercise to translate your prose into js. – Bergi Mar 06 '18 at 18:26
  • What do you mean by `'fetch something from server, then retry'`? – nicholaswmin Mar 06 '18 at 18:39
  • I think your solution is a good one, but I can also think of a recursive solution that returns a Promise on retry. (The outer promise will wait for and take the value of the returned promise.) Considering the memory and readability implications, I'm not sure it's better than the solution you proposed. – Jeff Bowman Mar 06 '18 at 18:42
  • You just want a recursive function which returns a promise depending on the result of a `switch case` somethingy. – Redu Mar 06 '18 at 20:04
  • @JeffreyWesterkamp I'm looking for a generic wrapper that will call some arbitrary function (returning a Promise) x number of times until it resolves, using an arbitrary errorHandler to handle any failed attempts. This is why I used a simple example (to illustrate what I'm going for) instead of providing any concrete implementations, as it should be use-case independent. – sookie Mar 07 '18 at 10:42
  • @NicholasKyriakides That was just to illustrate that some other asynchronous task might be called (perhaps to resolve some data) before executing a retry or an abort – sookie Mar 07 '18 at 10:44
  • Your `tryAtMost` function looks good to me. The only thing I'd change is to `throw` the `nextAction.error` instead of `return`ing it. – Bergi Mar 07 '18 at 11:25

3 Answers3

2

There are a few ways to do this, as the other post shows. Personally I find the usage of class unnecessary. I'd approach it using something like

async function fetchWithRetries(theURL, remainingRetries = 5) {
  const response = await fetch(theURL);

  switch (response.status) {
    case 200:
      return await response.json(); // or whatever you need
    case 299:
      if (remainingRetries === 0) {
        throw new Error();
      }
      return await fetchWithRetries(theURL, remainingRetries - 1);
    case 399:
      throw new Error();
    case 499:
      if (remainingRetries === 0) {
        throw new Error();
      }

      const otherData = await fetchOtherData();

      return await fetchWithRetries(theURL, remainingRetries - 1);

    default:
      // TODO: You didn't specify other codes?
  }
}
loganfsmyth
  • 156,129
  • 30
  • 331
  • 251
  • I like it more when done with promises but this is close to what i had meant in my comment. Just cool... – Redu Mar 06 '18 at 20:15
  • 1
    @Redu Yeah you can certainly write it out as a `.then` chain if you want. I think the async fn approach ends up being more readable though. – loganfsmyth Mar 06 '18 at 21:03
  • @loganfsmyth You could even use a loop instead of recursion in the `async function` :-) – Bergi Mar 06 '18 at 21:57
  • @Bergi True! I was still thinking partially in promises I guess :P – loganfsmyth Mar 06 '18 at 22:15
1

I would simply create a Class that returns an async function (which returns a Promise).

  • The Class instance keeps track of the attempts.
  • The async function attempts to fetch something x number of times, equal to the number of maxAttempts.
  • If the request responds properly without any errors just return the result.
  • Otherwise keep trying until you exhaust the number of maxAttempts.

An example for Node.js using request-promise-native:

const rp = require('request-promise-native')

class RetryableFetch {
  constructor({ url, maxAttempts = 3 }) {
    this.url = url
    this.maxAttempts = maxAttempts    
    this.attempts = 0

    return this.generateRequest()
  }

  async generateRequest() {
    for (let i = 0; i < this.maxAttempts; i++) {
      try {
        return await rp(this.url)
      } catch(err) {
        switch (err.statusCode) {
          // Add more cases here as you see fit.
          case 399:
            throw err
            break;
          default:
            if (++this.attempts === this.maxAttempts) throw err
        }
      }
    }
  }
}

Usage:

new RetryableFetch({
  url: 'https://www.google.com'
})
.then(result => {
  console.log(result)
})
.catch(err => {
  console.error(err)
})

You can of course substitute rp with Fetch if you want this to work in the browser since both use a Promise-based API.

nicholaswmin
  • 21,686
  • 15
  • 91
  • 167
  • [Do not return promises from a constructor!](https://stackoverflow.com/q/24398699/1048572) Really there's no reason at all to use a `class` (with no method that is called from outside) here when a simple `function` would be enough. – Bergi Mar 06 '18 at 20:03
  • @Bergi I disagree with the linked answer. Also, there's really no reason not to use a class. Both methods are valid; The recursive method being more functional-style than this. I personally find using classes to keep state way far more maintainable. – nicholaswmin Mar 06 '18 at 20:30
  • The reason not to use `class` is that the whole code is basically a single method, or should I say, function. You still can keep state in an object if you want, but you don't need any prototype inheritance. Would you still have used a class if you had to write it in ES5 style? – Bergi Mar 06 '18 at 21:53
  • Sorry, I'm not following; There's no classes per se in ES5. But I got your point. A local variable in a Function could keep state. I'll still leave this answer as is though since I prefer using Classes for anything that keeps state. – nicholaswmin Mar 06 '18 at 22:53
  • I meant `function RetryableFetch(…) { … }` with `RetryableFetch.prototype.generateRequest = async function() { … }`. – Bergi Mar 06 '18 at 23:01
  • Yeah got it, and yes I would. It allows me to refactor/add more methods in the future like `.abort()` etc. – nicholaswmin Mar 06 '18 at 23:02
  • @NicholasKyriakides I should probably have been more clear in my question, but as per the last sentence in it, I'm looking for a generic wrapper for this kind of behavior rather than anything use-case dependent. Your answer (as well as loganfsmyth's) has given me direction though. I think I may have a solution, but can you generalize your answer for me? – sookie Mar 07 '18 at 10:57
  • @sookie I'm not quite following; What's the behaviour to generalise here? Do you mean you want that `switch.. case` and how it should proceed to be specified at the call site instead of it being "hardcoded" within the function/class itself? – nicholaswmin Mar 07 '18 at 10:58
  • @NicholasKyriakides Yes, I essentially would like to be able to call some generic `tryAtMost` function, passing it the maxAttempts, a promise-returning function to call that many times, and an errorHandler for handling any errors caught (i.e switch statement). This can then be used with any arbitrary async request and handler. I will update my answer with a potential solution I'm looking at atm. Hopefully it will make a little more sense then – sookie Mar 07 '18 at 11:10
  • Thanks. I've a potential solution included in my question. Feel free to scrutinize it ^_^ ... (PS: Looking to use native es6 as well - no libraries) – sookie Mar 07 '18 at 11:22
1

Based off your comment:

I'm looking to create a generic wrapper for this kind of "conditionally" retrying promise.

Here's a more generalised wrapper for this:

  • It allows you to specify the number of max attempts.
  • You pass it your own Promise.
  • You specify, at the construction site, what should happen if the promise rejects and the max attempts have not yet been reached.

// Class Retryable

class Retryable {
  constructor({ promise, maxAttempts = 1, attemptRetry }) {
    this.promise = promise
    this.maxAttempts = maxAttempts
    this.attemptRetry = attemptRetry

    this.attempts = 0
  }

  generateTry() {
    console.info('generating request')

    return this.promise().catch(err => {
      if (++this.attempts === this.maxAttempts) throw err

      return this.attemptRetry(err, () => this.generateTry() , () => {
        throw err
      })
    })
  }
}

// Usage

const retryable = new Retryable({
  maxAttempts: 4,
  promise: () => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        reject({ status: 500 })
        // If you `resolve` here instead you will trigger `.then()`
      }, 200)
    })
  },

  attemptRetry: function(err, yes, no) {
    switch (err.status) {
      case 500:
        return yes()
        break;
      default:
        return no()
    }
  }
})

retryable.generateTry().then(result => {
  console.log(result)
}).catch(err => {
  console.error(err)
})
nicholaswmin
  • 21,686
  • 15
  • 91
  • 167