Skip to content

🧐[问题 | question or bug?] How to implement refresh token with interceptors #11550

@williamfitzGH

Description

@williamfitzGH

🧐 问题描述 | Problem description

Originally I would use the response interceptors to intercept the response, detect the 401 and trigger a new request to generate a new access token via refresh and then retry the initial request and respond accordingly.. this way, the original requester will wait on the resp instead of going into the catch (via try/catch).

Issue is, only 2XX status codes are triggering the response interceptor? Therefore, I need to rely on the errorHandler and I am facing weird timing issues whereby the initial request is getting the original 401 response, causing it to except (go into catch), then once that completes it goes back and gets my refresh and my logic that should have fired before the catch is firing after! Very strange behaviour. Looking for support on how to correctly implement this. All original solutions I could locate, all used the response interceptor. I also use to use the response interceptor. So not sure if this is a bug or intended behaviour now with the latest version but I cant for the life of me, figure this out.

💻 示例代码 | Sample code

In my app.tsx:

/**
 * @see https://umijs.org/docs/api/runtime-config#getinitialstate
 * */
export async function getInitialState(): Promise<{
    settings?: Partial<LayoutSettings>;
    currentUser?: API.CurrentUser;
    loading?: boolean;
    fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
}> {
    let currentUser: API.CurrentUser | undefined;
    let loading = true;

    const fetchUserInfo = async () => {
        try {
            const msg = await getCurrentUser(); // triggers refresh if expired
            return msg.data;
        } catch (_error) {
            // ISSUE HERE, we except before the interception finishes!
            return undefined; // fail silently
        } finally {
            loading = false; // stop loading when done
        }
    };

    const {location} = history;
    if (![loginPath, '/user/register', '/user/register-result'].includes(location.pathname)) {
        currentUser = await fetchUserInfo();
    }

    return {
        fetchUserInfo,
        currentUser,
        loading,
        settings: defaultSettings as Partial<LayoutSettings>,
    };
}

/**
 * @name request configuration, can configure error handling
 * It provides a unified network request and error handling solution based on axios and a hooks' useRequest.
 * @doc https://umijs.org/docs/max/request#configuration
 */
export const request: RequestConfig = {
    baseURL: '/',
    ...errorConfig
};

// errorConfig (don't mind the refresh token in local storage, just for testing purposes!):

import type {RequestOptions} from '@@/plugin-request/request';
import {history, request, RequestConfig} from '@umijs/max';
import {message, notification} from 'antd';
import {refreshAccessToken} from "@/services/user/auth";

// Error handling scheme: error types
enum ErrorShowType {
    SILENT = 0,
    WARN_MESSAGE = 1,
    ERROR_MESSAGE = 2,
    NOTIFICATION = 3,
    REDIRECT = 9,
}

// Response data format agreed with the backend
interface ResponseStructure {
    success: boolean;
    data: any;
    errorCode?: number;
    errorMessage?: string;
    showType?: ErrorShowType;
}

// Track refresh state globally
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
let pendingQueue: ((token: string | null) => void)[] = [];

// Helper: run queued requests once refresh completes
const processQueue = (token: string | null) => {
    pendingQueue.forEach((cb) => cb(token));
    pendingQueue = [];
};

/**
 * Error handling
 * Built-in error handling of pro, you can make your own changes here
 * @doc https://umijs.org/docs/max/request#configuration
 */
