DEV Community

Cover image for Add Retries to HTTP requests
Mark Michon for Bearer

Posted on • Originally published at blog.bearer.sh

Add Retries to HTTP requests

📣 This post originally appeared as Add Retries to Your API Calls on The Bearer Blog.

Whether you're making API calls from Node.js or in the browser, connection failures are going to happen eventually. Some request errors are valid. Maybe the endpoint was wrong or the client sent the wrong data. Other times you can be sure that the error is the result of a problem with the connection to the server or one of the many hops in-between. While API and web service monitoring can inform you about the problem, a more active solution can take care of it for you.

To fix this, you can improve your HTTP request library by adding intelligent retry functionality. This kind of remediation is crucial to ensuring your API calls are successful. Some libraries, like got support retrying failed requests out of the box, while others like axios require a separate plugin. If your preferred library doesn't support them, this article is for you. We'll explore adding status-code-specific retries to a request, making them incrementally longer with a technique called "back-off", and more.

The basics of a retry

To decide when to retry a request, we need to consider what to look for. There are a handful of HTTP status codes that you can check against. This will let your retry logic differentiate between a failed request that is appropriate to retry—like a gateway error—and one that isn't—like a 404. For our examples, we will use 408, 500, 502, 503, 504, 522, and 524. You could also check for 429, as long as you incorporate the Retry-After header into the back-off logic.

The next consideration we want is how often to retry. We will start with a delay, then increase it each additional time. This is a concept known as "back-off". The time between requests will grow with each attempt. Finally, we'll also need to decide how many attempts to make before giving up.

Here's an example of the logic we'll be using in pseudo-code:

  1. If total attempts > attempts, continue
  2. if status code type matches, continue
  3. if (now - delay) > last attempt, try request
  4. else, return to the start

We could also check for things like error codes (from Node.js), and limit retrying to certain methods. For example, ignoring POST is often a good idea, to ensure no duplicate entries are created.

Recursive request structure

To make this all work, we'll be making a request from within a failed request. This requires the use of recursion. Recursion is when a function calls itself.

recursion example

For example, if we wanted to infinitely keep trying to make a request it might look like this:

