Skip to content

fix(resilience): tolerate transient network failures in model-list fetch (#78)#81

Merged
ltmoerdani merged 3 commits into
mainfrom
fix/issue-78-model-list-fetch-resilience
Jul 23, 2026
Merged

fix(resilience): tolerate transient network failures in model-list fetch (#78)#81
ltmoerdani merged 3 commits into
mainfrom
fix/issue-78-model-list-fetch-resilience

Conversation

@ltmoerdani

Copy link
Copy Markdown
Owner

What

Fixes #78. Reporter @leiyu1980 (Windows 11 + VPN + corporate firewall, VS Code 1.129.0, extension 0.4.1) saw the Zen model list "flash briefly before disappearing" at startup, with both Go and Zen logging TypeError: fetch failed.

Why

Initial triage looked like a regression of the closed #51 picker crash (TypeScript schema change on VS Code 1.126, fixed by PR #53). Wrong instinct. Web research confirmed the real root cause:

  • Node's built-in fetch (undici) defaults to headersTimeout=300s with no connect timeout. A single hung TCP connect could stall the picker for up to 5 minutes.
  • nodejs/undici#5450 documents that under concurrent load, undici's socket-reuse + keep-alive race produces TypeError: fetch failed. Maintainer response: "expected behavior, use retries."
  • VS Code 1.129 introduced the agent host, which dramatically increases concurrent provideLanguageModelChatInformation calls into BYOK providers — multiplying the chance of hitting that race per unit time.
  • fetchModels() was written for the happy path only: no timeout, no retry, no User-Agent, no graceful cache fallback. One transient failure → bundled list or empty picker ("flash then disappear").

After the reporter replied, a second signature surfaced: their VPN/firewall was passing POST /chat/completions (with Content-Type: application/json) while dropping the bare GET /models (no headers). That points at SSL-inspecting proxies (Zscaler / Netskope / Fortinet) which treat anonymous GETs as scanner traffic.

How

Six coordinated changes in src/extension.ts:

  1. Per-attempt timeoutAbortSignal.timeout(15_000) so a hung connect can't hold the picker hostage for undici's 5-minute default.
  2. Exponential retry — up to 3 attempts with backoff (500ms / 1s / 2s), gated by isTransientFetchError(). Retries only on ECONNRESET, EAI_AGAIN, UND_ERR_CONNECT_TIMEOUT, HTTP 408/429/5xx, and the generic TypeError: fetch failed wrapper. Never on AbortError from the caller's CancellationToken or on HTTP 4xx.
  3. User-Agent at runtimegetUserAgent() reads context.extension.packageJSON.version once, caches it, falls back to FALLBACK_USER_AGENT in test harnesses. Kills the recurring drift (header said 0.3.6 while package.json said 0.4.1).
  4. CancellationToken threadingfetchModels(apiKey, token?) composes the caller's token with the timeout signal via AbortSignal.any([...]). Cancellation short-circuits to a fallback and never retries.
  5. 1-hour cached snapshot — every successful fetch persists { ids, fetchedAt } to globalState under opencode.modelListCache.v1::<vendor>. On final failure, prefers the cached snapshot over the bundled fallbackModels. This is what kills the "flash then disappear" UX.
  6. Accept: application/json header — makes the GET look like a legitimate API call instead of an anonymous scanner, matching what testConnection() and the chat transports already send. Specifically targeted at the reporter's VPN + corporate firewall signature.

Drive-by UX fix

While replying to the reporter, I told them to "run OpenCode Go: Refresh Models from the Command Palette". That command didn't exist. Refresh Models was only reachable as a sub-item inside the OpenCode Go: Manage Provider QuickPick, and Zen had no Manage Provider command at all — a Zen user (which is the reporter's case) had zero manual refresh path via the palette.

Three new top-level commands are now registered:

  • OpenCode Go: Refresh Models — skips the Manage menu, fetches straight away
  • OpenCode Zen: Manage Provider — parity with Go
  • OpenCode Zen: Refresh Models — same as Go refresh, scoped to Zen

Implementation: new public refreshModels() method on OpenCodeProvider wraps the existing private refreshMetadataAndModels() + changeEmitter.fire() + toast. The "Refresh Models" action inside manage() now delegates to this method (single source of truth). No behavior change to existing opencodego.manage.

What this does NOT fix (honest caveats)

  • VPN/firewall hard block to opencode.ai → user must set VS Code http.proxy. Documented in README and in the issue reply.
  • Gateway outage longer than the retry budget (~3.5s) → degrades to cache (1h) then bundled.
  • undici socket-reuse race itself → upstream behavior, we tolerate it via retry. Did not install a global custom dispatcher (interceptors.retry + interceptors.dns) because that affects every fetch in the extension and was deemed overkill for this MVP fix.

Verification

  • npm run compile clean (after every change)
  • get_errors clean on all modified files
  • vsce package builds opencode-copilot-chat-0.4.2.vsix (1.06 MB, 115 files)
  • Manual test A (command visibility): PASS — all 3 new commands appear in the palette
  • Manual tests B–F (refresh toast, cache fallback, retry log, paritas Zen Manage): pending user / reporter

Docs

  • docs/issues/35-20260720-issue78-model-list-fetch-resilience.md — root-cause investigation, 10 authoritative references, behavior matrix, what this does NOT fix
  • CHANGELOG.md — new [0.4.2] — 2026-07-23 section (released), with [Resilience] entry listing all 6 changes and [Commands] entry for the parity fix
  • README.md — commands table updated (Go/Zen Refresh + Zen Manage)
  • docs/devlog.md — session handoff + new section for issue [BUG] Could not fetch OpenCode Zen (Agents) model list. Using bundled model list. fetch failed #78

Commits (3 — please merge with --merge, never --squash)

  1. 8fcde64fix(resilience): make model-list fetch tolerant of transient network failures (contains Fixes #78 → auto-closes on merge)
  2. a04939cfeat(commands): add top-level Refresh Models commands + Zen Manage Provider
  3. 2ab0c31fix(resilience): send Accept header for corporate firewall compatibility (#78) + version bump 0.4.1 → 0.4.2

Notes for review

  • The isTransientFetchError() classifier parses HTTP status from both an explicit .status field and the "Model list request failed (NNN): ..." message string, because fetchModels() throws a plain Error (not a typed error) on non-2xx responses.
  • AbortSignal.any() and AbortSignal.timeout() require Node 20.3+, which VS Code's ^1.125.0 engine floor guarantees.
  • Draft PR so the reporter's verification (Refresh Models command + cache behavior) can land before merge.

Fixes #78

…failures

On flaky networks (and especially on VS Code 1.129 where the new agent host
raises the rate of concurrent provideLanguageModelChatInformation calls), a
single transient `TypeError: fetch failed` at startup — DNS wobble, TCP reset,
undici socket reuse race (nodejs/undici#5450) — caused the picker to drop to
the bundled list or empty out entirely ("flash then disappear", issue #78).

fetchModels() now:
- wraps each attempt in AbortSignal.timeout(15_000) so a hung connect can't
  stall the picker for undici's default 5 minutes
- retries up to 3 times with exponential backoff (500ms / 1s / 2s) on
  transient errors only — ECONNRESET, EAI_AGAIN, UND_ERR_CONNECT_TIMEOUT,
  HTTP 408/429/5xx, and the generic TypeError: fetch failed wrapper. Never
  retries on AbortError from VS Code's CancellationToken or on HTTP 4xx
- sends a User-Agent header built from the extension's packageJSON.version
  so strict gateways don't silently drop the request and the version string
  can't drift again (previously hardcoded 0.3.6 while package.json was 0.4.1)
- caches every successful fetch to globalState
  (opencode.modelListCache.v1::<vendor>, TTL 1 hour) and prefers that
  snapshot over the bundled list when all retries fail
- composes the caller's CancellationToken with the timeout via
  AbortSignal.any([...]) so a cancelled resolution tears down the in-flight
  fetch immediately

Drive-by: refreshMetadataAndModels() now passes the stored API key to
fetchModels() so the gateway returns the per-key personalized model list
(previously fetched anonymously).

Verification:
- npm run compile clean
- get_errors clean on src/extension.ts, CHANGELOG.md, new doc

Docs: docs/issues/35-20260720-issue78-model-list-fetch-resilience.md

Fixes #78
…ovider

Drive-by fix uncovered while triaging #78 reply: the reporter could not find
"OpenCode Go: Refresh Models" in the Command Palette because it was never
registered as a top-level command. It only existed as an action inside the
"OpenCode Go: Manage Provider" QuickPick. Zen had it worse: no Manage Provider
command at all, so a Zen user had zero manual refresh path via the palette.

This adds three new top-level commands:

- OpenCode Go: Refresh Models — skips the Manage menu, fetches straight away
- OpenCode Zen: Manage Provider — parity with the Go Manage menu
- OpenCode Zen: Refresh Models — same as Go refresh, scoped to Zen

Implementation: new public refreshModels() method on OpenCodeProvider wraps
the existing refreshMetadataAndModels() + changeEmitter.fire() + toast. The
"Refresh Models" action inside manage() now delegates to this method so there
is a single source of truth. No behavior change to existing opencodego.manage.

Verification:
- npm run compile clean
- get_errors clean on all 5 modified files

Docs:
- CHANGELOG [Unreleased] > Added
- README commands table updated (Go/Zen Refresh + Zen Manage)
- docs/issues/35-...md > new section 9b explaining the parity fix

Refs #78
…ity (#78)

The #78 reporter's reply revealed a signature I had not considered: on their
machine (Windows 11 + VPN + corporate firewall), POST requests to
/chat/completions with Content-Type: application/json were succeeding while
the bare GET to /models was being dropped — even though both share the same
host. That points at SSL-inspecting proxies (Zscaler, Netskope, Fortinet)
which commonly treat anonymous GETs without Accept or Content-Type as
scanner traffic.

fetchModels() now sends an explicit Accept: application/json header so the
request looks like a legitimate API call, matching what testConnection() and
the chat transport already send.

Also bumps the version to 0.4.2 and promotes [Unreleased] to a released
section dated 2026-07-23, since this is the last change before we cut the
release.

Verification:
- npm run compile clean
- vsce package builds opencode-copilot-chat-0.4.2.vsix (1.06 MB, 115 files)
- get_errors clean on all 4 modified files

Docs:
- CHANGELOG: new [0.4.2] section, item (6) added to the [Resilience] entry
- docs/issues/35-...md: solution section now lists 6 coordinated changes

Refs #78
@ltmoerdani
ltmoerdani marked this pull request as ready for review July 22, 2026 21:48
@ltmoerdani
ltmoerdani merged commit 54108f8 into main Jul 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant