anomalyco / anomalyco/opencode

Desktop: `Cannot connect to API` + `AggregateError` on high-latency providers (z.ai/GLM, DashScope/Qwen) — Node’s 250 ms connect-attempt cap; same provider works in the CLI

Open
#45,178 1 comment 0 reactions 1 assignee View on GitHub

@Brendonovich is already working on this.

Since Aug 26, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

On the Desktop app, every request to a provider whose TCP handshake takes longer than 250 ms fails at connect time with:

AI_APICallError: Cannot connect to API:  (cause: AggregateError)

The same provider, same machine, same API key works fine in the CLI. The cause is not the network, the key, or the provider: it is Node's Happy Eyeballs default, net.autoSelectFamilyAttemptTimeout = 250 ms. The Desktop app runs on Electron/Node (undici), so the cap applies. The CLI is built on Bun, which has no such cap — hence the confusing asymmetry.

For me this made zai-coding-plan/glm-5.3 look permanently broken in the Desktop app while it worked in the CLI on the same host.

Environment

  • OpenCode Desktop 1.18.23 (Electron 42.3.3, Node 24.15.0, undici 7.24.4)
  • OpenCode CLI 1.18.23 (Bun) on the same host — unaffected
  • Linux, IPv4-only egress (no global IPv6 address)
  • Providers: zai-coding-plan (api.z.ai), alibaba-token-plan (dashscope.aliyuncs.com)

Symptom

In ~/.local/share/opencode/log/opencode.log, every attempt fails and is retried with backoff (~2.8s / 5.5s / 10s / 20s / 30s) until the turn dies:

level=ERROR message="stream error" providerID=zai-coding-plan modelID=glm-5.3 \
  error.error="AI_APICallError: Cannot connect to API:  (cause: AggregateError)"

Two details make this hard to diagnose:

  • The error says "Cannot connect", so it reads as a network or DNS outage.
  • Other providers keep working in the same seconds. In one 66-second window, claude-opus-5 and claude-sonnet-5 streamed without a single error while glm-5.3 failed 6/6. That asymmetry sends you looking for a routing problem that isn't there.

Root cause

AggregateError carries one entry per address tried:

ETIMEDOUT@47.245.163.4
ETIMEDOUT@47.245.170.100
ENETUNREACH@240b:4005:115:d90c:21db:4065:f33f:7db
ENETUNREACH@240b:4005:115:d950:857e:ac8:4d94:3b98

The IPv6 entries are expected on an IPv4-only host and return instantly. The IPv4 entries are the problem: they are ETIMEDOUT rather than refused, because Happy Eyeballs aborted each attempt after 250 ms. Measured TCP connect times from this host (curl -w %{time_connect}):

Host connect time under the 250 ms cap?
api.z.ai (Aliyun) 285–444 ms no
dashscope.aliyuncs.com 462–1525 ms no
api.anthropic.com 185–227 ms yes
api.openai.com 208–291 ms borderline
generativelanguage.googleapis.com 175–777 ms mostly

So the endpoint is reachable and healthy — the handshake simply doesn't finish inside the window the client allows it.

Reproduction

No OpenCode session needed; this runs the app's own runtime as Node:

