Retry transient admin read requests
This commit is contained in:
parent
6726a21e9c
commit
da85f07340
@ -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);
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user