Apollo GraphQL : Async Access Token Refresh

The Problem

I’ve been working on a React Native app recently and one of the things I struggled quite a bit with was automatically refreshing OAuth2 access tokens.

The example given in the the Apollo Error Link documentation is a good starting point but assumes that the getNewToken() operation is synchronous.

import { onError } from 'apollo-link-error';
// ...

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // error code is set to UNAUTHENTICATED
            // when AuthenticationError thrown in resolver

            // modify the operation context with a new token
            const oldHeaders = operation.getContext().headers;
            operation.setContext({
              headers: {
                ...oldHeaders,
                authorization: getNewToken(),
              },
            });
            // retry the request, returning the new observable
            return forward(operation);
        }
      }
    }
    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
      // if you would also like to retry automatically on
      // network errors, we recommend that you use
      // apollo-link-retry
    }
  }
);

Initial Solution

We’ll be using the fromPromise utility function from the apollo-link package to transform our Promise to an Observable.

A couple of things to note :

  • The ErrorHandler should either return an Observable or nothing.

  • The forward function returns an Observable.

  • Apollo is using the zen-observable implementation for Observables.

import { onError } from 'apollo-link-error';
import { fromPromise } from 'apollo-link';
// ...

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // error code is set to UNAUTHENTICATED
            // when AuthenticationError thrown in resolver

            return fromPromise(
              getNewToken().catch(error => {
                // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                return;
              })
            ).filter(value => Boolean(value))
             .flatMap(accessToken => {
              const oldHeaders = operation.getContext().headers;
              // modify the operation context with a new token
              operation.setContext({
                headers: {
                  ...oldHeaders,
                  authorization: `Bearer ${accessToken}`,
                },
              });

              // retry the request, returning the new observable
              return forward(operation);
            });
        }
      }
    }
    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
      // if you would also like to retry automatically on
      // network errors, we recommend that you use
      // apollo-link-retry
    }
  }
);

In a real world scenario :

  • You’ll be using a refresh token to get and store a new pair of access and refresh tokens.

  • Your Auth Link will be responsible for setting the Authorization header for the outgoing requests and will be running after your Error Link.

import { onError } from 'apollo-link-error';
import { fromPromise, concat } from 'apollo-link';
// ...

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // error code is set to UNAUTHENTICATED
            // when AuthenticationError thrown in resolver

            return fromPromise(
              getNewToken()
                .then(({ accessToken, refreshToken }) => {
                  // Store the new tokens for your auth link
                  return accessToken;
                })
                .catch(error => {
                  // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                  return;
                })
            ).filter(value => Boolean(value))
             .flatMap(() => {
              // retry the request, returning the new observable
              return forward(operation);
            });
        }
      }
    }
    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
      // if you would also like to retry automatically on
      // network errors, we recommend that you use
      // apollo-link-retry
    }
  }
);

const apolloLink = concat(errorLink, concat(authLink, httpLink));

Concurrent Requests

I initially stopped at the above implementation which worked correctly until two or more requests failed concurrently.

A couple of changes were made to tackle that scenario :

  • Only refresh the token once.

  • Keep track of the remaining failed requests and retry them once the token is refreshed.

import { onError } from 'apollo-link-error';
import { fromPromise } from 'apollo-link';
// ...

let isRefreshing = false;
let pendingRequests = [];

const resolvePendingRequests = () => {
  pendingRequests.map(callback => callback());
  pendingRequests = [];
};

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // error code is set to UNAUTHENTICATED
            // when AuthenticationError thrown in resolver
            let forward$;

            if (!isRefreshing) {
              isRefreshing = true;
              forward$ = fromPromise(
                getNewToken()
                  .then(({ accessToken, refreshToken }) => {
                    // Store the new tokens for your auth link
                    resolvePendingRequests();
                    return accessToken;
                  })
                  .catch(error => {
                    pendingRequests = [];
                    // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                    return;
                  })
                  .finally(() => {
                    isRefreshing = false;
                  })
              ).filter(value => Boolean(value));
            } else {
              // Will only emit once the Promise is resolved
              forward$ = fromPromise(
                new Promise(resolve => {
                  pendingRequests.push(() => resolve());
                })
              );
            }

            return forward$.flatMap(() => forward(operation));
        }
      }
    }
    if (networkError) {
      console.log(`[Network error]: ${networkError}`);
      // if you would also like to retry automatically on
      // network errors, we recommend that you use
      // apollo-link-retry
    }
  }
);

const apolloLink = concat(errorLink, concat(authLink, httpLink));

Even if this solves my problem for now, there is still room for improvement and your feedback is greatly appreciated.


Anas Taha picture

Thanks Soumyajit, your feedback really helps.

Will elaborate more on authentication in a future post.

Soumyajit Pathak picture

Welcome to Able, Anas! Looks great for an initial solution.

Would love to see a more detailed post on authentication in GraphQL context. I have seen a lot of people struggle with authentication (both on the client / server) in the GraphQL ecosystem.

Kudos, man!

Hartani Yassir picture

Thank you Anas for this awesome blog post, it worked like a charm. One small note: in the case of concurrent requests, the first request will not be forwarded again, I fixed the issue by removing the else condition outside forward$ = fromPromise( new Promise(resolve => { pendingRequests.push(() => resolve()); }) );

Anas Taha picture

Hi Yassir,

Thank you very much for your feedback.

In the case of concurrent requests, the first request blocks further ones and pushes them (to pendingRequests) to be resolved later until it is done refreshing the access token.

Removing the else condition will lead to the first request running twice.

Would be great if you could share a sample where the first request is not retried.

Thanks again.

Jorge B picture

Hi Anas, this is looking very good, it's exactly what I was looking for!

Something I've noticed is that, when the token refresh request fails, the first "normal" request that had previously failed is repeated. And it's only the first one, doesn't matter that you have 1, 3, or more concurrent requests.

That repeated request obviously fails silently, so it's not a big issue for this flow, but I can't seem to find out why it is re-triggered even though we do pendingRequests = []...

Thought I could bring it up with you in case you have any ideas!

Anas Taha picture

Hi Jorge,

Thanks for your comment, I'm glad it helps.

Good catch, I didn't pay attention to that.

Since the token refresh request error is caught, forward$ still emits and therefore the operation is retried.

One way to get around that is to filter out falsy values :

// ...
             forward$ = fromPromise(
                getNewToken()
                  .then(({ accessToken, refreshToken }) => {
                    // Store the new tokens for your auth link
                    resolvePendingRequests();
                    return accessToken;
                  })
                  .catch(error => {
                    pendingRequests = [];
                    // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                    return; // Return a falsy value
                  })
                  .finally(() => {
                    isRefreshing = false;
                  })
              ).filter(value => Boolean(value));
// ...

Please let me know if that works for you.

Jorge B picture

Hi Anas,

Nice one there, now the issue with the refetching is gone!

Anas Taha picture

Hey Jorge,

Great, will reflect that on the post.

Thanks again