Vue3项目并行请求触发401状态码的竞态问题处理咨询
Vue3 JWT认证请求竞态问题的解决办法
问题场景
开发基于Vue3的JWT认证项目时遇到以下问题:用户长时间未操作网站后,导航至某页面会同时发起3个数据请求用于页面渲染。第一个请求因token过期返回401错误,执行token刷新操作时,另外2个请求已携带旧token并行发送,同样返回401,需要处理这种竞态条件。
现有请求工具代码
import { getCookie, removeCookie, setCookie } from 'typescript-cookie'; import { useFetch } from '@vueuse/core'; import useUserStore from '@/store/useUserStore'; ... const BASE_API_URL = import.meta.env.VITE_APP_API; const IS_HTTP_ONLY_COOKIES = import.meta.env.VITE_HTTP_ONLY_COOKIES === 'true'; type RequestOptions = { url: string; method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'; params?: QueryParams; data?: JsonSerializable; contentType?: string; signal?: AbortSignal | null; }; export class FetchError extends Error { errors: Record<string, string[]>; statusCode: number; constructor( message: string, errors: Record<string, string[]>, statusCode: number ) { super(message); this.name = 'FetchError'; this.errors = errors; this.statusCode = statusCode; } } const request = async (options: RequestOptions): Promise<unknown> => { const { url, method, params, data, signal, contentType = 'application/json', } = options; if (signal?.aborted) { return {}; } let fullUrl = `${BASE_API_URL}${url}`; let body: string | undefined; if (contentType === 'application/json') { body = JSON.stringify(data); } const headers: HeadersInit = { 'Content-Type': contentType, 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Access-Control-Allow-Headers, Authorization', }; const accessToken = getCookie('access-token'); if (accessToken) { headers.Authorization = `Bearer ${accessToken}`; } const requestOptions: RequestInit = { method, headers, body, }; if (params && method === 'GET') { fullUrl += `?${params}`; } const response = await useFetch(fullUrl, requestOptions, { beforeFetch(ctx) { if (requestOptions.signal?.aborted) { ctx.cancel(); } return ctx; }, afterFetch(ctx) { if ( ctx.response.headers.get('Content-Type')?.includes('application/json') ) { ctx.data = JSON.parse(ctx.data); } return ctx; }, onFetchError(ctx) { ctx.error = JSON.parse(ctx.data); return ctx; }, }); if ( !IS_HTTP_ONLY_COOKIES && response.statusCode.value === 401 && getCookie('refresh-token') ) { try { const refreshResponse = await refreshTokens(); if (refreshResponse.statusCode.value === 200) { // eslint-disable-next-line camelcase const { access_token, refresh_token } = refreshResponse.data.value; setCookie('access-token', access_token); setCookie('refresh-token', refresh_token); return request(options); } if (refreshResponse.statusCode.value !== 200) { throw new Error('JWT error'); } } catch (e) { removeCookie('access-token'); removeCookie('refresh-token'); const userStore = useUserStore(); userStore.user = null; ElMessage.error('JWT error'); throw new Error('JWT error'); } } if (IS_HTTP_ONLY_COOKIES && response.statusCode.value === 401) { removeCookie('access-token'); const userStore = useUserStore(); userStore.user = null; ElMessage.error('JWT error.'); throw new Error('Error'); } if (response.statusCode.value === 200 || response.statusCode.value === 204) { return response.data; } if (response.statusCode.value === 422) { throw new FetchError('Request error', response.error.value.errors, 422); } if (response.statusCode.value === 402) { throw new FetchError('Request error', {}, 402); } throw new Error('Request error'); }; export default request;
解决方案
核心思路是用一个全局Promise变量标记是否正在执行token刷新操作,让后续的401请求等待刷新完成后再用新token重发,避免重复刷新和携带旧token请求。
1. 添加全局刷新状态变量
在request函数外部定义变量,保存当前的刷新请求Promise:
// 全局变量,标记是否正在刷新token,同时保存刷新请求的Promise let refreshTokenPromise: Promise<any> | null = null;
2. 修改401错误处理逻辑
检查是否已有刷新请求在进行,有则直接等待;没有则发起刷新并保存Promise,刷新完成后重置状态,最后用新token重发当前请求。
修改后的401分支代码:
if ( !IS_HTTP_ONLY_COOKIES && response.statusCode.value === 401 && getCookie('refresh-token') ) { try { // 如果没有正在进行的刷新请求,发起刷新并保存Promise if (!refreshTokenPromise) { refreshTokenPromise = refreshTokens(); } // 等待刷新完成 const refreshResponse = await refreshTokenPromise; if (refreshResponse.statusCode.value === 200) { // eslint-disable-next-line camelcase const { access_token, refresh_token } = refreshResponse.data.value; setCookie('access-token', access_token); setCookie('refresh-token', refresh_token); // 刷新成功后重置Promise状态 refreshTokenPromise = null; // 用新token重新发起当前请求 return request(options); } else { throw new Error('JWT error'); } } catch (e) { // 刷新失败也要重置Promise状态 refreshTokenPromise = null; removeCookie('access-token'); removeCookie('refresh-token'); const userStore = useUserStore(); userStore.user = null; ElMessage.error('JWT error'); throw new Error('JWT error'); } }
3. 完整修改后的request函数
import { getCookie, removeCookie, setCookie } from 'typescript-cookie'; import { useFetch } from '@vueuse/core'; import useUserStore from '@/store/useUserStore'; ... const BASE_API_URL = import.meta.env.VITE_APP_API; const IS_HTTP_ONLY_COOKIES = import.meta.env.VITE_HTTP_ONLY_COOKIES === 'true'; type RequestOptions = { url: string; method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'; params?: QueryParams; data?: JsonSerializable; contentType?: string; signal?: AbortSignal | null; }; export class FetchError extends Error { errors: Record<string, string[]>; statusCode: number; constructor( message: string, errors: Record<string, string[]>, statusCode: number ) { super(message); this.name = 'FetchError'; this.errors = errors; this.statusCode = statusCode; } } // 全局变量,标记是否正在刷新token,同时保存刷新请求的Promise let refreshTokenPromise: Promise<any> | null = null; const request = async (options: RequestOptions): Promise<unknown> => { const { url, method, params, data, signal, contentType = 'application/json', } = options; if (signal?.aborted) { return {}; } let fullUrl = `${BASE_API_URL}${url}`; let body: string | undefined; if (contentType === 'application/json') { body = JSON.stringify(data); } const headers: HeadersInit = { 'Content-Type': contentType, 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Access-Control-Allow-Headers, Authorization', }; const accessToken = getCookie('access-token'); if (accessToken) { headers.Authorization = `Bearer ${accessToken}`; } const requestOptions: RequestInit = { method, headers, body, }; if (params && method === 'GET') { fullUrl += `?${params}`; } const response = await useFetch(fullUrl, requestOptions, { beforeFetch(ctx) { if (requestOptions.signal?.aborted) { ctx.cancel(); } return ctx; }, afterFetch(ctx) { if ( ctx.response.headers.get('Content-Type')?.includes('application/json') ) { ctx.data = JSON.parse(ctx.data); } return ctx; }, onFetchError(ctx) { ctx.error = JSON.parse(ctx.data); return ctx; }, }); if ( !IS_HTTP_ONLY_COOKIES && response.statusCode.value === 401 && getCookie('refresh-token') ) { try { // 如果没有正在进行的刷新请求,发起刷新并保存Promise if (!refreshTokenPromise) { refreshTokenPromise = refreshTokens(); } // 等待刷新完成 const refreshResponse = await refreshTokenPromise; if (refreshResponse.statusCode.value === 200) { // eslint-disable-next-line camelcase const { access_token, refresh_token } = refreshResponse.data.value; setCookie('access-token', access_token); setCookie('refresh-token', refresh_token); // 刷新成功后重置Promise状态 refreshTokenPromise = null; // 用新token重新发起当前请求 return request(options); } else { throw new Error('JWT error'); } } catch (e) { // 刷新失败也要重置Promise状态 refreshTokenPromise = null; removeCookie('access-token'); removeCookie('refresh-token'); const userStore = useUserStore(); userStore.user = null; ElMessage.error('JWT error'); throw new Error('JWT error'); } } if (IS_HTTP_ONLY_COOKIES && response.statusCode.value === 401) { removeCookie('access-token'); const userStore = useUserStore(); userStore.user = null; ElMessage.error('JWT error.'); throw new Error('Error'); } if (response.statusCode.value === 200 || response.statusCode.value === 204) { return response.data; } if (response.statusCode.value === 422) { throw new FetchError('Request error', response.error.value.errors, 422); } if (response.statusCode.value === 402) { throw new FetchError('Request error', {}, 402); } throw new Error('Request error'); }; export default request;
原理说明
- 第一个请求触发401时,会创建
refreshTokenPromise并发起刷新请求,后续请求遇到401时直接等待该Promise完成,不会重复发起刷新 - 刷新完成后,所有等待的请求都会用新的access token重新发起,避免携带旧token导致的401错误
- 无论刷新成功还是失败,都会重置
refreshTokenPromise,保证后续请求能正常处理
内容的提问来源于stack exchange,提问作者Ruslan
相关产品推荐
相关产品推荐

