fix(app): retry transport errors wrapped by the client (#49426)

This commit is contained in:
Luke Parker
2026-09-17 10:34:52 +10:00
committed by GitHub
Unverified
parent f6604cd367
commit 5a9448acbb
3 changed files with 88 additions and 3 deletions
@@ -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"]
+41
View File
@@ -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(
+8 -3
View File
@@ -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<T>(operation: () => Promise<T>, options: RetryOptions = {}): Promise<T> {