Skip to content

Commit 5a9448a

Browse files
authored
fix(app): retry transport errors wrapped by the client (#49426)
1 parent f6604cd commit 5a9448a

3 files changed

Lines changed: 88 additions & 3 deletions

File tree

‎packages/app/src/runtime/server/global-sync/bootstrap.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createStore } from "solid-js/store"
55
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
66
import { ServerScope } from "@/runtime/server/scope"
77
import type { ServerApi } from "@/runtime/server/api"
8+
import { createServerTransport } from "@/runtime/server/client"
89
import type { ServerSync } from "@/runtime/server/sync"
910
import { worktreeInventoryKey } from "@/workspaces/inventory"
1011

@@ -64,6 +65,44 @@ test("bootstraps projects through the native store setter and preserves subseque
6465
}
6566
})
6667

68+
// Chromium aborts in-flight loopback requests with ERR_NETWORK_CHANGED when Windows reconfigures an
69+
// adapter; the client wraps that as ClientError("Transport"), which the bootstrap retry must see through.
70+
test("recovers project metadata after the connection to the server is dropped", async () => {
71+
const body = JSON.stringify([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
72+
let dropped = 0
73+
const requests: string[] = []
74+
const server = Bun.listen({
75+
hostname: "127.0.0.1",
76+
port: 0,
77+
socket: {
78+
open(socket) {
79+
if (dropped >= 2) return
80+
dropped += 1
81+
socket.terminate()
82+
},
83+
data(socket, chunk) {
84+
requests.push(String(chunk).split(" ")[0] ?? "")
85+
socket.end(
86+
`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}`,
87+
)
88+
},
89+
},
90+
})
91+
const transport = createServerTransport({ http: { url: `http://127.0.0.1:${server.port}` } })
92+
93+
try {
94+
const result = await new QueryClient({ defaultOptions: { queries: { retry: false } } }).fetchQuery(
95+
loadProjectsQuery(ServerScope.local, transport.api.project),
96+
)
97+
expect(dropped).toBe(2)
98+
// happy-dom's fetch adds a CORS preflight; only the GET is the retried API call.
99+
expect(requests.filter((method) => method === "GET")).toHaveLength(1)
100+
expect(result).toMatchObject([{ id: "project", worktree: "/repo" }])
101+
} finally {
102+
server.stop(true)
103+
}
104+
})
105+
67106
describe("query keys", () => {
68107
test("partitions identical directories by server scope", () => {
69108
const location = {} as ServerApi["location"]

‎packages/util/src/retry.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,47 @@ describe("client retry", () => {
1717
expect(failures).toHaveLength(3)
1818
})
1919

20+
test("retries a real network failure wrapped as the cause of a transport error", async () => {
21+
const server = Bun.listen({
22+
hostname: "127.0.0.1",
23+
port: 0,
24+
socket: {
25+
open(socket) {
26+
socket.terminate()
27+
},
28+
data() {},
29+
},
30+
})
31+
const failures: unknown[] = []
32+
try {
33+
await expect(
34+
retry(
35+
() =>
36+
fetch(`http://127.0.0.1:${server.port}/api/project`).catch((cause) => {
37+
failures.push(cause)
38+
throw new Error("Transport", { cause })
39+
}),
40+
{ delay: 0 },
41+
),
42+
).rejects.toThrow("Transport")
43+
} finally {
44+
server.stop(true)
45+
}
46+
expect(failures).toHaveLength(3)
47+
expect(failures.every((cause) => cause instanceof TypeError)).toBe(true)
48+
})
49+
50+
test("does not retry a transport error whose cause is not transient", async () => {
51+
const failures: string[] = []
52+
await expect(
53+
retry(async () => {
54+
failures.push("attempt")
55+
throw new Error("Transport", { cause: new Error("invalid response") })
56+
}),
57+
).rejects.toThrow("Transport")
58+
expect(failures).toHaveLength(1)
59+
})
60+
2061
test("does not retry other failures", async () => {
2162
const failures: string[] = []
2263
await expect(

‎packages/util/src/retry.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ const transientMessages = [
1717
"socket hang up",
1818
]
1919

20-
function isTransientError(error: unknown) {
20+
// Generated clients wrap fetch failures as ClientError("Transport", { cause }) and Bun/Node put the
21+
// network detail in `code`, so the transient signal may sit anywhere along the cause chain.
22+
function isTransientError(error: unknown, depth = 0): boolean {
2123
if (!error) return false
24+
const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : ""
2225
// oxlint-disable-next-line no-base-to-string -- Error input is intentionally normalized for message matching.
23-
const message = String(error instanceof Error ? error.message : error).toLowerCase()
24-
return transientMessages.some((item) => message.includes(item))
26+
const message = `${String(error instanceof Error ? error.message : error)} ${code}`.toLowerCase()
27+
if (transientMessages.some((item) => message.includes(item))) return true
28+
if (!(error instanceof Error) || depth >= 5) return false
29+
return isTransientError(error.cause, depth + 1)
2530
}
2631

2732
export async function retry<T>(operation: () => Promise<T>, options: RetryOptions = {}): Promise<T> {

0 commit comments

Comments
 (0)