0

I feel like there's probably just a fundamental gap in my knowledge, as I'm new to Apollo Client. But I've perused Stack Overflow, GitHub issues, and the Google for obvious solutions to an issue I'm running into and haven't found any.

Basically I have the following Apollo Client setup (simplified):

const auth = new Auth()
const authMiddleware = new ApolloLink((operation, forward) => {
  const authToken = auth.getToken().access_token

  console.log(authToken)

  operation.setContext(({ headers = {} }) => ({
    headers: {
      ...headers,
      authorization: authToken ? `Bearer ${authToken}` : ''
    }
  }))

  return forward(operation)
})
const cache = new InMemoryCache()
const errorLink = onError(({ forward, graphQLErrors, networkError, operation }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ extensions, locations, message, path }) => {
      if (extensions.code === 'access-denied') {
        auth.refresh()
          .then(() => {
            console.log(`new access token: ${auth.getToken().access_token}`)
            return forward(operation)
          }).catch((error) => {
            handleLogout(error)
          })
      }
    })
  }
})
const handleLogout = (reason) => {
  auth.logout()
}
const httpLink = new HttpLink({ uri: '' })

const client = new ApolloClient({
  cache: cache,
  link: ApolloLink.from([
    errorLink,
    authMiddleware,
    httpLink
  ])
})

And I have a simple query:

client.query({
  query: Queries.MyQuery
}).then((response) => {
  console.log(response)
}, (error) => {
  console.log(error)
})

The client successfully executes the query if there's a valid OAuth access token the first time it runs. If, however, I expire the access token on our OAuth server and then try to execute the query, it does not complete successfully.

When debugging, I can see what's going on:

  1. authMiddleware adds the old access token properly to the request header.
  2. The request fails because the token is no longer valid. This is handled property by errorLink.
  3. errorLink also successfully retrieves a new access token and returns forward(operation).
  4. authMiddleware gets called again, adds the new access token, and returns forward(operation).

This is where things break down. The query never re-executes. If I manually refresh the page to re-execute the query, it uses the new access token and completes successfully.

From reading the docs, it sounds like the way I've set it up should work, but obviously I'm doing something incorrectly.

Nate Irwin
  • 600
  • 1
  • 11
  • 20
  • It's worth remembering that a forEach loop can't handle asynchronous calls. So even with the correct refresh / forward(operation) setup this loop will need to be substituted for a regular for or for/ of which can handle async – Justin.Mathew Jan 21 '22 at 02:21
  • Yep. Great point, @Justin.Mathew! – Nate Irwin Jan 26 '22 at 19:36

1 Answers1

1

I was able to piece together what was going on by digging into a variety of sources. It was confusing mostly because a lot of devs have struggled with this in the past (and still seem to be), so there's a plethora of outdated solutions and posts out there.

This GitHub issue was the most useful source of information, even though it's attached to a repository that's now deprecated. This Stack Overflow answer was also helpful.

I spent some time going down the path of using a utility method to turn the promise into an Observable, but this is no longer required if you use fromPromise.

Here's the solution I ended up with that works with Apollo Client 3.2.0:

const authLink = new ApolloLink((operation, forward) => {
  const authToken = auth.getToken().access_token

  console.info(`access token: ${authToken}`)
  operation.setContext(({ headers }) => ({
    headers: {
      ...headers,
      authorization: authToken ? `Bearer ${authToken}` : ''
    }
  }))

  return forward(operation)
})
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
  if (graphQLErrors) {
    const firstGraphQLError = graphQLErrors[0]

    if (firstGraphQLError.extensions.code === 'access-denied') {
      let innerForward

      if (!isRefreshing) {
        isRefreshing = true
        innerForward = fromPromise(
          auth.refresh()
            .then(() => {
              const authToken = auth.getToken().access_token
              console.info(`access token refreshed: ${authToken}`)
              resolvePendingRequests()
              return authToken
            })
            .catch(() => {
              pendingRequests = []
              // Log the user out here.
              return false
            })
            .finally(() => {
              isRefreshing = false
            })
        ).filter(value => Boolean(value))
      } else {
        innerForward = fromPromise(
          new Promise(resolve => {
            pendingRequests.push(() => resolve())
          })
        )
      }

      return innerForward.flatMap(() => {
        return forward(operation)
      })
    } else {
      console.log(`[GraphQL error]: Message: ${firstGraphQLError.message}, Location: ${firstGraphQLError.locations}, Path: ${firstGraphQLError.path}`)
    }
  }

  if (networkError) {
    console.log(`[Network error]: ${networkError}`)
  }
})

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: from([
    errorLink,
    authLink,
    new HttpLink({ uri: '' })
  ])
})

This solution also handles multiple concurrent requests, queuing them up and requesting them once the access token has been refreshed.

Nate Irwin
  • 600
  • 1
  • 11
  • 20
  • does isRefreshing has a false as initial value ? and is it a global variable ? – yaminoyuki Jun 30 '23 at 18:25
  • @yaminoyuki: It's been awhile since I posted this, but it looks like `isRefreshing` is a package variable that's initially set to `false`. – Nate Irwin Jul 03 '23 at 13:56