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";
|
import { apiRequest, setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "./request";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
setAccessToken("");
|
setAccessToken("");
|
||||||
setRefreshHandler(null);
|
setRefreshHandler(null);
|
||||||
setUnauthorizedHandler(null);
|
setUnauthorizedHandler(null);
|
||||||
@ -59,3 +60,27 @@ test("shares one refresh request across concurrent stale access token responses"
|
|||||||
expect(refreshCount).toBe(1);
|
expect(refreshCount).toBe(1);
|
||||||
expect(fetch).toHaveBeenCalledTimes(4);
|
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 TOKEN_KEY = "hyapp-admin.access-token";
|
||||||
const APP_CODE_KEY = "hyapp-admin.app-code";
|
const APP_CODE_KEY = "hyapp-admin.app-code";
|
||||||
const APP_CODE_HEADER = "X-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 accessToken = window.localStorage.getItem(TOKEN_KEY) || "";
|
||||||
let selectedAppCode = normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
|
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;
|
requestHeaders[APP_CODE_HEADER] = selectedAppCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetchWithNetworkRetry(url, {
|
||||||
method,
|
method,
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
|
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 || "请求失败";
|
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) {
|
function buildURL(path: string, query?: QueryParams) {
|
||||||
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
||||||
if (query) {
|
if (query) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user