diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx
index b6f405c02..1f3158e8d 100644
--- a/packages/web/src/app/sessions/[id]/page.test.tsx
+++ b/packages/web/src/app/sessions/[id]/page.test.tsx
@@ -149,20 +149,20 @@ describe("SessionPage project polling", () => {
expect(fetch).toHaveBeenCalledWith(
"/api/projects",
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ expect.any(Object),
);
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/worker-1",
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ expect.any(Object),
);
expect(fetch).toHaveBeenCalledWith(
"/api/sessions?fresh=true",
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ expect.any(Object),
);
expect(fetch).toHaveBeenCalledWith(
"/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ expect.any(Object),
);
expect(
@@ -258,7 +258,7 @@ describe("SessionPage project polling", () => {
expect(fetch).toHaveBeenCalledWith(
"/api/sessions?project=my-app&fresh=true",
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ expect.any(Object),
);
});
@@ -336,7 +336,7 @@ describe("SessionPage project polling", () => {
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
});
- it("times out a stuck session fetch and replaces the infinite loader with an error state", async () => {
+ it("keeps retrying after a stuck session fetch times out before showing an error state", async () => {
global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/projects") {
@@ -377,12 +377,30 @@ describe("SessionPage project polling", () => {
});
await flushAsyncWork();
+ expect(screen.getByText("Loading session…")).toBeInTheDocument();
+ expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(20_000);
+ });
+ await flushAsyncWork();
+
+ expect(screen.getByText("Loading session…")).toBeInTheDocument();
+ expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(12_000);
+ });
+ await flushAsyncWork();
+
expect(screen.getByText("Failed to load session")).toBeInTheDocument();
expect(screen.getByText(/taking too long/i)).toBeInTheDocument();
expect(screen.queryByText("Loading session…")).not.toBeInTheDocument();
});
- it("shows a recoverable unavailable state when the first session request aborts", async () => {
+ it("does not show the route error after the first aborted session request", async () => {
+ let sessionFetches = 0;
+
global.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
@@ -396,6 +414,7 @@ describe("SessionPage project polling", () => {
}
if (url === "/api/sessions/worker-1") {
+ sessionFetches += 1;
return Promise.reject(new DOMException("Aborted", "AbortError"));
}
@@ -415,9 +434,24 @@ describe("SessionPage project polling", () => {
render();
await flushAsyncWork();
- expect(screen.getByText("Session unavailable")).toBeInTheDocument();
- expect(screen.getByText(/backend has not returned this session yet/i)).toBeInTheDocument();
- expect(screen.queryByText("Loading session…")).not.toBeInTheDocument();
+ expect(sessionFetches).toBe(1);
+ expect(screen.getByText("Loading session…")).toBeInTheDocument();
+ expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3_000);
+ });
+ await flushAsyncWork();
+
+ expect(sessionFetches).toBeGreaterThanOrEqual(3);
+ expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(4_000);
+ });
+ await flushAsyncWork();
+
+ expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0);
});
it("marks sidebar data as loading until the sessions list resolves", async () => {
diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx
index cbd7efb63..f16c5c365 100644
--- a/packages/web/src/app/sessions/[id]/page.tsx
+++ b/packages/web/src/app/sessions/[id]/page.tsx
@@ -84,6 +84,9 @@ let cachedProjects: ProjectInfo[] | null = null;
let cachedSidebarSessions: DashboardSession[] | null = null;
const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000;
const SESSION_FETCH_TIMEOUT_MS = 8000;
+const SESSION_LOAD_MAX_CONSECUTIVE_FAILURES = 4;
+const SESSION_LOAD_MAX_RETRY_ELAPSED_MS = 30_000;
+const SESSION_LOAD_RETRY_BACKOFF_MS = [1_000, 2_000, 4_000] as const;
const PROJECT_SIDEBAR_FETCH_TIMEOUT_MS = 5000;
const PROJECTS_FETCH_TIMEOUT_MS = 5000;
const validSessionStatuses = new Set(Object.values(SESSION_STATUS));
@@ -136,6 +139,19 @@ function isAbortLikeError(error: unknown): boolean {
return false;
}
+function isTransientSessionLoadError(error: unknown): boolean {
+ if (isAbortLikeError(error)) return true;
+ if (error instanceof Error) {
+ const message = error.message.toLowerCase();
+ return (
+ message.includes("timed out") ||
+ message.includes("network") ||
+ message.includes("failed to fetch")
+ );
+ }
+ return false;
+}
+
function getSessionLoadErrorMessage(error: Error): string {
const normalized = error.message.toLowerCase();
if (normalized.includes("timed out")) {
@@ -418,6 +434,22 @@ export default function SessionPage() {
const projectSessionsFetchControllerRef = useRef(null);
const sidebarFetchControllerRef = useRef(null);
const pageUnloadingRef = useRef(false);
+ const sessionLoadFailureCountRef = useRef(0);
+ const sessionLoadFirstFailureAtRef = useRef(null);
+ const sessionLoadRetryTimerRef = useRef | null>(null);
+
+ const clearSessionLoadRetry = useCallback(() => {
+ if (sessionLoadRetryTimerRef.current) {
+ clearTimeout(sessionLoadRetryTimerRef.current);
+ sessionLoadRetryTimerRef.current = null;
+ }
+ }, []);
+
+ const resetSessionLoadFailures = useCallback(() => {
+ sessionLoadFailureCountRef.current = 0;
+ sessionLoadFirstFailureAtRef.current = null;
+ clearSessionLoadRetry();
+ }, [clearSessionLoadRetry]);
// Keep prefixByProjectRef in sync so fetchProjectSessions (stable [] dep) reads latest map
useEffect(() => {
@@ -497,6 +529,7 @@ export default function SessionPage() {
fetchingSessionRef.current = true;
const controller = new AbortController();
sessionFetchControllerRef.current = controller;
+ let keepLoadingForRetry = false;
try {
const data = await fetchJsonWithTimeout(
`/api/sessions/${encodeURIComponent(id)}`,
@@ -510,8 +543,9 @@ export default function SessionPage() {
setRouteError(null);
setSessionMissing(false);
hasLoadedSessionRef.current = true;
+ resetSessionLoadFailures();
} catch (err) {
- if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) {
+ if (pageUnloadingRef.current || controller.signal.aborted) {
return;
}
const message = err instanceof Error ? err.message : "Failed to load session";
@@ -524,20 +558,55 @@ export default function SessionPage() {
setSessionMissing(true);
}
setLoading(false);
+ resetSessionLoadFailures();
return;
}
+
+ if (!hasLoadedSessionRef.current && isTransientSessionLoadError(err)) {
+ const failureCount = sessionLoadFailureCountRef.current + 1;
+ sessionLoadFailureCountRef.current = failureCount;
+ sessionLoadFirstFailureAtRef.current ??= Date.now();
+ const elapsedMs = Date.now() - sessionLoadFirstFailureAtRef.current;
+ const shouldKeepRetrying =
+ failureCount < SESSION_LOAD_MAX_CONSECUTIVE_FAILURES &&
+ elapsedMs < SESSION_LOAD_MAX_RETRY_ELAPSED_MS;
+
+ if (shouldKeepRetrying) {
+ const delay =
+ SESSION_LOAD_RETRY_BACKOFF_MS[
+ Math.min(failureCount - 1, SESSION_LOAD_RETRY_BACKOFF_MS.length - 1)
+ ];
+ keepLoadingForRetry = true;
+ setLoading(true);
+ console.warn("Session fetch failed transiently; retrying", {
+ sessionId: id,
+ failureCount,
+ retryInMs: delay,
+ error: err,
+ });
+ clearSessionLoadRetry();
+ sessionLoadRetryTimerRef.current = setTimeout(() => {
+ sessionLoadRetryTimerRef.current = null;
+ void fetchSession();
+ }, delay);
+ return;
+ }
+ }
+
console.error("Failed to fetch session:", err);
if (!hasLoadedSessionRef.current) {
setRouteError(err instanceof Error ? err : new Error("Failed to load session"));
}
} finally {
- setLoading(false);
+ if (!keepLoadingForRetry) {
+ setLoading(false);
+ }
fetchingSessionRef.current = false;
if (sessionFetchControllerRef.current === controller) {
sessionFetchControllerRef.current = null;
}
}
- }, [id]);
+ }, [clearSessionLoadRetry, id, resetSessionLoadFailures]);
const fetchProjectSessions = useCallback(async () => {
if (fetchingProjectSessionsRef.current) return;
@@ -727,11 +796,12 @@ export default function SessionPage() {
useEffect(() => {
return () => {
+ clearSessionLoadRetry();
sessionFetchControllerRef.current?.abort();
projectSessionsFetchControllerRef.current?.abort();
sidebarFetchControllerRef.current?.abort();
};
- }, []);
+ }, [clearSessionLoadRetry]);
if (loading) {
return (
@@ -816,6 +886,7 @@ export default function SessionPage() {
setRouteError(null);
setSessionMissing(false);
setLoading(true);
+ resetSessionLoadFailures();
void Promise.all([fetchProjects(), fetchSession(), fetchSidebarSessions()]);
},
}}
diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx
index cc8bb51f2..2b6b78e1c 100644
--- a/packages/web/src/components/ProjectSidebar.tsx
+++ b/packages/web/src/components/ProjectSidebar.tsx
@@ -634,6 +634,7 @@ function ProjectSidebarInner({
{!isDegraded ? (
{
e.stopPropagation();
onMobileClose?.();
@@ -659,6 +660,7 @@ function ProjectSidebarInner({
{!isDegraded && orchestratorLink && (
{
e.stopPropagation();
onMobileClose?.();
diff --git a/packages/web/src/components/__tests__/UpdateBanner.test.tsx b/packages/web/src/components/__tests__/UpdateBanner.test.tsx
index 79b97b05c..f744a680b 100644
--- a/packages/web/src/components/__tests__/UpdateBanner.test.tsx
+++ b/packages/web/src/components/__tests__/UpdateBanner.test.tsx
@@ -20,8 +20,30 @@ function mockVersionResponse(body: {
describe("UpdateBanner", () => {
let fetchMock: ReturnType;
+ let localStorageStore: Map;
beforeEach(() => {
+ localStorageStore = new Map();
+ Object.defineProperty(window, "localStorage", {
+ configurable: true,
+ writable: true,
+ value: {
+ getItem: (key: string) => localStorageStore.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ localStorageStore.set(key, value);
+ },
+ removeItem: (key: string) => {
+ localStorageStore.delete(key);
+ },
+ clear: () => {
+ localStorageStore.clear();
+ },
+ get length() {
+ return localStorageStore.size;
+ },
+ key: (index: number) => [...localStorageStore.keys()][index] ?? null,
+ },
+ });
window.localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
diff --git a/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts b/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts
index 19e5e4115..cfbf1a66d 100644
--- a/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts
+++ b/packages/web/src/hooks/__tests__/useSessionEvents.mux.test.ts
@@ -50,7 +50,7 @@ describe("useSessionEvents - mux", () => {
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/sessions?project=proj",
- expect.objectContaining({ signal: expect.any(AbortSignal), cache: "no-store" }),
+ expect.objectContaining({ cache: "no-store" }),
);
});
});
diff --git a/packages/web/src/lib/__tests__/client-fetch.test.ts b/packages/web/src/lib/__tests__/client-fetch.test.ts
new file mode 100644
index 000000000..0625478a9
--- /dev/null
+++ b/packages/web/src/lib/__tests__/client-fetch.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { __clearInflightFetchesForTest, dedupFetch, fetchJsonWithTimeout } from "../client-fetch";
+
+describe("dedupFetch", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ __clearInflightFetchesForTest();
+ });
+
+ it("shares one underlying request for concurrent requests with the same key", async () => {
+ let resolveFetch: ((response: Response) => void) | undefined;
+ const fetchMock = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const first = dedupFetch("/api/sessions/ao-187");
+ const second = dedupFetch("/api/sessions/ao-187");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
+ resolveFetch?.(response);
+
+ await expect(first.then((res) => res.json())).resolves.toEqual({ ok: true });
+ await expect(second.then((res) => res.json())).resolves.toEqual({ ok: true });
+ });
+
+ it("starts a new request after the in-flight request settles", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response("{}", { status: 200 }))
+ .mockResolvedValueOnce(new Response("{}", { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await dedupFetch("/api/sessions/ao-187");
+ await dedupFetch("/api/sessions/ao-187");
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not coalesce requests with different headers", async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response("{}", { status: 200 }))
+ .mockResolvedValueOnce(new Response("{}", { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await Promise.all([
+ dedupFetch("/api/sessions/ao-187", { headers: { Accept: "application/json" } }),
+ dedupFetch("/api/sessions/ao-187", { headers: { Accept: "text/plain" } }),
+ ]);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("aborts the underlying fetch when all waiters abort", async () => {
+ const controller = new AbortController();
+ let underlyingSignal: AbortSignal | undefined;
+ const fetchMock = vi.fn(
+ (_input: RequestInfo | URL, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ underlyingSignal = init?.signal ?? undefined;
+ init?.signal?.addEventListener("abort", () => {
+ reject(new DOMException("The operation was aborted.", "AbortError"));
+ });
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const request = dedupFetch("/api/sessions/ao-187", { signal: controller.signal });
+ controller.abort();
+
+ await expect(request).rejects.toMatchObject({ name: "AbortError" });
+ expect(underlyingSignal?.aborted).toBe(true);
+ });
+
+ it("times out when response headers arrive but the JSON body stalls", async () => {
+ vi.useFakeTimers();
+ const cancelBody = vi.fn();
+ const body = new ReadableStream({
+ cancel: cancelBody,
+ });
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(body, {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const request = fetchJsonWithTimeout("/api/sessions/ao-187", {
+ timeoutMs: 100,
+ timeoutMessage: "session timed out",
+ });
+ const expectation = expect(request).rejects.toThrow("session timed out");
+
+ await vi.advanceTimersByTimeAsync(100);
+
+ await expectation;
+ expect(cancelBody).toHaveBeenCalled();
+ });
+});
diff --git a/packages/web/src/lib/client-fetch.ts b/packages/web/src/lib/client-fetch.ts
index aede5f60d..03421d685 100644
--- a/packages/web/src/lib/client-fetch.ts
+++ b/packages/web/src/lib/client-fetch.ts
@@ -5,6 +5,215 @@ interface FetchJsonOptions extends RequestInit {
timeoutMessage?: string;
}
+interface InflightFetch {
+ controller: AbortController;
+ consumers: number;
+ maxConsumers: number;
+ promise: Promise;
+ settled: boolean;
+}
+
+const inflightFetches = new Map();
+
+function getFetchUrl(input: RequestInfo | URL): string {
+ if (typeof input === "string") return input;
+ if (input instanceof URL) return input.toString();
+ return input.url;
+}
+
+function getFetchMethod(input: RequestInfo | URL, init: RequestInit | undefined): string {
+ return (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
+}
+
+function hashBody(body: BodyInit | null | undefined): string {
+ if (body === null || body === undefined) return "";
+ if (typeof body === "string") return body;
+ if (body instanceof URLSearchParams) return body.toString();
+ if (body instanceof Blob) return `blob:${body.type}:${body.size}`;
+ if (body instanceof ArrayBuffer) return `array-buffer:${body.byteLength}`;
+ if (ArrayBuffer.isView(body)) {
+ return `${body.constructor.name}:${body.byteOffset}:${body.byteLength}`;
+ }
+ if (body instanceof FormData) {
+ return [...body.entries()]
+ .map(([key, value]) =>
+ typeof File !== "undefined" && value instanceof File
+ ? `${key}=file:${value.name}:${value.type}:${value.size}`
+ : `${key}=${value}`,
+ )
+ .join("&");
+ }
+ return body.constructor.name;
+}
+
+function hashHeaders(input: RequestInfo | URL, init: RequestInit | undefined): string {
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
+ if (init?.headers) {
+ new Headers(init.headers).forEach((value, key) => {
+ headers.set(key, value);
+ });
+ }
+ return [...headers.entries()]
+ .map(([key, value]) => `${key.toLowerCase()}:${value}`)
+ .sort()
+ .join("\n");
+}
+
+function getFetchKey(input: RequestInfo | URL, init: RequestInit | undefined): string {
+ return `${getFetchUrl(input)}|${getFetchMethod(input, init)}|${hashHeaders(input, init)}|${hashBody(init?.body)}`;
+}
+
+function cloneResponse(response: Response): Response {
+ if (typeof response.clone !== "function") {
+ throw new TypeError("Cannot clone deduplicated fetch response");
+ }
+ return response.clone();
+}
+
+function abortError(): DOMException {
+ return new DOMException("The operation was aborted.", "AbortError");
+}
+
+function throwIfAborted(signal: AbortSignal | undefined): void {
+ if (signal?.aborted) {
+ throw abortError();
+ }
+}
+
+async function readResponseText(response: Response, signal: AbortSignal | undefined): Promise {
+ throwIfAborted(signal);
+
+ if (!response.body) {
+ return typeof response.text === "function" ? await response.text() : "";
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ const chunks: string[] = [];
+
+ try {
+ while (true) {
+ throwIfAborted(signal);
+ let removeAbortListener: () => void = () => {};
+ const abortPromise =
+ signal === undefined
+ ? null
+ : new Promise((_, reject) => {
+ const abort = () => {
+ void reader.cancel().catch(() => {});
+ reject(abortError());
+ };
+ if (signal.aborted) {
+ abort();
+ return;
+ }
+ signal.addEventListener("abort", abort, { once: true });
+ removeAbortListener = () => signal.removeEventListener("abort", abort);
+ });
+
+ try {
+ const result = await Promise.race([
+ reader.read(),
+ ...(abortPromise ? [abortPromise] : []),
+ ]);
+ if (result.done) break;
+ chunks.push(decoder.decode(result.value, { stream: true }));
+ } finally {
+ removeAbortListener();
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ chunks.push(decoder.decode());
+ return chunks.join("");
+}
+
+async function readResponseJson(
+ response: Response,
+ signal: AbortSignal | undefined,
+): Promise {
+ throwIfAborted(signal);
+ if (!response.body && typeof response.json === "function") {
+ return (await response.json()) as T;
+ }
+ return JSON.parse(await readResponseText(response, signal)) as T;
+}
+
+export function dedupFetch(input: RequestInfo | URL, init?: RequestInit): Promise {
+ const key = getFetchKey(input, init);
+ let entry = inflightFetches.get(key);
+ if (!entry) {
+ const controller = new AbortController();
+ const { signal: _signal, ...sharedInit } = init ?? {};
+ const request = fetch(input, { ...sharedInit, signal: controller.signal });
+ const newEntry: InflightFetch = {
+ controller,
+ consumers: 0,
+ maxConsumers: 0,
+ promise: request,
+ settled: false,
+ };
+ request.finally(() => {
+ newEntry.settled = true;
+ inflightFetches.delete(key);
+ }).catch(() => {
+ // The original request promise is returned to callers; this side-effect
+ // chain only prevents an unhandled rejection from the cleanup branch.
+ });
+ entry = newEntry;
+ inflightFetches.set(key, entry);
+ }
+
+ entry.consumers += 1;
+ entry.maxConsumers = Math.max(entry.maxConsumers, entry.consumers);
+
+ const callerSignal = init?.signal ?? undefined;
+ let removeAbortListener: () => void = () => {};
+ let released = false;
+ const release = () => {
+ if (released) return;
+ released = true;
+ removeAbortListener();
+ entry.consumers -= 1;
+ if (entry.consumers === 0 && !entry.settled) {
+ entry.controller.abort();
+ }
+ };
+
+ const responsePromise = entry.promise.then((response) =>
+ entry.maxConsumers > 1 ? cloneResponse(response) : response,
+ );
+
+ const abortPromise =
+ callerSignal === undefined
+ ? null
+ : new Promise((_, reject) => {
+ const abort = () => {
+ release();
+ reject(abortError());
+ };
+ if (callerSignal.aborted) {
+ abort();
+ return;
+ }
+ callerSignal.addEventListener("abort", abort, { once: true });
+ removeAbortListener = () => callerSignal.removeEventListener("abort", abort);
+ });
+
+ return Promise.race([responsePromise, ...(abortPromise ? [abortPromise] : [])]).finally(
+ release,
+ );
+}
+
+export function __clearInflightFetchesForTest(): void {
+ for (const entry of inflightFetches.values()) {
+ entry.controller.abort();
+ }
+ inflightFetches.clear();
+}
+
function mergeAbortSignals(
signals: Array,
): AbortSignal | undefined {
@@ -26,14 +235,23 @@ function mergeAbortSignals(
return controller.signal;
}
-async function readErrorMessage(response: Response): Promise {
+async function readErrorMessage(
+ response: Response,
+ signal: AbortSignal | undefined,
+): Promise {
try {
- const payload = (await response.json()) as { error?: string; message?: string } | null;
+ const payload = await readResponseJson<{ error?: string; message?: string } | null>(
+ response,
+ signal,
+ );
const message = payload?.error ?? payload?.message;
if (typeof message === "string" && message.trim().length > 0) {
return message.trim();
}
- } catch {
+ } catch (error) {
+ if (signal?.aborted || (error instanceof DOMException && error.name === "AbortError")) {
+ throw error;
+ }
// Ignore parse failures and fall back to status text.
}
@@ -52,6 +270,7 @@ export async function fetchJsonWithTimeout(
const { timeoutMs = 8_000, timeoutMessage, signal, ...init } = options;
const timeoutController = new AbortController();
let timer: ReturnType | null = null;
+ let removeAbortListener: () => void = () => {};
let timedOut = false;
try {
@@ -68,9 +287,28 @@ export async function fetchJsonWithTimeout(
reject(new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
+ void timeoutPromise.catch(() => {});
+
+ const abortPromise =
+ mergedSignal === undefined
+ ? null
+ : new Promise((_, reject) => {
+ const abort = () => {
+ if (!timedOut) {
+ reject(abortError());
+ }
+ };
+ if (mergedSignal.aborted) {
+ abort();
+ return;
+ }
+ mergedSignal.addEventListener("abort", abort, { once: true });
+ removeAbortListener = () => mergedSignal.removeEventListener("abort", abort);
+ });
+ void abortPromise?.catch(() => {});
const response = await Promise.race([
- fetch(input, requestInit).catch((error: unknown) => {
+ dedupFetch(input, requestInit).catch((error: unknown) => {
if (timedOut) {
throw new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`, {
cause: error,
@@ -79,16 +317,32 @@ export async function fetchJsonWithTimeout(
throw error;
}),
timeoutPromise,
+ ...(abortPromise ? [abortPromise] : []),
]);
+ const readWithTimeout = (read: Promise): Promise =>
+ Promise.race([
+ read.catch((error: unknown) => {
+ if (timedOut) {
+ throw new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`, {
+ cause: error,
+ });
+ }
+ throw error;
+ }),
+ timeoutPromise,
+ ...(abortPromise ? [abortPromise] : []),
+ ]);
+
if (!response.ok) {
- throw new Error(await readErrorMessage(response));
+ throw new Error(await readWithTimeout(readErrorMessage(response, mergedSignal)));
}
- return (await response.json()) as T;
+ return await readWithTimeout(readResponseJson(response, mergedSignal));
} finally {
if (timer) {
clearTimeout(timer);
}
+ removeAbortListener();
}
}