[Bug] Streams die under long thinking gaps: 180s first-party idle abort + retry permanently disabled after one visible block

Status Open
Reported on v2.1.226
Maintainer reply None cached
Activity 2 comments · opened Aug 9, 2026

Preflight Checklist

  • [x] I have searched existing issues — many open reports carry this symptom (#70217, #81848, #82994, #84551, #80408, #83943 among others); none identifies the client-side mechanism below. #84404 is related but distinct: there the retry loop runs all 10 attempts against a custom endpoint; here the retry is disabled by a gate on the first-party path.
  • [x] This is a single bug report — two defects, but they share one code path and one measurement corpus, and separating them would orphan the evidence.
  • [x] I am using the latest version of Claude Code

What's Wrong?

API Error: Connection closed mid-response / Response stalled mid-stream on nearly every long free-form turn. Measured over 8 days of real transcripts: 246 truncated streams in 67,235 API calls, counting only records with isApiErrorMessage: true (the transcript's error field reads "server_error" even for client-side aborts, so it cannot be used to classify — the message text can).

Environment: Claude Code 2.1.226, native first-party endpoint (no proxy — verified 0 connections on the only local gateway), macOS 25.5, Opus, high effort, median context 220–280k.

Two separable client-side defects:

Defect 1 — first-party path aborts healthy requests at 180 s, below the documented 300 s floor

$SS() takes NSS = 180000 when the provider is "firstParty", while the env-var path floors at 300 s:

function Cba(){ return Math.max(PK(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS)||0, 300000) }
function $SS(e){ let t = Cba(), r = e === "firstParty" ? NSS : t, ... }   // NSS = 180000

With high effort and a large context the model routinely stays silent longer than 3 minutes before the first byte; the client kills a request that was fine.

Evidence it is a hard timer, not a distribution: request age at abort has p10 = 183 s and nothing below it — a step function at the predicted 180 s (measured median 192 s).

Raising it requires two variables, because the smaller one wins:

Vn = te.CLAUDE_ENABLE_STREAM_WATCHDOG ?? !0        // default ON
En = Cba()                                          // 300000 when unset
di = Math.min( Aba(Kn()), Vn ? En : 1/0 )           // BYTE_ override alone loses to 300000

CLAUDE_BYTE_STREAM_IDLE_TIMEOUT_MS alone is clamped to 300 s; only setting CLAUDE_STREAM_IDLE_TIMEOUT_MS as well lifts the deadline. Measured effect with both at 900000: watchdog aborts went 70 (preceding 9 days) → 0, while connection-resets were unaffected — which separates defect 1 from defect 2 cleanly.

Ask: apply the 300 s floor to the first-party path too, or document NSS and surface the effective deadline in the error text. Note also that the retry banner renders (deadline − 20000), so raising the deadline inflates the user-facing "will retry in Xm" wait — at 900000 it shows "14m".

Defect 2 — one yielded visible block permanently disables retry, so identical faults get opposite outcomes

The retry gate is if (!bt && ...), where bt latches true as soon as any content block that is not thinking/redacted_thinking is yielded. Same session, same error code, ~10 minutes apart:

23:59:33  [WARN] Stream connection error (ECONNRESET) — retrying streaming (1/10)
00:10:05  [WARN] Stream connection closed (ECONNRESET) after 1 block(s) yielded
                 — finalizing partial response

The first recovered invisibly. The second surfaced as API Error: Connection closed mid-response. One yielded block was the entire difference; the retry budget of 10 was never used past 1.

This is why the failure looks user-specific. Everyone's streams get reset; a reset during thinking is retried invisibly (measured: 71% of resets never reach the screen). A workload that emits visible output early and then keeps streaming for minutes spends almost all of its exposure in the non-retryable state. Measured by turn shape: tool_use turns truncate at 0.2 per 1k, free-form text turns at 15.3 per 1k75×, with no corresponding difference in the underlying fault.

Ask: resume after partial output where the API allows it, or at minimum report blocks_yielded and the remaining retry budget in the user-facing error so the difference is not invisible.

Contributing condition — minutes with zero bytes in either direction

During a long thinking gap the connection carries nothing either way. TCP-level keep-alive (tested: always_keepalive=1, keepidle=30s, persisted via LaunchDaemon) did not reduce resets. An application-level keep-alive — an SSE comment line, or an HTTP/2 PING — would keep the flow provably alive through middleboxes where TCP-level demonstrably does not. Combined with defect 2, a single reaped flow costs the whole turn's output.

Ruled out locally, with the measurement that ruled it out

TCP keepalive/NAT idle (applied, no change) · PMTU (v4 1480 / v6 1500 OK) · local proxy (0 connections) · process reapers (none touch claude) · memory pressure (wrong hours) · account rate limit (1.2% of block consumed at reset moment) · link quality at the reset minute (6/6 TLS handshakes, TTFB 40–136 ms) · turn duration · context size (weak: 0.9→3.9/1k vs 75× for turn shape) · CLI version (non-monotonic once double-counting is removed) · session concurrency (flat per call, 1→6 sessions). A local link-flap investigation continues on my side — but defects 1 and 2 are client-code behavior, reproducible regardless of that outcome.

Request ids for server-side correlation

Night burst (2026-08-09, one session, 8 resets in ~64 min, UTC):

req_011CdrCsyJNiCnR8xPb6vZGW  00:12:04Z
req_011CdrFKuoF7UPTdNmLyV5VV  00:43:37Z
req_011CdrFYthsMrhr9iMwNKsrp  00:47:07Z
req_011CdrFnojcRbGu1Jjv4n2vd  00:49:38Z
req_011CdrG4kXaYLsbqPJjBZfW6  00:53:38Z
req_011CdrG9BUdB9XNJm5k1Sf8U  00:55:08Z
req_011CdrGDZFfqYeAYSBLyBXTn  00:55:39Z
req_011CdrHoe4hnVHVpWV1scvDv  01:16:10Z

Morning burst (2026-08-09, three concurrent sessions, UTC):

req_011Cds9P5fD6omqsKwG2u3Am  12:07:39Z
req_011Cds9eJwqPde1KXU9wgafq  12:10:04Z
req_011CdsBv33njkwAFBbmcAfxS  12:39:24Z
req_011CdsDcjzsrXsgXP2yJirmS  13:03:26Z

~160 further paired request ids over the 8-day corpus available on request (pairing: nearest preceding requestId record in the session transcript; validated 32/32 over the last two days).

Environment Info

  • Platform: darwin 25.5.0
  • Version: 2.1.226 (native install, latest channel)
  • Endpoint: first-party Anthropic (no ANTHROPIC_BASE_URL in any scope)

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