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
@@ -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 实例
*/
@@ -31,7 +42,8 @@ export function createHttpClient(): $Fetch {
return ofetch.create({
baseURL: config.baseUrl,
timeout: config.receiveTimeout,
retry: 0, // 401 刷新由 auth_refresh_interceptor 自定义处理
retry: 1, // 401 刷新ofetch 重放一次原请求
retryStatusCodes: [ErrorCode.httpUnauthorized],
headers: {
"Content-Type": "application/json",
},
@@ -45,9 +57,11 @@ export function createHttpClient(): $Fetch {
authRefreshInterceptor.onResponseError,
((ctx: unknown) => {
const c = ctx as {
options?: { retry?: number | false };
response?: { status?: number; _data?: unknown };
error?: Error;
};
if (shouldLetAuthRetryRun(c)) return;
const status = c.response?.status;
const code =
status === ErrorCode.httpUnauthorized