You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 03:24:54