skip to content

In an Apollo Client 4 dashboard whose gateway answers expired access tokens with HTTP 401, how do you refresh the token and replay the request in the link chain?

level: seniorimportance: should knowfreq 30%

answer

  1. the handler can return an Observable
  2. forward(operation) replays downstream links
  3. the header link must run again
  4. one refresh shared by every panel
  5. a replay is attempted only once

basics

~20 s

Use an ErrorLink placed before the SetContextLink that adds the header. On a 401 ServerError, its handler returns an Observable that awaits one shared refresh and then calls forward(operation), so the replay re-reads the new token.

solid answer

~50 s

In Apollo Client 4 an `ErrorLink` handler receives one `error`. A gateway's plain-JSON 401 arrives as a `ServerError` with `statusCode` 401; a GraphQL server's own rejection arrives as `CombinedGraphQLErrors`, with a code in `extensions`, so check both. To replay, the handler returns an Observable, for example `from(refresh()).pipe(switchMap(() => forward(operation)))`. `forward` re-runs only the links after the `ErrorLink`, so the `SetContextLink` that writes the header must sit after it, or the replay resends the old token. Share one in-flight refresh promise, because a dashboard fires many panel queries at once and each would otherwise start its own refresh. Keep `RetryLink` from retrying 4xx answers with `retryIf`, so the 401 reaches the `ErrorLink` immediately. If the replay fails as well, `ErrorLink` does not call its handler again; the error propagates, and the app signs the user out.

code

ts · 30 lines
ts
import { ApolloLink, CombinedGraphQLErrors, HttpLink, ServerError } from "@apollo/client";
import { SetContextLink } from "@apollo/client/link/context";
import { ErrorLink } from "@apollo/client/link/error";
import { RetryLink } from "@apollo/client/link/retry";
import { from, switchMap } from "rxjs";
import { getAccessToken, refreshAccessToken } from "./auth";

let refreshing: Promise<void> | null = null;
const refreshOnce = () =>
  (refreshing ??= refreshAccessToken().finally(() => { refreshing = null; }));

const isExpired = (error: unknown) =>
  (ServerError.is(error) && error.statusCode === 401) ||
  (CombinedGraphQLErrors.is(error) &&
    error.errors.some((e) => e.extensions?.code === "UNAUTHENTICATED"));

const refreshLink = new ErrorLink(({ error, operation, forward }) => {
  if (!isExpired(error)) return;
  return from(refreshOnce()).pipe(switchMap(() => forward(operation)));
});

const retryLink = new RetryLink({
  attempts: { max: 3, retryIf: (error) => !(ServerError.is(error) && error.statusCode < 500) },
});

const authLink = new SetContextLink((prevContext) => ({
  headers: { ...prevContext.headers, authorization: `Bearer ${getAccessToken()}` },
}));

export const link = ApolloLink.from([refreshLink, retryLink, authLink, new HttpLink({ uri: "/graphql" })]);

go deeper

for a junior

Recall that an expired token surfaces as an error in the link chain, and that an ErrorLink can send the same operation again with forward(operation).

for a middle

Explain how a gateway 401 arrives as a ServerError while a GraphQL-level rejection arrives as CombinedGraphQLErrors, and how returning an Observable from the handler replays the operation.

for a senior

Demonstrate the ordering that makes the replay pick up the new token, single-flight refresh across concurrent panels, the one-replay limit, and keeping RetryLink from retrying 401s.

for a principal

Weigh reactive refresh on 401 against proactive refresh before expiry, and decide who owns the policy so that the auth and data layers do not drift apart.