// probe.mjs
import net from "node:net"
if (process.argv[2] === "raise") net.setDefaultAutoSelectFamilyAttemptTimeout(5000)
let ok = 0, fail = 0, last = null
for (let i = 0; i < 8; i++) {
  try {
    await fetch("https://api.z.ai/api/coding/paas/v4/chat/completions",
      { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
    ok++
  } catch (e) {
    fail++
    last = (e.cause?.errors ?? []).map(x => `${x.code}@${x.address}`).join(", ")
  }
}
console.log(`timeout=${net.getDefaultAutoSelectFamilyAttemptTimeout()}ms ok=${ok} fail=${fail}`, last ?? "")
$ ELECTRON_RUN_AS_NODE=1 /opt/OpenCode/ai.opencode.desktop probe.mjs
timeout=250ms ok=0 fail=8 ETIMEDOUT@47.245.163.4, ENETUNREACH@240b:..., ETIMEDOUT@47.245.170.100, ENETUNREACH@240b:...

$ ELECTRON_RUN_AS_NODE=1 /opt/OpenCode/ai.opencode.desktop probe.mjs raise
timeout=5000ms ok=8 fail=0

0/8 → 8/8 from one setting. Substituting any high-latency endpoint reproduces it; anything under ~250 ms will not.

Why this is worth fixing rather than documenting

The affected providers are, right now, mostly the Chinese ones — z.ai / Zhipu (GLM), DashScope / Alibaba (Qwen), and others in that group. They are not slow models; their endpoints are simply far away from a lot of users, so a 300–500 ms handshake is normal and not a symptom of anything wrong. What they are is cheap — often an order of magnitude cheaper per token than the US-hosted frontier providers, and on flat-rate coding plans effectively free at the margin. That combination is exactly why people put them behind subagents, batch jobs, and delegation setups, and why they end up configured alongside a US provider that works flawlessly.

That is the trap: the cheap provider is the one that breaks, the expensive one keeps working in the same second, and the error message blames the network. As these providers keep gaining users, this will keep arriving as "provider X is broken in OpenCode" bug reports that are really this one line of default configuration.

A 250 ms budget for a TCP handshake is a reasonable default for choosing between two address families on a local network. It is not a reasonable ceiling for reaching an API endpoint on another continent.

Suggested fix

packages/desktop/src/main/index.ts already sets process-wide network defaults in exactly this style — the CA-certificate block around line 180:

try {
  setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
} catch (error) {
  logger.warn("failed to load system certificates", error)
}

Adding the connect-attempt cap next to it, guarded the same way:

import { setDefaultAutoSelectFamilyAttemptTimeout } from "node:net"

// Node caps each Happy Eyeballs connect attempt at 250 ms. Provider endpoints
// that are geographically distant (api.z.ai, dashscope.aliyuncs.com) routinely
// need 300-1500 ms for the TCP handshake, so every attempt aborts with ETIMEDOUT
// and fetch throws AggregateError. Raise the ceiling; genuine refusals
// (ECONNREFUSED / ENETUNREACH) still return immediately.
try {
  setDefaultAutoSelectFamilyAttemptTimeout(5000)
} catch (error) {
  logger.warn("failed to raise connect attempt timeout", error)
}

Better still would be making it configurable per provider, but a higher default fixes the reported class of failures with no downside I can find: the cap only ever costs time against an address that silently blackholes packets, and in that case Happy Eyeballs is already the wrong tool.

Please do not "fix" this by disabling autoselection. net.setDefaultAutoSelectFamily(false) also cures the provider failure, and it is the obvious first thing to try — but it removes the IPv6→IPv4 fallback along with the cap. localhost resolves to ::1 first, while most local servers (including MCP servers) bind 127.0.0.1 only, so every localhost MCP connection then dies with ECONNREFUSED and no second attempt. Measured in the Electron runtime:

setting api.z.ai localhost:8080 (MCP)
default (250 ms) 0/3 3/3
setDefaultAutoSelectFamily(false) 3/3 0/3 ECONNREFUSED
attemptTimeout = 5000 3/3 3/3

Raising the timeout keeps the fallback and fixes the provider. Disabling autoselection trades one bug for another.

Workaround for anyone hitting this now

Drop this file into ~/.config/opencode/plugin/slow-connect-fix.js*.js files there are loaded automatically, so no opencode.json edit is needed. It works for both Desktop and CLI:

import net from "node:net"

export const SlowConnectFix = async () => {
  try {
    net.setDefaultAutoSelectFamilyAttemptTimeout?.(5000)
  } catch {}
  return {}
}

Restart the app afterwards — a running instance keeps the value it started with.

NODE_OPTIONS="--network-family-autoselection-attempt-timeout=5000" before launching the app is equivalent, but Electron filters NODE_OPTIONS in packaged apps, so it may be dropped silently. The plugin runs inside OpenCode's own process and avoids that question.

Possibly related

These describe different mechanisms (Bun-side IPv6 blackhole stalls, a 5-minute headers timeout, an undici EHOSTUNREACH bug on LAN addresses), but they land near the same symptom and may be worth cross-checking:

  • #36808 — opencode.ai resolves to four IPv6 addresses and stalls Bun fetch on IPv6 blackhole networks
  • #36029 — CLI binary hangs on SSE streaming; desktop app on same host works (IPv6 path silent-drop)
  • #40095 — provider silently returns empty response / hangs on machines with broken IPv6 connectivity
  • #33757 — fetch fails with EHOSTUNREACH for non-localhost addresses
  • #26602 — Desktop hits 5-minute Headers Timeout Error with slow local providers

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.