Skip to content

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

Merged
Hona merged 1 commit into
anomalyco:v2from
Hona:retry-transport-cause
Sep 17, 2026
Merged

Hona merged 1 commit into
anomalyco:v2from
Hona:retry-transport-cause

Conversation

@Hona

@Hona Hona commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Refs #46651

The reporter's netlog shows the real failure: the sidecar passed readiness, then Chromium aborted the loopback bootstrap requests with ERR_NETWORK_CHANGED (Windows adapter reconfiguration right after login, or Wi-Fi/VPN flaps mid-session). That surfaces to JS as TypeError: Failed to fetch, and the app never recovered.

On v2 the bootstrap already wraps those requests in retry(...), but the retry never fired:

flowchart LR
  fetch["fetch()<br/>TypeError: Failed to fetch"] --> wrap["generated client<br/>ClientError Transport, cause = TypeError"]
  wrap --> retry["isTransientError<br/>reads error.message only"]
  retry --> give["message is Transport<br/>not transient, throw"]
Loading
  • packages/client/src/promise/generated/client.ts:311-318 wraps every fetch rejection as ClientError("Transport", { cause }), whose message is the literal "Transport".
  • packages/util/src/retry.ts matched transient text against error.message only, so the "Failed to fetch" on cause was invisible.
  • health.ts:65 already checks error.reason === "Transport"; retry.ts was never reconciled with the v2 client.

Measured through the real stack (createRequestQueue → OpenCode.make → retry) in Chrome 153 against a closed 127.0.0.1 port:

Path Attempts
retry(() => fetch(...)) — raw browser fetch 3
retry(() => api.project.list()) — app client stack 1

Fix

isTransientError now walks the cause chain and also reads code, which is where Bun/Node put the network detail (ECONNRESET with a generic message):

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 : ""
  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)
}

Affected call sites: bootstrap.ts:66, 152 and workspaces/location.tsx:40.

Tests use real sockets (a TCP listener that resets the first connections, then serves /api/project) rather than thrown fakes; both fail on the previous retry.ts.

Not addressed here

Unary request failures still do not trigger the sidecar reconnect path; only the SSE stream dropping does (packages/client/src/solid/connection.ts:147-183). That covers the "never reconnects until restart" report and is a separate change.

The generated client wraps every fetch failure as ClientError("Transport", { cause }),
so the message-based transient check in @opencode/util/retry never saw "Failed to fetch"
and gave up on the first dropped loopback request. Walk the cause chain and include
Node/Bun error codes.

Refs anomalyco#46651
Copilot AI lite review requested due to automatic review settings September 17, 2026 00:07
@Hona
Hona requested a review from Brendonovich as a code owner September 17, 2026 00:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Hona
Hona merged commit 5a9448a into anomalyco:v2 Sep 17, 2026
12 checks passed
jinhuang712 pushed a commit to jinhuang712/opencode that referenced this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants