đź“Ł 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:
- If total attempts > attempts, continue
- if status code type matches, continue
- if (now - delay) > last attempt, try request
- 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.
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)
}
})
}
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))
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)
})
}
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)
}
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)
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)
}
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)
}
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)
})
}
})
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)
})
}
})
})
}
The key parts here are:
- Returning a new
Promise
-
resolve
on successful actions -
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)
})
}
})
})
}
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.