Skip to content

fix(sdk): apply undici headersTimeout for long-running requests#33535

Open
bashrusakh wants to merge 5 commits into
anomalyco:devfrom
bashrusakh:issue-30252-vivid-dingo
Open

fix(sdk): apply undici headersTimeout for long-running requests#33535
bashrusakh wants to merge 5 commits into
anomalyco:devfrom
bashrusakh:issue-30252-vivid-dingo

Conversation

@bashrusakh

@bashrusakh bashrusakh commented Jun 23, 2026

Copy link
Copy Markdown

Issue for this PR

Fixes #30252

Type of change

  • Bug fix

What does this PR do?

The issue report assumes a "Go server", but the server is Node/undici — there is no Go component. The ~303s wall is undici's default headersTimeout and bodyTimeout (both 300s).

The change that actually fixes the repro is the SDK client→server dispatcher (node-fetch.ts): the blocking POST /session/:id/message endpoint holds the connection open until the prompt completes, and undici kills it at 300s. The previous req.timeout = false was a no-op — undici does not read Request.timeout. The SDK lazily imports undici (now a hard dependency) inside a runtime guard and caches the import promise, so browser bundles never pull undici (and its node:* deps) and concurrent first calls share a single Agent with both headersTimeout: 0 and bodyTimeout: 0.

The aisdk.ts / provider.ts changes map provider.timeout to undici's headersTimeout and bodyTimeout on the server→provider outbound path (revives #26599 by @osamahaltassan). These only take effect when core/server run under Node. When no provider.timeout is configured, the server→provider default stays at 300s — by design, to honor the documented config. The mapping logic is extracted as a pure resolveTimeoutMs function so it is testable under Bun without hitting the Node-only Agent constructor.

Two shared modules avoid duplication:

  • packages/core/src/util/undici-dispatcher.tsresolveTimeoutMs + createUndiciDispatcher, used by both aisdk.ts and provider.ts
  • packages/sdk/js/src/node-fetch.tsnodeFetchWithDispatcher, used by both client.ts and v2/client.ts

Bun is unchanged: both modules guard via process.versions.bun. Custom provider fetch is unchanged: dispatcher is skipped when a custom fetch is set. bodyTimeout is mapped alongside headersTimeout (same value), so SSE streams with gaps >300s between chunks are not killed by undici's default bodyTimeout. This does not conflict with the existing chunkTimeout / headerTimeout abort signals — those use AbortSignal/AbortController, which are independent of the undici dispatcher's socket-level timeouts.

How did you verify your code works?

  • bun typecheck passes on packages/core and packages/sdk/js
  • packages/opencode has pre-existing errors in global.ts and project.ts unrelated to this change (confirmed via git stash — the errors exist on clean dev)
  • bun test test/util/undici-dispatcher.test.ts — 10 pass, 0 fail:
    • resolveTimeoutMs: false→0, positive number→value, undefined→undefined, 0→undefined, negative→undefined, null→undefined, string→undefined
    • createUndiciDispatcher: Bun guard returns undefined, undefined/0 returns undefined, valid timeout returns Agent instance (toBeInstanceOf)
  • Bun path is unchanged: both modules return undefined/fall through to plain fetch when process.versions.bun is set
  • Custom provider fetch is unchanged: dispatcher is skipped when a custom fetch is provided

Screenshots / recordings

N/A — no UI changes.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Node's undici fetch defaults headersTimeout to 300s, which kills
long-running sessions at the ~303s mark regardless of provider timeout
config. The SDK client's req.timeout = false was a no-op for undici.

Map provider.timeout to undici headersTimeout on the server->LLM path
(revives anomalyco#26599), and use an undici Agent with headersTimeout: 0 on the
SDK client->server path. Bun is unchanged; dynamic import keeps browser
compat.

Fixes anomalyco#30252
@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. and removed needs:compliance This means the issue will auto-close after 2 hours. labels Jun 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

bashrusakh and others added 4 commits June 24, 2026 04:54
Address review feedback on anomalyco#33535:
- Make undici a regular dependency of @opencode-ai/sdk (was optional peer
  with silent .catch fallback that hid missing dep)
- Map bodyTimeout alongside headersTimeout on server→provider path (fixes
  SSE streams with >300s gaps between chunks)
- Extract createUndiciDispatcher into shared core/util/undici-dispatcher.ts
- Extract nodeFetchWithDispatcher into shared sdk/js/src/node-fetch.ts
- Add unit test for createUndiciDispatcher covering all branches
Address review feedback:
- Extract resolveTimeoutMs() as pure function so the mapping logic is
  testable under Bun without hitting the Node-only Agent guard
- Test now verifies all mapping branches via resolveTimeoutMs + Agent
  instance check (toBeInstanceOf) instead of poking private fields
- Restore browser-safety in SDK: lazy import('undici') inside guard
  instead of top-level static import that would pull node:* into bundles
  even when the runtime guard prevents execution
Concurrent first calls to nodeFetchWithDispatcher could each create an
Agent before either cached the result. Cache the import promise itself
so all callers share one resolution.
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.

provider.*.options.timeout config is not applied to API requests

1 participant