Bug: ECONNRESET ('terminated') during streaming with partial data kills the session — no retry, no fallback
Bug: ECONNRESET ("terminated") during streaming with partial data kills the session — no retry, no fallback
What's Wrong?
When the server resets the TCP connection mid-stream (ECONNRESET), Claude Code surfaces API Error: terminated and stops. No retry, no non-streaming fallback — the agent dies and the user must manually retry. This happens even though:
- The error is transient (ECONNRESET is a recoverable network condition)
- Non-streaming fallback exists and works for the no-data case
- The retry wrapper (
withRetry) handles 429/5xx but notTypeError("terminated")
Root cause (from source analysis)
The terminated error is a TypeError thrown by undici's Fetch.terminate() when the server sends TCP RST:
TypeError: terminated
at Fetch.onAborted (node:internal/deps/undici/undici:11132:53)
at Fetch.terminate (node:internal/deps/undici/undici:10290:14)
This error reaches the streaming catch block in queryModel(), where two conditions prevent recovery:
Condition 1: newMessages.length > 0 throws without fallback
// Pseudocode from deobfuscated catch block:
let errorToThrow = streamIdleAborted
? Error(newMessages.length > 0 ? "partial response" : "no chunks")
: originalError; // ← TypeError("terminated")
if (newMessages.length > 0) throw errorToThrow; // ← NO FALLBACK
// Only reaches non-streaming fallback if newMessages.length === 0
If any content_block_stop events were received before the RST, newMessages.length > 0 → the error is thrown directly → no non-streaming fallback. This is the same v2.1.104 regression that affects partial response received (#46987).
Condition 2: shouldRetry() doesn't match TypeError
The retry wrapper only retries errors with HTTP status codes:
if (error.status === 429) return true; // rate limit
if (error.status >= 500) return true; // server error
return false;
// TypeError("terminated") has no .status → NOT retried
Evidence from debug logs
Same session, same project, two different outcomes depending on whether partial data was received before the RST:
Case 1 — Apr 17, no partial data → FALLBACK WORKS:
19:38:28 [DEBUG] [BackendRegistry] isInProcessEnabled: true
19:38:32 [ERROR] Error streaming, falling back to non-streaming mode: terminated
19:38:32 [DEBUG] [API:request] Creating client... ← non-streaming retry succeeds
Case 2 — Apr 20, partial data received → NO RECOVERY:
08:18:15 [WARN] Streaming idle warning: no chunks received for 90s
08:18:46 [ERROR] Error in API request: terminated
08:18:46 [ERROR] Connection error details: code=ECONNRESET, message=read ECONNRESET
08:18:46 [ERROR] TypeError: terminated
at Fetch.onAborted (undici:11132:53)
at Fetch.terminate (undici:10290:14)
No fallback attempted. Agent stopped. User had to manually retry.
Same pattern repeated 30 minutes later:
09:18:58 [WARN] Streaming idle warning: no chunks received for 90s
09:19:28 [ERROR] Error in API request: terminated
09:19:28 [ERROR] Connection error details: code=ECONNRESET, message=read ECONNRESET
Again no recovery.
Environment
- Claude Code: v2.1.112 (npm)
- Model: claude-opus-4-6 (1M context)
- Plan: Max ($200/month)
- OS: Windows 10, Git Bash
CLAUDE_ENABLE_STREAM_WATCHDOG=1CLAUDE_STREAM_IDLE_TIMEOUT_MS=180000
What Should Happen?
- ECONNRESET should be retryable.
shouldRetry()should matchTypeError("terminated")(and other connection errors likeETIMEDOUT,EPIPE,EHOSTUNREACH) the same way it matches 5xx. These are transient network conditions, not permanent failures.
- Non-streaming fallback should work regardless of partial data. The
if (newMessages.length > 0) throwguard (introduced in v2.1.104) prevents fallback for the most common real-world failure: model starts responding, then the connection drops. This is the exact scenario where fallback is most valuable — the user's prompt was valid, the model was working, only the transport failed.
- At minimum, the agent should retry the same request instead of surfacing a raw
TypeError("terminated")to the user and stopping. Even without non-streaming fallback, a simple streaming retry after a 2-3s backoff would recover from most ECONNRESET events.
Suggested fix
Option A — Add connection errors to shouldRetry():
function shouldRetry(error) {
if (error.status === 429 || error.status >= 500) return true;
// Add: transient connection errors
const msg = error.message || '';
if (msg.includes('terminated') || msg.includes('ECONNRESET') ||
msg.includes('ETIMEDOUT') || msg.includes('EPIPE')) return true;
return false;
}
Option B — Remove the newMessages.length > 0 throw guard:
Allow non-streaming fallback even when partial data was received. Yes, this means the same prompt runs twice (double token cost on that turn). But it's strictly better than killing the session and losing all context.
Option C — Both A and B (recommended): retry first (handles transient RST), fall back to non-streaming if retries exhausted (handles persistent streaming issues).
Related issues
- #46987 —
Stream idle timeout - partial response received(samenewMessages.length > 0throw, 100 comments, 86 👍) - #33949 — SSE streaming hangs root cause analysis (33 comments, 21 👍)
- #39755 — Watchdog fallback dead code
- anthropic-sdk-typescript#998 — Ping-aware streaming watchdog proposal (architectural fix for the family of streaming reliability bugs)
Is this a regression?
Partially. The newMessages.length > 0 throw was introduced in v2.1.104. Before that, ECONNRESET with partial data would silently fall back to non-streaming mode (double token cost but session survived). v2.1.111 reverted a related retry cap from v2.1.110, but didn't address this specific throw condition.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