From 5a9448acbb03aa82631330cf110870bd2b703069 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:34:52 +1000 Subject: [PATCH] fix(app): retry transport errors wrapped by the client (#49426) --- .../server/global-sync/bootstrap.test.ts | 39 ++++++++++++++++++ packages/util/src/retry.test.ts | 41 +++++++++++++++++++ packages/util/src/retry.ts | 11 +++-- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.test.ts b/packages/app/src/runtime/server/global-sync/bootstrap.test.ts index 7e8983ff878..82bd45b9ac0 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.test.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.test.ts @@ -5,6 +5,7 @@ import { createStore } from "solid-js/store" import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap" import { ServerScope } from "@/runtime/server/scope" import type { ServerApi } from "@/runtime/server/api" +import { createServerTransport } from "@/runtime/server/client" import type { ServerSync } from "@/runtime/server/sync" import { worktreeInventoryKey } from "@/workspaces/inventory" @@ -64,6 +65,44 @@ test("bootstraps projects through the native store setter and preserves subseque } }) +// Chromium aborts in-flight loopback requests with ERR_NETWORK_CHANGED when Windows reconfigures an +// adapter; the client wraps that as ClientError("Transport"), which the bootstrap retry must see through. +test("recovers project metadata after the connection to the server is dropped", async () => { + const body = JSON.stringify([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }]) + let dropped = 0 + const requests: string[] = [] + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + if (dropped >= 2) return + dropped += 1 + socket.terminate() + }, + data(socket, chunk) { + requests.push(String(chunk).split(" ")[0] ?? "") + socket.end( + `HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\naccess-control-allow-origin: *\r\nconnection: close\r\n\r\n${body}`, + ) + }, + }, + }) + const transport = createServerTransport({ http: { url: `http://127.0.0.1:${server.port}` } }) + + try { + const result = await new QueryClient({ defaultOptions: { queries: { retry: false } } }).fetchQuery( + loadProjectsQuery(ServerScope.local, transport.api.project), + ) + expect(dropped).toBe(2) + // happy-dom's fetch adds a CORS preflight; only the GET is the retried API call. + expect(requests.filter((method) => method === "GET")).toHaveLength(1) + expect(result).toMatchObject([{ id: "project", worktree: "/repo" }]) + } finally { + server.stop(true) + } +}) + describe("query keys", () => { test("partitions identical directories by server scope", () => { const location = {} as ServerApi["location"] diff --git a/packages/util/src/retry.test.ts b/packages/util/src/retry.test.ts index b84987269eb..0169b3b5506 100644 --- a/packages/util/src/retry.test.ts +++ b/packages/util/src/retry.test.ts @@ -17,6 +17,47 @@ describe("client retry", () => { expect(failures).toHaveLength(3) }) + test("retries a real network failure wrapped as the cause of a transport error", async () => { + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + socket.terminate() + }, + data() {}, + }, + }) + const failures: unknown[] = [] + try { + await expect( + retry( + () => + fetch(`http://127.0.0.1:${server.port}/api/project`).catch((cause) => { + failures.push(cause) + throw new Error("Transport", { cause }) + }), + { delay: 0 }, + ), + ).rejects.toThrow("Transport") + } finally { + server.stop(true) + } + expect(failures).toHaveLength(3) + expect(failures.every((cause) => cause instanceof TypeError)).toBe(true) + }) + + test("does not retry a transport error whose cause is not transient", async () => { + const failures: string[] = [] + await expect( + retry(async () => { + failures.push("attempt") + throw new Error("Transport", { cause: new Error("invalid response") }) + }), + ).rejects.toThrow("Transport") + expect(failures).toHaveLength(1) + }) + test("does not retry other failures", async () => { const failures: string[] = [] await expect( diff --git a/packages/util/src/retry.ts b/packages/util/src/retry.ts index a20a31a069b..42183bddf1e 100644 --- a/packages/util/src/retry.ts +++ b/packages/util/src/retry.ts @@ -17,11 +17,16 @@ const transientMessages = [ "socket hang up", ] -function isTransientError(error: unknown) { +// Generated clients wrap fetch failures as ClientError("Transport", { cause }) and Bun/Node put the +// network detail in `code`, so the transient signal may sit anywhere along the cause chain. +function isTransientError(error: unknown, depth = 0): boolean { if (!error) return false + const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "" // oxlint-disable-next-line no-base-to-string -- Error input is intentionally normalized for message matching. - const message = String(error instanceof Error ? error.message : error).toLowerCase() - return transientMessages.some((item) => message.includes(item)) + const message = `${String(error instanceof Error ? error.message : error)} ${code}`.toLowerCase() + if (transientMessages.some((item) => message.includes(item))) return true + if (!(error instanceof Error) || depth >= 5) return false + return isTransientError(error.cause, depth + 1) } export async function retry(operation: () => Promise, options: RetryOptions = {}): Promise {