mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(app): retry transport errors wrapped by the client (#49426)
This commit is contained in:
committed by
GitHub
Unverified
parent
f6604cd367
commit
5a9448acbb
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user