building an API client with retries
By Per Fröjd
- javascript
- axios
- snippets
Use case
In most applications, the frontend ends up communicating a lot with a REST(ful) backend. A scenario that usually comes up is dealing with authentication, and how to properly handle authentication-related issues within the application.
Recently, I was building a react-application that needed to interface with backend, authenticated via JWTs. The quick gist of JWT is that it’s stateless, unlike cookie-based authentication, and that it often relies on short-lived tokens.
I had the following implementation issues to deal with:
- Each JWT-token comes with a short-lived token, which stays valid for 15 minutes.
- Paired with each token, we’re also given a refresh-token which can be used to retrieve a new token.
- Whenever we send an invalid token, our request is returned with a status of 401.
- A refreshToken can and will expire (but a much longer timespan)
For obvious reasons, we do not want our application to lose our authenticated state every 15 minutes, whenever our short-lived token has expired, so we need the refreshing of the token to happen seamlessly.
Possible solutions
The token we receive when we authenticate contains the expiration-date of the token. We could try to schedule the frontend-application to automatically refresh the token if we’re getting closer to the time of expiration.
Scheduling is a pain to implement (and do correctly), and we don’t know if the end-user will keep tabs open for long. It could cause inconsistencies during long sessions. It’s also inefficient to continously ask for new tokens, if they are not being used in future calls.
An alternative I found, when researching the problem, was to continously use the short-lived token for as long as possible, and once the API tells us that that the token has expired, request a new token. Even better if we can allow this to happen behind the scenes of the user.
Automating a behaviour like this can be troublesome, but I found some examples, and it turned out to be fairly simple to avoid the worst kind of problems.
The implementation
For the context of this, I’m using the axios
package to send requests. For this solution, we use the interceptors that axios
exposes, but this could also be implemented with something more standard, like fetch
.
So axios
allows us to define multiple interceptors on two actions, on request
and response
. So first of all we set up the the initial request interceptor, to send the token we have for all requests.
Every interceptor is called with two functions, a callback for the intent of the request/response, and an error function which can handle an error that happens.
In this case, the interceptor will be activated on each and every call we make.
client.interceptors.request.use(
(config) => {
const { session } = getState()
if (session.token) {
config.headers['Authorization'] = `Bearer ${session.token}`
}
config.headers['Content-Type'] = 'application/json'
return config
},
(error) => Promise.reject(error)
)
Note that for this example, we add these interceptor to a client that was created earlier. These interceptors can also be added straight to the axios
object, and be globally available for all calls.
The interceptor uses a callback, to give access to the configuration for the request. So we grab our current session object from our state (in this case, it’s a redux
implementation). If we currently have a token, set the Authorization
header.
If you’re wondering about the Bearer
-wording, this is the format JWT-based authentication most often expects. We also use this interceptor to set up the Content-Type
header, although it’s something that isn’t necessary for this example.
The retries
We’ll start setting up the response interceptor with the following:
client.interceptors.response.use(
(response) => {
return response
},
(error) => {
// We save down the information we used to send the initial request, this contains our
// payload, the URL we were talking to and more.
const originalRequest = error.config
// This object now contains all the relevant information regarding the response.
const response = error.response
return error.response
}
)
At this point, the interceptor will do nothing, and nothing has really changed. So this is where we need to think about things, what is it we’re trying to intercept? Previously I mentioned the API we interface with will send us a 401
response whenever our token has expired, so lets start with that.
Now, we also know that we should only retry certain calls. We also know that the refreshToken call can fail, and should then trigger a logout in the system.
We’ll need to know whether the request we did is the result of a retry
, so we add a new property to the request, and check that.
if (!response.status === 401) {
return error.response
}
if (response.status === 401 && originalRequest.url === 'auth/refresh') {
// The refreshToken has expired
// Logout, redirect, do whatever we need.
return error.response
}
originalRequest._isRetry = true
Great, now we’re fairly sure we’re going to be getting all the requests that fail to pass through these filters. You can add your own additional filters here depending on your requirements. In some cases, it might be good to set up a list of allowed/disallowed URLs that can and should be retried.
Now, we’ll need to actually start refreshing the token, so we’re going to return a promise that does just that.
return new Promise(async (resolve, reject) => {
let res
try {
// Attempt to refresh the token.
res = await APIClient.refreshToken()
} catch (err) {
// The refreshing has failed.
return reject('An inconvenient error message')
}
// Get the tokens out of the response.
const { token, refreshToken } = res
const jwt = decode(token)
updateYourStateWithYourNewTokens(jwt)
// Re-trigger the original request which originally got the 401.
return resolve(client(originalRequest))
})
There’s a couple pointers for the code block above, as there are some things that are very dependant on your own authentication implementation. But it’s up to you to define what happens to the result of the refreshToken
call, where and how you update your state as a result of this. The most important part is that we re-try to resolve the failed call that we still carry in our originalRequest
object. The client
here is our axios-client, but might as well be axios(originalRequest)
.
The final implementation looks like this:
client.interceptors.response.use(
(response) => {
return response
},
(error) => {
// We save down the information we used to send the initial request, this contains our
// payload, the URL we were talking to and more.
const originalRequest = error.config
// This object now contains all the relevant information regarding the response.
const response = error.response
if (!response.status === 401 && originalRequest._isRetry) {
return error.response
}
if (response.status === 401 && originalRequest.url === 'auth/refresh') {
// The refreshToken has expired
// Logout, redirect, do whatever we need.
return error.response
}
originalRequest._isRetry = true
// The actual promise which will refresh the token.
return new Promise(async (resolve, reject) => {
let res
try {
// Attempt to refresh the token.
res = await APIClient.refreshToken()
} catch (err) {
// The refreshing has failed.
return reject('An inconvenient error message')
}
// Get the tokens out of the response.
const { token, refreshToken } = res
const jwt = decode(token)
updateYourStateWithYourNewTokens(jwt)
// Re-trigger the original request which originally got the 401.
return resolve(client(originalRequest))
})
}
)
This is definitely not a “one-size” fits all solution, and will require some tailoring to fit your use case. It could be a building block.
Bonus
We found some edge cases that we wanted to fix, so I’ll list them here:
In some cases, we were doing multiple requests at once, which would queue up refreshToken requests one after another (because each failed request would trigger a refresh).
To fix this, we implemented a “queue” of promises, and a simple boolean to allow us to monitor the current state of the queue.
let isRefreshing = false
let failedQueue = []
const processQueue = () => {
failedQueue.forEach((prom) => {
prom.resolve()
})
failedQueue = []
}
With this, we added a check to see if we are currently isRefreshing
, and if we are, do the following:
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(() => {
return client(originalRequest)
})
}
originalRequest._isRetry = true
isRefreshing = true
return new Promise(async (resolve, reject) => {
// Our refresh-token promise.
//...
updateYourStateWithYourNewTokens(jwt)
processQueue()
isRefreshing = false
})
So what does this do? When dealing with a failed request, we check to see if we’re currently isRefreshing
, and if we are instead add a promise where we attempt to re-send the original request to the processQueue.
Essentially this would mean the following:
- Request A fails, 401.
- isRefreshing = true;
- Request B (refreshToken call is sent)
- While waiting for B, Request C, D and E is piled up.
- C, D, E is added to the queue.
- Request B is successful. The tokens are updated and we then decide to start processing the queue of C, D and E.
- Because the tokens used in C, D and E are now valid, they should resolve.
I would like to improve the queue to make it clearer what it does. For the future, perhaps.