How to execute an async fetch request and then retry last failed request?
I'm refreshing the token this way (updated OP's):
import { ApolloClient } from 'apollo-client';
import { onError } from 'apollo-link-error';
import { ApolloLink, Observable } from 'apollo-link'; // add Observable
// Define Http link
const httpLink = new createHttpLink({
uri: '/my-graphql-endpoint',
credentials: 'include'
});
// Add on error handler for apollo link
return new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError, operation, forward }) => {
// User access token has expired
if (graphQLErrors && graphQLErrors[0].message === 'Unauthorized') {
// We assume we have both tokens needed to run the async request
if (refreshToken && clientToken) {
// Let's refresh token through async request
return new Observable(observer => {
authAPI.requestRefreshToken(refreshToken, clientToken)
.then(refreshResponse => {
operation.setContext(({ headers = {} }) => ({
headers: {
// Re-add old headers
...headers,
// Switch out old access token for new one
authorization: `Bearer ${refreshResponse.access_token}` || null,
}
}));
})
.then(() => {
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
};
// Retry last failed request
forward(operation).subscribe(subscriber);
})
.catch(error => {
// No refresh or client token available, we force user to login
observer.error(error);
});
});
}
}
})
])
});
We just had the same issues and after a very complicated solution with lots of Observeables we got a simple solution using promises which will be wrapped as an Observable in the end.
let tokenRefreshPromise: Promise = Promise.resolve()
let isRefreshing: boolean
function createErrorLink (store): ApolloLink {
return onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
// this is a helper method where we are checking the error message
if (isExpiredLogin(graphQLErrors) && !isRefreshing) {
isRefreshing = true
tokenRefreshPromise = store.dispatch('authentication/refreshToken')
tokenRefreshPromise.then(() => isRefreshing = false)
}
return fromPromise(tokenRefreshPromise).flatMap(() => forward(operation))
}
if (networkError) {
handleNetworkError(displayErrorMessage)
}
})
}
All pending requests are waiting for the tokenRefreshPromise and will then be forwarded.
Accepted answer is quite good but it wouldn't work with 2 or more concurrent requests. I've crafted the one below after testing different cases with my token renew workflow that fits my needs.
It's necessary to set errorLink
before authLink
in link pipeline.
client.ts
import { ApolloClient, from, HttpLink } from '@apollo/client'
import errorLink from './errorLink'
import authLink from './authLink'
import cache from './cache'
const httpLink = new HttpLink({
uri: process.env.REACT_APP_API_URL,
})
const apiClient = new ApolloClient({
link: from([errorLink, authLink, httpLink]),
cache,
credentials: 'include',
})
export default apiClient
Cache shared between 2 apollo client instances for setting user query when my renewal token is expired
cache.ts
import { InMemoryCache } from '@apollo/client'
const cache = new InMemoryCache()
export default cache
authLink.ts
import { ApolloLink } from '@apollo/client'
type Headers = {
authorization?: string
}
const authLink = new ApolloLink((operation, forward) => {
const accessToken = localStorage.getItem('accessToken')
operation.setContext(({ headers }: { headers: Headers }) => ({
headers: {
...headers,
authorization: accessToken,
},
}))
return forward(operation)
})
export default authLink
errorLink.ts
import { ApolloClient, createHttpLink, fromPromise } from '@apollo/client'
import { onError } from '@apollo/client/link/error'
import { GET_CURRENT_USER } from 'queries'
import { RENEW_TOKEN } from 'mutations'
import cache from './cache'
let isRefreshing = false
let pendingRequests: Function[] = []
const setIsRefreshing = (value: boolean) => {
isRefreshing = value
}
const addPendingRequest = (pendingRequest: Function) => {
pendingRequests.push(pendingRequest)
}
const renewTokenApiClient = new ApolloClient({
link: createHttpLink({ uri: process.env.REACT_APP_API_URL }),
cache,
credentials: 'include',
})
const resolvePendingRequests = () => {
pendingRequests.map((callback) => callback())
pendingRequests = []
}
const getNewToken = async () => {
const oldRenewalToken = localStorage.getItem('renewalToken')
const {
data: {
renewToken: {
session: { renewalToken, accessToken },
},
},
} = await renewTokenApiClient.mutate({
mutation: RENEW_TOKEN,
variables: { input: { renewalToken: oldRenewalToken } },
})!
localStorage.setItem('renewalToken', renewalToken)
localStorage.setItem('accessToken', accessToken)
}
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
switch (err?.message) {
case 'expired':
if (!isRefreshing) {
setIsRefreshing(true)
return fromPromise(
getNewToken().catch(() => {
resolvePendingRequests()
setIsRefreshing(false)
localStorage.clear()
// Cache shared with main client instance
renewTokenApiClient!.writeQuery({
query: GET_CURRENT_USER,
data: { currentUser: null },
})
return forward(operation)
}),
).flatMap(() => {
resolvePendingRequests()
setIsRefreshing(false)
return forward(operation)
})
} else {
return fromPromise(
new Promise((resolve) => {
addPendingRequest(() => resolve())
}),
).flatMap(() => {
return forward(operation)
})
}
}
}
}
})
export default errorLink