fix(api): replay requests after token refresh
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 认证刷新拦截器
|
||||
*
|
||||
* 处理 401 错误,自动刷新 token 并重试原请求。
|
||||
* 处理 401 错误,自动刷新 token;原请求重放由 ofetch retry 机制完成。
|
||||
*
|
||||
*/
|
||||
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) => {
|
||||
const status = ctx.response?.status;
|
||||
@@ -144,10 +144,15 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
|
||||
const requestUrl =
|
||||
ctx.request instanceof Request ? ctx.request.url : ctx.request;
|
||||
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
|
||||
const isGuestMode = !AuthStorage.getInstance().hasLoginToken();
|
||||
const hasLoginTokenResult = await AuthStorage.getInstance().hasLoginToken();
|
||||
const isGuestMode =
|
||||
!Result.isOk(hasLoginTokenResult) || !hasLoginTokenResult.data;
|
||||
|
||||
// 并发刷新:等待或启动
|
||||
if (isRefreshing && refreshPromise) {
|
||||
@@ -177,10 +182,10 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => {
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新成功:更新请求头的 token
|
||||
if (ctx.request instanceof Request) {
|
||||
ctx.request.headers.set("Authorization", `Bearer ${newToken}`);
|
||||
}
|
||||
// 刷新成功:更新当前 options;retry 时 tokenInterceptor 也会重新读取最新 token。
|
||||
const headers = new Headers(ctx.options.headers);
|
||||
headers.set("Authorization", `Bearer ${newToken}`);
|
||||
ctx.options.headers = headers;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ const NO_TOKEN_PATHS: readonly string[] = [
|
||||
ApiPath.emailLogin,
|
||||
ApiPath.register,
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user