fix(api): replay requests after token refresh

This commit is contained in:
2026-07-02 12:08:56 +08:00
parent 5821a4d062
commit 51987882eb
4 changed files with 118 additions and 9 deletions
@@ -1,7 +1,7 @@
/** /**
* 认证刷新拦截器 * 认证刷新拦截器
* *
* 处理 401 错误,自动刷新 token 并重试原请求 * 处理 401 错误,自动刷新 token;原请求重放由 ofetch retry 机制完成
* *
*/ */
import type { FetchHook } from "ofetch"; import type { FetchHook } from "ofetch";
@@ -132,7 +132,7 @@ function withTestAccountFlag<T extends Record<string, unknown>>(
} }
/** /**
* 401 → 刷新 token 并重试 * 401 → 刷新 token,并允许 ofetch retry 重放原请求
*/ */
export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => { export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
const status = ctx.response?.status; const status = ctx.response?.status;
@@ -144,10 +144,15 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
const requestUrl = const requestUrl =
ctx.request instanceof Request ? ctx.request.url : ctx.request; ctx.request instanceof Request ? ctx.request.url : ctx.request;
const path = new URL(requestUrl).pathname; const path = new URL(requestUrl).pathname;
if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) return; if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) {
ctx.options.retry = false;
return;
}
// 已登录则刷新登录 token,否则刷新游客 token // 已登录则刷新登录 token,否则刷新游客 token
const isGuestMode = !AuthStorage.getInstance().hasLoginToken(); const hasLoginTokenResult = await AuthStorage.getInstance().hasLoginToken();
const isGuestMode =
!Result.isOk(hasLoginTokenResult) || !hasLoginTokenResult.data;
// 并发刷新:等待或启动 // 并发刷新:等待或启动
if (isRefreshing && refreshPromise) { if (isRefreshing && refreshPromise) {
@@ -177,10 +182,10 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
); );
} }
// 刷新成功:更新请求头的 token // 刷新成功:更新当前 optionsretry 时 tokenInterceptor 也会重新读取最新 token
if (ctx.request instanceof Request) { const headers = new Headers(ctx.options.headers);
ctx.request.headers.set("Authorization", `Bearer ${newToken}`); headers.set("Authorization", `Bearer ${newToken}`);
} ctx.options.headers = headers;
}; };
/** /**
@@ -20,6 +20,7 @@ const NO_TOKEN_PATHS: readonly string[] = [
ApiPath.emailLogin, ApiPath.emailLogin,
ApiPath.register, ApiPath.register,
ApiPath.guestLogin, ApiPath.guestLogin,
ApiPath.refresh,
]; ];
/** /**
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorage } from "unstorage";
import memoryDriver from "unstorage/drivers/memory";
import { AuthStorage } from "@/data/storage/auth";
import { SpAsyncUtil } from "@/utils";
import { createHttpClient } from "../http_client";
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
headers: { "Content-Type": "application/json" },
...init,
});
}
function callUrl(call: Parameters<typeof fetch>): URL {
const request = call[0];
return new URL(request instanceof Request ? request.url : String(request));
}
describe("createHttpClient", () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_API_BASE_URL = "https://api.example.test";
SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
AuthStorage._resetForTests();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("refreshes a business token and replays the original request once after 401", async () => {
const authStorage = AuthStorage.getInstance();
await authStorage.setLoginToken("old-login-token");
await authStorage.setRefreshToken("refresh-token");
const requestAuthHeaders: Array<string | null> = [];
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
const headers =
input instanceof Request ? input.headers : new Headers(init?.headers);
requestAuthHeaders.push(headers.get("Authorization"));
if (url.pathname === "/api/auth/refresh") {
return jsonResponse({
success: true,
data: {
token: "new-login-token",
refreshToken: "new-refresh-token",
},
});
}
if (
url.pathname === "/api/chat/history" &&
fetchMock.mock.calls.filter(
(call) => callUrl(call).pathname === "/api/chat/history",
).length === 1
) {
return jsonResponse(
{ message: "expired" },
{ status: 401, statusText: "Unauthorized" },
);
}
return jsonResponse({ success: true, data: { ok: true } });
});
vi.stubGlobal("fetch", fetchMock);
const http = createHttpClient();
await expect(http("/api/chat/history")).resolves.toEqual({
success: true,
data: { ok: true },
});
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([
"/api/chat/history",
"/api/auth/refresh",
"/api/chat/history",
]);
expect(requestAuthHeaders).toEqual([
"Bearer old-login-token",
null,
"Bearer new-login-token",
]);
});
});
+15 -1
View File
@@ -22,6 +22,17 @@ function chain(...fns: (FetchHook | undefined)[]): FetchHook {
}; };
} }
function shouldLetAuthRetryRun(ctx: {
response?: { status?: number };
options?: { retry?: number | false };
}): boolean {
return (
ctx.response?.status === ErrorCode.httpUnauthorized &&
typeof ctx.options?.retry === "number" &&
ctx.options.retry > 0
);
}
/** /**
* 创建 ofetch 实例 * 创建 ofetch 实例
*/ */
@@ -31,7 +42,8 @@ export function createHttpClient(): $Fetch {
return ofetch.create({ return ofetch.create({
baseURL: config.baseUrl, baseURL: config.baseUrl,
timeout: config.receiveTimeout, timeout: config.receiveTimeout,
retry: 0, // 401 刷新由 auth_refresh_interceptor 自定义处理 retry: 1, // 401 刷新ofetch 重放一次原请求
retryStatusCodes: [ErrorCode.httpUnauthorized],
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@@ -45,9 +57,11 @@ export function createHttpClient(): $Fetch {
authRefreshInterceptor.onResponseError, authRefreshInterceptor.onResponseError,
((ctx: unknown) => { ((ctx: unknown) => {
const c = ctx as { const c = ctx as {
options?: { retry?: number | false };
response?: { status?: number; _data?: unknown }; response?: { status?: number; _data?: unknown };
error?: Error; error?: Error;
}; };
if (shouldLetAuthRetryRun(c)) return;
const status = c.response?.status; const status = c.response?.status;
const code = const code =
status === ErrorCode.httpUnauthorized status === ErrorCode.httpUnauthorized