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