From da85f073409281480ed499dc5b44606bf36529bc Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 2 Jul 2026 15:46:41 +0800 Subject: [PATCH] Retry transient admin read requests --- src/shared/api/request.test.ts | 25 +++++++++++++++++++++++++ src/shared/api/request.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/shared/api/request.test.ts b/src/shared/api/request.test.ts index ca38592..96c7337 100644 --- a/src/shared/api/request.test.ts +++ b/src/shared/api/request.test.ts @@ -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); +}); diff --git a/src/shared/api/request.ts b/src/shared/api/request.ts index d2e9140..eefe9b5 100644 --- a/src/shared/api/request.ts +++ b/src/shared/api/request.ts @@ -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( 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) {