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
- A request returns 401 (or
customErrorDetectorreturnstrue) - All subsequent requests queue while the refresh is in progress
- The refresh callback is called once regardless of how many requests are waiting
- On success: the new token is injected into all queued requests' headers, then they are replayed through the full
RetryManagerpipeline - On skip: the callback resolved with no usable token (
tokenomitted,null, orundefined) — see Opt out of refresh below - On failure: all queued requests are rejected with the refresh error
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
onTokenRefreshedoronTokenRefreshFailed - No update to the instance default auth header and no “failed refresh” short-circuit for the old token
- No
TokenRefreshFailedErrorfor 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();
}