dsh-stream-timeout-fix
English · 简体中文
A tiny DeepSeek Harness (DSH) host plugin that stops Node's built-in fetch
from killing long, silently-stalled LLM streams — the ones that die with
TypeError: terminated (cause: BodyTimeoutError, UND_ERR_BODY_TIMEOUT)
after roughly 304–305 seconds of mid-stream silence, and get reported as a
TRANSPORT failure even though the provider never hung up.
Who this is for. Third-party / self-hosted OpenAI-compatible endpoints whose responses can take a long time and occasionally stall mid-stream: university or company private deployments, LiteLLM-style gateways, self-hosted vLLM, and the like. Against a well-behaved large cloud API you probably do not need it.
Maintenance. Written by the author for personal use, and published in case it helps someone else. It may not be actively maintained, issues and PRs may go unanswered, and there is no support commitment. The plugin is ~120 lines — read it before you install it. See Validation status below for what has (and has not) actually been observed.
Not on npm yet.
dsh-stream-timeout-fixhas not been published to the npm registry, so install it from this repository for now — see Install.
The symptom
With a streaming LLM endpoint, some requests fail like this:
- the SSE response is already established and content has started flowing (reasoning tokens, partial answers, tool-call deltas), then
- nothing arrives for minutes while the upstream keeps generating, and then
- the request dies locally after ~304–305 s of silence with
TypeError: terminated, classified as a transport/network error.
The provider's own logs show the opposite of a provider-side failure: it records a
client disconnect (LiteLLM / nginx log 499) and the upstream still finishes
generating and still bills the tokens — you pay for output you never received.
The root cause
Node's built-in fetch runs on undici, which
ships two client-side clocks that are invisible until they fire:
| Clock | Default | Applies to | Error when it fires |
|---|---|---|---|
headersTimeout |
300_000 ms |
waiting for the first response byte | TypeError: fetch failed, cause HeadersTimeoutError (UND_ERR_HEADERS_TIMEOUT) |
bodyTimeout |
300_000 ms |
idle gap between body bytes (re-armed by every received byte) | TypeError: terminated, cause BodyTimeoutError (UND_ERR_BODY_TIMEOUT) |
Because bodyTimeout is re-armed per byte, ordinary slow-but-flowing streams are
never affected (streams running 20+ minutes are fine). It only bites when the
gap exceeds 300 s — exactly what a stalled upstream produces.
Two things make this hard to attribute correctly:
bodyTimeoutreports itself asterminated, i.e. literally "the other side disconnected". Nothing points at the client's own clock.- DSH's failure classifier matches
/terminated/and maps it toTRANSPORT(seedsh-llm-pi-ai), which reads as a network/provider problem.
So the observable facts (client disconnect in the provider log, 499, successful
upstream generation, a TRANSPORT error on our side) all look like "the platform
drops idle connections at ~300 s" — while the actual killer is our own default.
What this plugin does
DSH's LLM path never passes a custom fetch
(dsh-llm-pi-ai → pi-ai → OpenAI SDK v6 → globalThis.fetch → global undici
dispatcher), so one process-wide swap covers every provider at once:
setGlobalDispatcher(new Agent({ bodyTimeout: 1_800_000, headersTimeout: 600_000 }))
bodyTimeout = 30 min,headersTimeout = 10 minby default — finite on purpose, so a genuinely dead peer is still reclaimed.- Stream-level watchdogs (e.g. pi-ai's
streamIdleTimeoutMs) and provider retry policies remain the real guards; this plugin only removes the disagreement between two idle clocks. - Cross-version injection is fine: the global dispatcher lives in a process-wide
Symbol.for('undici.globalDispatcher.1')slot, so anAgentcreated by undici 8.x is honored by Node's built-infetch(undici 7.x).
Validation status (read before trusting it)
This plugin was written after diagnosing the failure, and it is proven by controlled local reproduction plus its own test suite — not by a real-world stall:
- Reproduced locally. A stream that goes silent mid-body is killed by undici's
default clock at last-byte + ~304 s with
TypeError: terminated(UND_ERR_BODY_TIMEOUT); with this plugin's agent the same silence completes (npm test,scripts/reproduce-300s.mjs). - Fingerprint match. Every production failure this was written for showed a
silence gap of 304.6–305.4 s,
message: "terminated", always after content had started flowing, never in the headers phase. - Not yet observed end-to-end. Since installation, no real 300+ s stall has occurred, so "a genuinely stalled request now survives and completes" is still an inference rather than a witnessed event. The first real stall after installing is the actual acceptance test.
If a request still dies during a long silence, compare the gap length: 304–305 s points at an undici default clock somewhere in the stack, while anything clearly different points at a server/gateway idle limit — which this plugin does not address.
Install
Not on npm yet. This package has not been published to the npm registry, so
npm install dsh-stream-timeout-fix— anddsh plugin … add dsh-stream-timeout-fix— will 404 for now. Use the repository recipe immediately below; the npm recipes start working after the first publish.
Requires Node ≥ 22.19 — that is the undici dependency's engine requirement
(engines.node >= 22.19.0). Any DSH build able to mount this plugin already bundles
undici 8.x itself, so this matches the real deployment range; on Node 20 the import
fails instead of silently misbehaving.
From this repository (works today)
git clone https://github.com/SundaeYAGO/dsh-stream-timeout-fix
cd dsh-stream-timeout-fix
npm install # provides the `undici` dependency the plugin imports
Then mount the checkout as a local bundle in your profile:
- in
~/.dsh/profiles/<profile>/package.json, add"dsh-stream-timeout-fix": "link:<absolute path to the checkout>"todependencies; - append
"dsh-stream-timeout-fix"todsh.profile.bundles; - install/link it in the profile directory (
pnpm install, which creates thenode_moduleslink) and restart DSH.
The package's own cordis.patch.yml mounts the plugin row, so no manual profile-patch
edit is needed. If your DSH CLI accepts git specs (the same form other DSH plugins use),
dsh plugin --profile <profile> add github:SundaeYAGO/dsh-stream-timeout-fix should
perform those three steps for you.
From npm (after the first publish)
With the DSH CLI (the bundle patch mounts the plugin row automatically — no manual profile patch edit):
dsh plugin --profile <profile-name> add dsh-stream-timeout-fix
Manually, in the profile directory (~/.dsh/profiles/<profile>):
- add the dependency:
"dsh-stream-timeout-fix": "^1.0.0"(then install), and - append
"dsh-stream-timeout-fix"todsh.profile.bundlesinpackage.json.
Then restart DSH (or check your version's live-apply behaviour — this plugin also hot-mounts on profiles that support live bundle application).
Verify
- The loader lists the entry as enabled/active (
include:stream-timeout-fix). - The host log contains one activation line:
[stream-timeout-fix] global dispatcher replaced: bodyTimeout=1800000ms, headersTimeout=600000ms ... - Runtime marker:
globalThis.__streamTimeoutFixholds the installedAgent, the effective timeouts, and the runtime versions. - The real proof is passive: the next 300+ s stall your provider has will no longer kill the request. If a request does still die during a long silence, check the gap length — anything clearly different from 304–305 s points at a genuine server/gateway idle limit rather than this client clock.
Configuration
| Setting | Default | Where |
|---|---|---|
bodyTimeoutMs |
1800000 |
patch row config, or env DSH_STREAM_TIMEOUT_BODY_MS |
headersTimeoutMs |
600000 |
patch row config, or env DSH_STREAM_TIMEOUT_HEADERS_MS |
Set either to 0 to disable that clock entirely (not recommended globally — you
lose the last-resort reclamation of dead sockets).
What this plugin does not do
- No retries. It ships no retry policy and never re-sends a request. Retrying is
the job of DSH's own retry plugin (
@deepseek-ai/dsh-llm-retry), configured per provider in your profile patch — see the pairing section below if you want both. - No provider, auth or routing configuration. It mounts no service, registers no tool, and touches no endpoint or credential.
- No cost control. It cannot cap, cancel or budget anything — read the next section.
- No server-side change. If your gateway has its own idle-connection limit, this plugin will not help; ask your operator for SSE comment heartbeats instead.
Cost and billing (please read)
The plugin makes no network requests of its own and adds no cost by itself. But it changes the outcome of a stall, and that has a price:
- Before: the request was killed at ~304 s, and you paid for tokens you never received.
- After: the request keeps waiting while the provider keeps generating — and the provider bills those tokens. You now pay for output you actually receive, and the request may take minutes longer.
- Combined with an unlimited retry policy, repeated stalls can multiply token spend (most providers bill every failed attempt too). Budget accordingly, watch your usage dashboard, and prefer smaller outputs.
- The clocks are finite on purpose (30 min / 10 min): a genuinely dead peer is still reclaimed instead of hanging forever.
Pairing with a retry policy (optional, not shipped)
Automatic retries belong to your profile patch
(~/.dsh/profiles/<profile>/cordis.patch.yml), not to this plugin. Example for a
provider route:
providers:
your-provider:
retryPolicy:
mode: always # no retry cap; ignores retryableCodes
backoff: { initialDelayMs: 5000, maxDelayMs: 300000, jitterRatio: 0.25 }
Choose delays that respect your provider's rate limit (institutional deployments often have a small per-key RPM), and keep the cost note above in mind: retries are billed.
Scope and caveats
- Process-wide by design. Every
fetchin the DSH host process (all providers, other plugins) loses the 300 s default ceiling and inherits the configured one. That is the point, but it is a real blast radius. - Not a cure for upstream stalls. If your provider pauses for minutes, the request still takes minutes. Pair this with a retry policy, and prefer smaller outputs so a stall costs less.
- Client-side keepalive is not an option. After an HTTP/1.1 POST body is sent,
a client has no legal place to write on that request, and SSE is one-way
(server → client). TCP keepalive and HTTP/2 PINGs only address transport-idle
middleboxes. The only legitimate in-stream heartbeat is a server-sent SSE
comment (
: ping) — a server-side measure. This plugin is the client-side fix. - Node only. It has no effect on Python/Go/Rust clients, which do not ship this default (a useful hint when a colleague "can run 700 s requests" and you cannot).
How it was diagnosed
- A non-streaming probe died at t = 304.6 s with
HeadersTimeoutError— an undici exception class, which revealed the client-side clocks. - Every production failure was re-checked: silence gaps of 304.6–305.4 s,
message: "terminated", always mid-stream, always after content started. - A local controlled reproduction (local SSE server, real Node fetch) reproduced
the fingerprint exactly: last byte at
t = 2.045 s→ error att = 306.062 s(TypeError: terminated, causeBodyTimeoutError, codeUND_ERR_BODY_TIMEOUT), while a client built withAgent({ bodyTimeout: 0 })survived 338 s of silence and completed the stream.setGlobalDispatcherfrom a differently versioned undici copy also fixed the built-infetch— the injection path this plugin uses.
scripts/reproduce-300s.mjs ships that reproduction:
node scripts/reproduce-300s.mjs # default fetch: reproduces the ~304 s kill
node scripts/reproduce-300s.mjs --plugin # after this plugin: survives past 300 s
Development
npm install
npm test
npm test runs a fast, deterministic pair:
- control — an
Agentwith a tinybodyTimeoutproves that a silent stream dies withUND_ERR_BODY_TIMEOUT(the reported failure shape); - fix — after
apply(), the same silence completes, and the global dispatcher is verified to be this plugin's agent; - plus header-phase survival and idempotent re-mount checks.
Provenance
Vibe-coded by the author, with GLM 5.3 and DeepSeek V4.1 Flash driving the
investigation and the implementation. Every claim in this README is backed by scripts
in this repo (npm test, scripts/reproduce-300s.mjs) — run them yourself rather than
trusting the prose. See
PROVENANCE.md
for the full note.
Changelog
See CHANGELOG.md.
License
MIT. undici is a normal dependency (not vendored), so its own license stays with it.
No comments yet. Be the first to write one.