function myRequest(url, options = {}) {
  return requests(url, options, response => {
    if (response.ok) {
      return response
    } else {
      return myRequest(url, options)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Notice that the else block returns the myRequest function. Since most modern HTTP request implementations are promise-based, we can return the result. This means that to the end user, the whole process looks like a normal call. For example:

myRequest("https://example.com").then(console.log(response))
Enter fullscreen mode Exit fullscreen mode

With a plan in mind, let's look at implementing retries in javascript.

Add retry to Fetch

First, we'll start with the browser's Fetch API. The fetch implementation will be similar to the recursion example above. Let's implement that same example, but using fetch and a status check.

function fetchRetry(url, options) {
  // Return a fetch request
  return fetch(url, options).then(res => {
    // check if successful. If so, return the response transformed to json
    if (res.ok) return res.json()
    // else, return a call to fetchRetry
    return fetchRetry(url, options)
  })
}
Enter fullscreen mode Exit fullscreen mode

This will work to infinitely retry failed requests. Note: a return will break out of the current block, so we don't need an else statement after return res.json().

Now let's add in a max number of retries.

function fetchRetry(url, options = {}, retries = 3) {
  return fetch(url, options)
    .then(res => {
      if (res.ok) return res.json()

      if (retries > 0) {
        return fetchRetry(url, options, retries - 1)
      } else {
        throw new Error(res)
      }
    })
    .catch(console.error)
}
Enter fullscreen mode Exit fullscreen mode

The code is mostly the same, except we've added a new argument and a new condition. Add the retries argument to the function, with a default value of 3. Then, rather than automatically calling the function on failure, check if any retries are remaining. If so, call fetchRetry. The new retries value passed to the next attempt is the current retries minus 1. This ensures that our "loop" decrements, and eventually will stop. Without this, it would run infinitely until the request succeeds. Finally, if retries is not greater than zero, throw a new error for .catch to handle.

To give it a try, you can make a request to https://status-codes.glitch.me/status/400. For example:

fetchRetry("https://status-codes.glitch.me/status/400")
  .then(console.log)
  .catch(console.error)
Enter fullscreen mode Exit fullscreen mode

If you check your network traffic, you should see four calls in total. The original, plus three retries. Next, let's add in a check for the status codes we want to retry.

function fetchRetry(url, options = {}, retries = 3) {
  const retryCodes = [408, 500, 502, 503, 504, 522, 524]
  return fetch(url, options)
    .then(res => {
      if (res.ok) return res.json()

      if (retries > 0 && retryCodes.includes(res.status)) {
        return fetchRetry(url, options, retries - 1)
      } else {
        throw new Error(res)
      }
    })
    .catch(console.error)
}
Enter fullscreen mode Exit fullscreen mode

First, declare an array of status codes we want to check for. You could also add this as part of the configuration, especially if you implemented this as a class with a more formal configuration. Next, the retry condition checks to see if the response's status exists in the array using ECMAScript's array.includes(). If it does, try the request. If not, throw an error.

There is one last feature to add. The incremental back-off delay between each request. Let's implement it.

function fetchRetry(url, options = {}, retries = 3, backoff = 300) {
  /* 1 */
  const retryCodes = [408, 500, 502, 503, 504, 522, 524]
  return fetch(url, options)
    .then(res => {
      if (res.ok) return res.json()

      if (retries > 0 && retryCodes.includes(res.status)) {
        setTimeout(() => {
          /* 2 */
          return fetchRetry(url, options, retries - 1, backoff * 2) /* 3 */
        }, backoff) /* 2 */
      } else {
        throw new Error(res)
      }
    })
    .catch(console.error)
}
Enter fullscreen mode Exit fullscreen mode

To handle the "wait" mechanic before retrying the request, you can use setTimeout. First, we add our new configuration argument (1). Then, set up the setTimeout and use the backoff value as the delay. Finally, when the retry occurs we also pass in the back-off with a modifier. In this case, backoff * 2. This means each new retry will wait twice as long as the previous.

Now if we try out the function by calling fetchRetry('https://status-codes.glitch.me/status/500'), the code will make the first request immediately, the first retry after waiting 300ms, the next 600ms after the first response, and the final attempt 900ms after the second response. You can give it a try with any status code by using https://status-codes.glitch.me/status/${STATUS_CODE}.

Further configuration and better options

This is a great solution for one-off requests or small apps, but for larger implementations, it could be improved. Building a configurable class (or class-like object) will give you more control and allow for separate settings for each API integration. You could also apply this logic to a circuit breaker or any other remediation pattern.

Another option is to use a tool that observes and reacts to anomalies in your API calls. At Bearer, our team is building just that. Instead of configuring all of this in code for each API, the Bearer Agent handles it all for you. Give it a try today and let us know what you think @BearerSH

Bonus: Add retry to Node's native http module

The fetch implementation above works for the browser, but what about Node.js? You could use a fetch-equivalent library like node-fetch. To make things interesting, let's look at applying the same concepts above to Node.js' native http module.

To make things a bit easier, we'll use the shorthand http.get method. The retry logic will stay the same, so check out our article on making API calls with http.request if you want to perform requests other than GET.

Before we get started, we'll need to change http.get from event-based to promise-based so we can interact with it the same way we did with fetch. If you are new to promises, they are an underlying concept that modern async implementations use. Every time you use .then or async/await, you are using promises under the hood. For the purposes of this article, all you need to know is that a promise can resolve or reject—in other words, the code passes or fails. Let's look at some code without any retry logic.

Here's a basic GET using http.get

let https = require("https")

https.get(url, res => {
  let data = ""
  let { statusCode } = res

  if (statusCode < 200 || statusCode > 299) {
    throw new Error(res)
  } else {
    res.on("data", d => {
      data += d
    })
    res.end("end", () => {
      console.log(data)
    })
  }
})
Enter fullscreen mode Exit fullscreen mode

To summarize, it requests a url. If the statusCode isn't in a defined "success range" (Fetch has the ok property to handle this) it throws an error. Otherwise, it builds a response and logs to the console. Let's look at what this looks like "promisified". To make it easier to follow, we'll leave off some of the additional error handling.

function retryGet(url) {
  return new Promise((resolve, reject) => {
    https.get(url, res => {
      let data = ""
      const { statusCode } = res
      if (statusCode < 200 || statusCode > 299) {
        reject(Error(res))
      } else {
        res.on("data", d => {
          data += d
        })
        res.on("end", () => {
          resolve(data)
        })
      }
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

The key parts here are:

  1. Returning a new Promise
  2. resolve on successful actions
  3. reject on errors

We can then test it by calling retryGet("https://status-codes.glitch.me/status/500").then(console.log).catch(console.error). Anything outside the 200 range will show up in our catch, while anything within the range will show up in then.

Next, let's bring all the logic from the fetch example into retryGet.

function retryGet(url, retries = 3, backoff = 300) {
  /*  1 */
  const retryCodes = [408, 500, 502, 503, 504, 522, 524] /* 2 */
  return new Promise((resolve, reject) => {
    https.get(url, res => {
      let data = ""
      const { statusCode } = res
      if (statusCode < 200 || statusCode > 299) {
        if (retries > 0 && retryCodes.includes(statusCode)) {
          /* 3 */
          setTimeout(() => {
            return retryGet(url, retries - 1, backoff * 2)
          }, backoff)
        } else {
          reject(Error(res))
        }
      } else {
        res.on("data", d => {
          data += d
        })
        res.on("end", () => {
          resolve(data)
        })
      }
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

This is similar to the fetch example. First, set up the new arguments (1). Then, define the retryCodes (2). Finally, set up the retry logic and return retryGet. This ensures that when the user calls retryGet(...) and expects a promise back, they will receive it.

Wrapping up

You stuck with it through the bonus section 🎉! Using the same concepts in this article, you can apply retry functionality to your favorite library if it doesn't already include it. Looking for something more substantial? Give Bearer a try, and check out with the Bearer Blog for more on Node.js, API integrations, monitoring best practices, and more.

Top comments (0)