跳到正文
曾亮懿 ZengLiangYi Blog

并发 401 下的 Token 刷新竞态:一个被低估的 Bug

多个请求同时收到 401 时,如何用单次刷新与等待队列避免 refresh token 轮换竞态,并确保刷新失败时所有请求都能正确结束。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

TL;DR: 当多个请求同时遇到 401 时,朴素实现会触发多次 Token 刷新,形成竞态。一个 isRefreshing 标志加订阅者队列就能把刷新合并为一次;但刷新失败时必须通知队列中的每个请求,否则它们返回的 Promise 会永久停在 pending 状态。

本文假设你熟悉 async/await、HTTP 拦截器(Axios 或 Fetch)和 JWT 认证基础。

问题:并发 401 不止一个

Token 刷新的第一版代码通常像这样:

// ❌ 朴素实现
axios.interceptors.response.use(null, async (error) => {
  if (error.response?.status === 401) {
    const newToken = await refreshToken();
    error.config.headers.Authorization = `Bearer ${newToken}`;
    return axios(error.config);
  }
  return Promise.reject(error);
});

单个请求失效时,这段代码足够用。但真实页面经常会同时请求用户信息、通知数量和业务数据。当 Token 在这些请求飞行途中失效时,每个 401 都会发起一次刷新:

Request A → 401 → refreshToken() ─┐
Request B → 401 → refreshToken()  │
Request C → 401 → refreshToken()  │← 同时触发多次刷新
Request D → 401 → refreshToken() ─┘

如果服务端采用 refresh token rotation,每次刷新都会使上一个 refresh token 失效。第一个请求刷新成功后,其余请求继续使用旧 Token,可能全部失败,最后把用户送回登录页。

心理模型:收银台排队

可以把并发请求想象成超市收银台:

  • 朴素实现中,每位顾客都单独去找店长。
  • 队列实现中,第一位顾客负责找店长,其他顾客原地排队;店长带回新 Token 后,所有人再继续结账。

实现这个模型只需要一个状态和一个队列:

let isRefreshing = false;
let subscribers: Subscriber[] = [];

实现带队列的刷新机制

1. 定义订阅者

// 字符串表示刷新成功,null 表示刷新失败
type Subscriber = (newToken: string | null) => void;

let isRefreshing = false;
let subscribers: Subscriber[] = [];

string | null 很重要:队列不仅需要接收成功结果,也必须收到失败结果。

2. 管理队列

function addSubscriber(callback: Subscriber) {
  subscribers.push(callback);
}

function notifySubscribers(newToken: string | null) {
  subscribers.forEach((callback) => callback(newToken));
  subscribers = [];
}

3. 调度刷新与重试

export async function handleUnauthorized<T>(
  doRefresh: () => Promise<string | null>,
  doRetry: (newToken: string) => Promise<T>,
  onFailure: () => void
): Promise<T> {
  // 已有刷新在进行:加入队列,等待同一次刷新结果
  if (isRefreshing) {
    return new Promise<T>((resolve, reject) => {
      addSubscriber((newToken) => {
        if (newToken) {
          doRetry(newToken).then(resolve).catch(reject);
        } else {
          reject(new Error('Token refresh failed'));
        }
      });
    });
  }

  isRefreshing = true;

  // 约定:doRefresh 捕获网络异常,并在失败时返回 null
  const newToken = await doRefresh();

  if (newToken) {
    notifySubscribers(newToken);
    isRefreshing = false;
    return doRetry(newToken);
  }

  notifySubscribers(null);
  isRefreshing = false;
  onFailure();
  return Promise.reject(new Error('Token refresh failed'));
}

这里的 doRefresh 应当把请求异常归一化为 null。如果它可能直接抛出异常,就要在调用处捕获,否则 isRefreshing 无法复位,等待队列也收不到结果:

const doRefresh = async () => {
  try {
    return await fetchNewToken();
  } catch {
    return null;
  }
};

4. 接入 Axios 拦截器

axios.interceptors.response.use(null, (error) => {
  const { response, config } = error;

  if (response?.status !== 401) return Promise.reject(error);
  if (config?.url?.includes('/auth/login')) return Promise.reject(error);

  // 刷新接口本身失败时不能再次进入刷新流程
  if (config?.url?.includes('/auth/refresh')) {
    clearStorage();
    window.location.href = '/login';
    return Promise.reject(error);
  }

  return handleUnauthorized(
    () => doRefresh(),
    (newToken) => {
      config.headers.Authorization = `Bearer ${newToken}`;
      return axios(config);
    },
    () => {
      clearStorage();
      window.location.href = '/login';
    }
  );
});

现在,同一批 401 只会触发一次刷新:

Request A → 401 → isRefreshing=false → 发起刷新
Request B → 401 → isRefreshing=true  → 加入队列
Request C → 401 → isRefreshing=true  → 加入队列
Request D → 401 → isRefreshing=true  → 加入队列

刷新成功 → notifySubscribers(newToken) → B、C、D 使用新 Token 重试

隐藏问题:永久 pending 的 Promise

不少实现会在刷新失败时直接清空队列:

// ❌ 队列中的 Promise 没有收到结果
isRefreshing = false;
subscribers = [];
onFailure();

队列里保存的是各个 Promise 的完成回调。直接丢弃这些回调,会让调用者拿到的 Promise 既不 resolve,也不 reject。

一个未决 Promise 并不必然永远占用内存;是否能被回收取决于外部是否仍持有引用。但从业务生命周期看,这些请求已经泄漏:加载状态无法结束,等待它们的组合 Promise 无法完成,调用方还可能继续保留相关闭包和资源。

正确做法是把失败也广播给所有订阅者:

// ✅ 每个等待请求都会进入 reject 分支
notifySubscribers(null);
isRefreshing = false;
onFailure();

这也是 Subscriber 使用 string | null,而不是只接受 string 的原因。

需要处理的边界情况

刷新接口自身的 401

刷新请求不能再次进入 handleUnauthorized(),否则会无限递归:

refreshToken() → 401 → handleUnauthorized() → refreshToken() → ...

登录和刷新端点应当在拦截器入口处直接排除。

多标签页

isRefreshing 是当前 JavaScript 上下文中的模块级状态,不能跨标签页或 iframe 自动同步。如果多个页面共享同一组凭证,可以使用 BroadcastChannel 或 SharedWorker 协调刷新结果。

状态复位顺序

在这套实现中,先通知当前队列,再把 isRefreshing 设回 false。这样通知过程仍属于当前刷新周期,不会在队列尚未分发完成时开启另一轮刷新。

刷新超时

队列没有内建超时。如果刷新请求可能长时间无响应,可以在 doRefresh 中加入超时,并统一返回 null:

const doRefresh = () =>
  Promise.race([
    fetchNewToken(),
    new Promise<null>((resolve) => {
      setTimeout(() => resolve(null), 10_000);
    }),
  ]);

取舍

优点 局限
无额外依赖,与 HTTP 客户端解耦 模块级状态不能跨标签页
同一时刻最多一次刷新 需要明确异常和超时契约
刷新成功与失败都能结束等待请求 重试仍需防止业务级无限循环

这套模式的重点不是“排队”本身,而是让一次刷新拥有完整的生命周期:成功时唤醒全部请求,失败时也结束全部请求。

相关代码与资料

分享:

相关文章

五种 AI 对话解析器:字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

AI SDK Provider 抽象:能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出,用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #TypeScript

SourceAdapter 内部链路:身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷,验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。