export const errorConfig: RequestConfig = {
    // Error handling: error handling scheme of umi@3.
    errorConfig: {
        // Error thrower
        errorThrower: (res) => {
            const {success, errorMessage} = res as ResponseStructure;

            // Only throw for business errors (success = false), not for HTTP status errors.
            if (!success) {
                const error: any = new Error(errorMessage);
                error.name = 'BizError';
                error.info = res;
                throw error;
            }

            // Let HTTP errors be handled downstream by errorHandler
        },
        //Error receiver and handler
        errorHandler: async (error: any, opts: any) => {
            if (opts?.skipErrorHandler) throw error;
            // Error thrown by our errorThrower.
            if (error.name === 'BizError') {
                const errorInfo: ResponseStructure | undefined = error.info;
                if (errorInfo) {
                    const {errorMessage, errorCode} = errorInfo;
                    switch (errorInfo.showType) {
                        case ErrorShowType.SILENT:
                            // do nothing
                            break;
                        case ErrorShowType.WARN_MESSAGE:
                            void message.warning(errorMessage);
                            break;
                        case ErrorShowType.ERROR_MESSAGE:
                            void message.error(errorMessage);
                            break;
                        case ErrorShowType.NOTIFICATION:
                            notification.open({
                                description: errorMessage,
                                message: errorCode,
                            });
                            break;
                        case ErrorShowType.REDIRECT:
                            // TODO: redirect
                            break;
                        default:
                            void message.error(errorMessage);
                    }
                }
                return;
            }

            // Handle HTTP errors
            if (error.response) {
                const {status, config} = error.response;

                if (status === 401) {
                    // We remove access_token so we do not override our request to refresh token endpoint
                    // (since we use the refresh token in header)
                    localStorage.removeItem('access_token');
                    const refreshToken = localStorage.getItem('refresh_token');
                    if (!refreshToken) {
                        if (history.location.pathname !== '/user/login') {
                            history.push('/user/login');
                        }
                        return Promise.reject(error); // ensure caller sees rejection
                    }

                    // If already refreshing, queue this request
                    if (isRefreshing && refreshPromise) {
                        return new Promise<any>((resolve, reject) => {
                            pendingQueue.push((newToken) => {
                                if (!newToken) {
                                    reject(error); // refresh failed
                                    return;
                                }
                                request(config.url!, {
                                    ...config,
                                    skipErrorHandler: true,
                                })
                                    .then(resolve)
                                    .catch(reject);
                            });
                        });
                    }

                    // First failing request triggers the refresh
                    if (!refreshPromise) {
                        isRefreshing = true;
                        refreshPromise = refreshAccessToken(refreshToken)
                            .then(data => {
                                if (data.access_token) {
                                    localStorage.setItem('access_token', data.access_token);
                                    processQueue(data.access_token);
                                    return data.access_token;
                                } else {
                                    processQueue(null);
                                    localStorage.removeItem('access_token');
                                    localStorage.removeItem('refresh_token');
                                    if (history.location.pathname !== '/user/login') {
                                        history.push('/user/login');
                                    }
                                    return null;
                                }
                            })
                            .catch(() => {
                                processQueue(null);
                                localStorage.removeItem('access_token');
                                localStorage.removeItem('refresh_token');
                                if (history.location.pathname !== '/user/login') {
                                    history.push('/user/login');
                                }
                                return null;
                            })
                            .finally(() => {
                                isRefreshing = false;
                                refreshPromise = null;
                            });
                    }

                    // Wait for the refresh to complete
                    const newToken = await refreshPromise;
                    if (!newToken) return Promise.reject(error);

                    // Retry original request
                    return request(config.url!, {
                        ...config,
                        skipErrorHandler: true,
                    });

                } else if (status === 403) {
                    // Do nothing for 403
                    return;
                }

                // other non-2xx HTTP codes
                return;
            } else if (error.request) {
                // The request was made but no response was received
                // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
                // http.ClientRequest in node.js
                void message.error('No response received! Please retry.');
            } else {
                // Something happened in setting up the request that triggered an Error
                void message.error('Request error, please retry.');
            }
        }
    },

    // Request interceptors
    requestInterceptors: [
        (config: RequestOptions) => {
            // Allow per-request `prefix` override
            // If config.prefix is present (set from API call), use it to rebuild URL
            if (config.prefix) {
                config.url = `${config.prefix}${config.url}`;
                delete config.prefix; // Prevent double prefixing later
            }

            // Always inject latest access token
            const token = localStorage.getItem('access_token');
            if (token) {
                config.headers = {
                    ...config.headers,
                    Authorization: `Bearer ${token}`,
                };
            }

            return config;
        }
    ],

    // Response interceptors
    // Only triggers for 2xx HTTP status codes
    responseInterceptors: [
        (response) => {
            const resData = response.data as ResponseStructure;

            // Handle backend errors
            if (resData?.success === false) {
                void message.error(resData.errorMessage || 'Request failed!');
            }

            return response;
        },
    ],
};

🚑 其他信息 | Other information

OS: Mac OS Sequoia V15.5

Node:v20.19.4

浏览器 | browser:Google Chrome: Version 139.0.7258.139 (Official Build) (arm64)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions