21
votes

Apollo link offers an error handler onError

Issue: Currently, we wish to refresh oauth tokens when they expires during an apollo call and we are unable to execute an async fetch request inside the onError properly.

Code:

initApolloClient.js

import { ApolloClient } from 'apollo-client';
import { onError } from 'apollo-link-error';
import { ApolloLink, fromPromise } from 'apollo-link';

//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  }) => {
            if (graphQLErrors) {
                //User access token has expired
                if(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 fromPromise(
                            authAPI.requestRefreshToken(refreshToken,clientToken)
                            .then((refreshResponse) => {
                                let headers = {
                                    //readd old headers
                                    ...operation.getContext().headers,
                                    //switch out old access token for new one
                                    authorization: `Bearer ${refreshResponse.access_token}`,
                                };

                                operation.setContext({
                                    headers
                                });

                                //Retry last failed request
                                return forward(operation);
                            })
                            .catch(function (error) {
                                //No refresh or client token available, we force user to login
                                return error;
                            })
                        )
                    }
                }
            }
        }
    }
}),

What happens is:

  1. Initial graphQL query runs and fails due to unauthorization
  2. The onError function of ApolloLink is executed.
  3. The promise to refresh the token is executed.
  4. The onError function of ApolloLink is executed again??
  5. The promise to refresh the token is completed.
  6. The initial graphQL query result is returned and its data is undefined

Between step 5 and 6, apollo doesn't re-run the initial failed graphQL query and hence the result is undefined.

Errors from console:

Uncaught (in promise) Error: Network error: Error writing result to store for query:
 query UserProfile($id: ID!) {
  UserProfile(id: $id) {
    id
    email
    first_name
    last_name
    }
    __typename
  }
}

The solution should allow us to:

  1. Run an async request when an operation fails
  2. Wait for the result of the request
  3. Retry failed operation with data from the request's result
  4. Operation should succeed to return its intended result
2
Could u show me the code of authAPI.refreshToken() ?Minh Kha
@MinhKha authAPI.refreshToken() returns an axios promise which calls the authentication endpoint to refresh the tokens.Mysteryos

2 Answers

58
votes

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);
              });
          });
        }
      }
    })
  ])
});
11
votes

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