Docs Plugins

TokenRefreshPlugin

Automatically refreshes access tokens when requests fail with 401. Queues all concurrent requests during the refresh and replays them transparently once a new token is available.

Install

import { createTokenRefreshPlugin } from 'axios-retryer/plugins/TokenRefreshPlugin';

Basic usage

import { createRetryer } from 'axios-retryer';
import { createTokenRefreshPlugin } from 'axios-retryer/plugins/TokenRefreshPlugin';

const retryer = createRetryer({ retries: 3 }).use(
  createTokenRefreshPlugin(
    // Refresh callback — called once per refresh cycle regardless of how many requests are queued
    async (axiosInstance) => {
      const refreshToken = localStorage.getItem('refreshToken');
      if (!refreshToken) throw new Error('No refresh token');  // Aborts immediately

      const { data } = await axiosInstance.post('/auth/refresh', { refreshToken });
      localStorage.setItem('accessToken', data.accessToken);
      return { token: data.accessToken };
    }
  ),
);

All options

import { createRetryer } from 'axios-retryer';
import { createTokenRefreshPlugin } from 'axios-retryer/plugins/TokenRefreshPlugin';

const retryer = createRetryer({ retries: 3 }).use(
  createTokenRefreshPlugin(
    async (axiosInstance) => {
      const { data } = await axiosInstance.post('/auth/refresh');
      return { token: data.accessToken };
    },
    {
      authHeaderName: 'Authorization',  // Header to inject the token into (default: 'Authorization')
      tokenPrefix: 'Bearer ',           // Prefix prepended to the token value (default: 'Bearer ')
      refreshStatusCodes: [401],        // HTTP status codes that trigger a refresh (default: [401])
      maxRefreshAttempts: 3,            // Max refresh attempts before giving up (default: 3)
      // Note: in v2.x this is the exact number of attempts (v1.x had an off-by-one bug)

      // Optional: detect auth errors inside 200 OK bodies (e.g. GraphQL)
      customErrorDetector: (responseData) => {
        if (typeof responseData !== 'object' || !responseData?.errors) return false;
        return responseData.errors.some((e: any) =>
          e.extensions?.code === 'UNAUTHENTICATED' ||
          e.message?.includes('token expired')
        );
      },
    }
  ),
);

How the refresh flow works

  1. A request returns 401 (or customErrorDetector returns true)
  2. All subsequent requests queue while the refresh is in progress
  3. The refresh callback is called once regardless of how many requests are waiting
  4. On success: the new token is injected into all queued requests' headers, then they are replayed through the full RetryManager pipeline
  5. On skip: the callback resolved with no usable token (token omitted, null, or undefined) — see Opt out of refresh below
  6. On failure: all queued requests are rejected with the refresh error
ℹ️
Replayed requests go through RetryManager

In v2.x, replayed requests re-enter the full RetryManager pipeline — they respect the concurrency queue, MetricsPlugin, and other plugin hooks. This was a bug in v1.x where replayed requests bypassed the manager entirely.

GraphQL — customErrorDetector

GraphQL APIs often return auth errors in a successful 200 body. Use customErrorDetector to catch these:

createTokenRefreshPlugin(
  async (axios) => {
    const { data } = await axios.post('/graphql/refresh');
    return { token: data.accessToken };
  },
  {
    customErrorDetector: (body) => {
      if (!body?.errors) return false;
      return body.errors.some((e: any) =>
        e.extensions?.code === 'UNAUTHENTICATED'
      );
    },
  }
)

Abort on missing token

Throwing from the refresh callback before any network call aborts all queued requests immediately and does not count as a retry attempt:

createTokenRefreshPlugin(async (axios) => {
  const token = sessionStorage.getItem('refreshToken');
  if (!token) {
    // No retry — redirect to login right away
    window.location.href = '/login';
    throw new Error('No refresh token available');
  }
  const { data } = await axios.post('/auth/refresh', { token });
  return { token: data.accessToken };
})

Opt out of refresh (null / undefined token)

Sometimes you want to decide after async work that this cycle should not refresh (without treating it as a hard failure). If the callback resolves with { token: null }, { token: undefined }, or omits token entirely, the plugin skips that refresh cycle:

  • No onTokenRefreshed or onTokenRefreshFailed
  • No update to the instance default auth header and no “failed refresh” short-circuit for the old token
  • No TokenRefreshFailedError for the refresh queue — this is not an exhausted-refresh failure
  • 401 path: the request that triggered refresh rejects with the original AxiosError (same 401)
  • Requests waiting on the request interceptor (blocked because a refresh was in flight) are released with their config unchanged so they continue to the network
  • Other requests already waiting as 401 follow-ups reject with their original AxiosError
  • customErrorDetector / 200 responses: the handler returns the same response object (e.g. GraphQL error body); queued parallel calls get their stored response the same way

Use throw (or TokenRefreshAbortError) when you want an immediate terminal failure; use resolve with no token when you want a silent no-op for this cycle.

// Resolve with no token to skip this refresh cycle (not a failure)
createTokenRefreshPlugin(async (axiosInstance) => {
  const allow = await shouldAttemptRefresh();
  if (!allow) {
    return { token: undefined }; // same as { token: null } or {}
  }
  const { data } = await axiosInstance.post('/auth/refresh');
  return { token: data.accessToken };
});

Plugin events

import { createRetryer } from 'axios-retryer';
import { createTokenRefreshPlugin } from 'axios-retryer/plugins/TokenRefreshPlugin';

const managed = createRetryer().use(
  createTokenRefreshPlugin(async (axiosInstance) => {
    const { data } = await axiosInstance.post('/auth/refresh');
    return { token: data.accessToken };
  }),
);

managed.on('onTokenRefreshed', (token) => {
  console.log('New token:', token);
});

Error classes

import {
  TokenRefreshAbortError,     // Callback threw before making the request
  TokenRefreshFailedError,    // All refresh attempts exhausted
  TokenRefreshTimeoutError,   // Refresh request timed out
  MissingTokenRefreshHandlerError, // No callback provided
} from 'axios-retryer/plugins/TokenRefreshPlugin';

// `retryer` = manager with TokenRefreshPlugin (see basic usage above)
try {
  await retryer.axiosInstance.get('/api/protected');
} catch (err) {
  if (err instanceof TokenRefreshAbortError) redirectToLogin();
  if (err instanceof TokenRefreshFailedError) showRefreshFailedBanner();
}
CircuitBreakerPlugin → All Plugins