fix(api): replay requests after token refresh
This commit is contained in:
@@ -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