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 客户端解耦 | 模块级状态不能跨标签页 |
| 同一时刻最多一次刷新 | 需要明确异常和超时契约 |
| 刷新成功与失败都能结束等待请求 | 重试仍需防止业务级无限循环 |
这套模式的重点不是“排队”本身,而是让一次刷新拥有完整的生命周期:成功时唤醒全部请求,失败时也结束全部请求。