## The failure being handled An internal analytics dashboard sends GraphQL through an API gateway that checks short-lived access tokens. When a token expires, the gateway rejects the request with **HTTP 401** before it reaches the GraphQL server. The user should not see an error. The client should obtain a new token and send the same operation again. In Apollo Client that recovery belongs in the **link chain**, so that no component has to know about it. How tokens are issued and refreshed is an authentication-protocol question. Here, `refreshAccessToken()` is a function the auth module provides. This question is about wiring it into Apollo's links. ## Recognising the expiry Apollo Client 4's `ErrorLink` handler receives a single `error`, not the 3.x `graphQLErrors`/`networkError` pair, together with `operation`, `forward` and, for GraphQL errors, `result`: - a gateway's plain-JSON `401` arrives as a **`ServerError`** with `statusCode === 401`; - if the GraphQL server rejects the token itself, the rejection arrives as **`CombinedGraphQLErrors`**, commonly with `extensions.code === "UNAUTHENTICATED"`. Check both with the static `is()` methods, because which one you get depends on who rejects the request and on the response content type. ## Replaying through the chain An `ErrorLink` handler that returns nothing lets the error continue up the chain. A handler that **returns an Observable** replaces the failed result with that Observable's results. Returning `forward(operation)` sends the operation through the rest of the chain again. Because the refresh is asynchronous, you wrap it with RxJS: `from(refreshOnce()).pipe(switchMap(() => forward(operation)))`. **Order is what makes this work.** `forward` re-runs only the links **after** the `ErrorLink`: | Chain | What the replay sends | |---|---| | `ErrorLink → RetryLink → SetContextLink → HttpLink` | `SetContextLink` runs again and reads the new token | | `SetContextLink → ErrorLink → HttpLink` | the context still holds the old header, so the replay fails with 401 again | ## Single-flight refresh A dashboard loads many panels at once, so one expiry fails many queries together. If each `ErrorLink` invocation called `refreshAccessToken()` itself, the client would run several refreshes in parallel, and with rotating refresh tokens those calls can invalidate each other. Keep one module-level promise: 1. The first 401 starts the refresh and stores the promise. 2. Every other 401 awaits the same promise. 3. When it settles, the promise is cleared, and each waiting operation replays with the new token. ## Keeping RetryLink out of the way Without a `retryIf`, `RetryLink`'s default attempts check retries **any** error, including a `ServerError` 401. With the default of five attempts, an expired token costs four delayed, useless retries before the `ErrorLink` sees it. Give `RetryLink` an `attempts.retryIf` that returns `false` for 4xx `ServerError`s, so it retries only what a retry can fix, such as dropped connections and 5xx answers. ## One expiry, step by step With the chain `ErrorLink → RetryLink → SetContextLink → HttpLink`, a token that expires while the dashboard is open plays out like this: 1. Six panel queries leave with the old token, and the gateway answers each with `401`. 2. Each `ServerError` travels up through `SetContextLink` and reaches `RetryLink`, whose `retryIf` declines to retry a 4xx. 3. The `ErrorLink` handler runs six times. The first call starts the refresh, and the other five await the same promise. 4. The refresh settles, and each handler's `switchMap` calls `forward(operation)`. 5. Each replay passes through `RetryLink` and then `SetContextLink`, which reads the new token, and reaches `HttpLink`. 6. The panels render. From the component's point of view, the queries simply took a little longer. ## Limits and edges - **One replay only.** If the replayed operation fails too, `ErrorLink` does not call its handler for it, to avoid an infinite loop. The error propagates, so treat a second 401 as "signed out". - **Refresh failure.** If `refreshAccessToken()` rejects, the Observable errors, the operation fails with that error, and the app should route to sign-in. - **Mutations.** A gateway 401 means the request never reached a resolver, so replaying a mutation is safe. A GraphQL-level rejection is safe to replay only if the server rejects before executing anything. - **Proactive refresh.** A `SetContextLink` setter can also refresh when the cached token is about to expire. That avoids most 401 round trips, and the `ErrorLink` remains the safety net.

  • Why does an Apollo Client 4 ErrorLink handler return from(refresh()).pipe(switchMap(...)) instead of awaiting the refresh?
    The handler must return synchronously: either nothing, to let the error continue, or an Observable to use in place of the failed result. It cannot be an `async` function whose Promise the link would wait on. Wrapping the refresh Promise with RxJS `from` and chaining `switchMap(() => forward(operation))` expresses "wait for the refresh, then replay" as the Observable the link subscribes to.
  • In Apollo Client 4, what happens if the replayed operation after a token refresh also comes back 401?
    `ErrorLink` does not call its handler for the result of a replay it started, which prevents an infinite refresh loop. The second 401 propagates to the hook as a `ServerError`, or as `CombinedGraphQLErrors` for a GraphQL-level rejection, and the app should treat it as a lost session and route to sign-in.

saying these in an interview costs you the question

  • Put SetContextLink before the refreshing ErrorLink; the replay picks up the new token anyway.
  • Let every failed query call the refresh endpoint on its own.
  • Make the ErrorLink handler async and await the refresh before returning.
  • RetryLink skips 4xx responses by default, so it never retries a 401.
  • ErrorLink keeps calling the handler on each replay until the token is valid.