Retry transient admin read requests

This commit is contained in:
zhx 2026-07-02 15:46:41 +08:00
parent 6726a21e9c
commit da85f07340
2 changed files with 56 additions and 1 deletions

View File

@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
import { apiRequest, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "./request";
afterEach(() => {
vi.useRealTimers();
setAccessToken("");
setRefreshHandler(null);
setUnauthorizedHandler(null);
@ -59,3 +60,27 @@ test("shares one refresh request across concurrent stale access token responses"
expect(refreshCount).toBe(1);
expect(fetch).toHaveBeenCalledTimes(4);
});
test("retries idempotent reads after a transient network failure", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi
.fn()
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValueOnce(new Response(JSON.stringify({ code: 0, data: { ok: true } })))
);
const request = apiRequest("/v1/admin/apps");
await vi.advanceTimersByTimeAsync(350);
await expect(request).resolves.toEqual({ ok: true });
expect(fetch).toHaveBeenCalledTimes(2);
});
test("does not replay mutations after a network failure", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new TypeError("Failed to fetch")));
await expect(apiRequest("/v1/admin/countries", { body: {}, method: "POST" })).rejects.toThrow("Failed to fetch");
expect(fetch).toHaveBeenCalledTimes(1);
});

View File

@ -3,6 +3,8 @@ import { API_BASE_URL } from "@/shared/config/env.js";
const TOKEN_KEY = "hyapp-admin.access-token";
const APP_CODE_KEY = "hyapp-admin.app-code";
const APP_CODE_HEADER = "X-App-Code";
const NETWORK_RETRY_ATTEMPTS = 2;
const NETWORK_RETRY_DELAY_MS = 350;
let accessToken = window.localStorage.getItem(TOKEN_KEY) || "";
let selectedAppCode = normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
@ -102,7 +104,7 @@ export async function apiRequest<TData = unknown, TBody = unknown>(
requestHeaders[APP_CODE_HEADER] = selectedAppCode;
}
const response = await fetch(url, {
const response = await fetchWithNetworkRetry(url, {
method,
headers: requestHeaders,
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
@ -155,6 +157,34 @@ function resolveErrorMessage(response: Response, message?: string) {
return message || response.statusText || "请求失败";
}
async function fetchWithNetworkRetry(url: string, init: RequestInit) {
const method = String(init.method || "GET").toUpperCase();
const maxAttempts = method === "GET" || method === "HEAD" ? NETWORK_RETRY_ATTEMPTS + 1 : 1;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await fetch(url, init);
} catch (error) {
lastError = error;
if (attempt >= maxAttempts) {
break;
}
// GAAP occasionally resets the browser HTTP/2 stream before a response exists; retry only idempotent reads.
await delay(NETWORK_RETRY_DELAY_MS * attempt);
}
}
throw lastError;
}
function delay(ms: number) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function buildURL(path: string, query?: QueryParams) {
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
if (query) {