WIP Fix: recover from network drops without restarting#73
Closed
teocns wants to merge 3 commits into
Closed
Conversation
Global fetch (undici) keeps a process-wide keep-alive pool. When the host network drops and reconnects, the pool keeps handing out half-dead sockets; with no timeout on the hot path a request waits on a dead socket forever instead of failing, the dead socket is never evicted, and every account fails until the process is restarted. Add a headers-only deadline (default 60s) to upstreamFetch on both the direct fetch and sx.org paths, disarmed the instant response headers arrive so long SSE bodies keep streaming unbounded. A stuck request now fails fast, which lets Node evict the dead socket so the client's retry reconnects cleanly, no restart needed. Also bound OAuth token refresh (30s) so a hung refresh can't wedge the coalesced _refreshPromise, and classify these timeouts as transient in the request path. Both windows are env-overridable via TEAMCLAUDE_UPSTREAM_HEADERS_TIMEOUT_MS and TEAMCLAUDE_REFRESH_TIMEOUT_MS. No new dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Time-to-first-byte only: 120s sits well above Claude's realistic first byte (even when queued or under load), so a slow-but-legitimate response, including a 429, is never mistaken for a dead socket. After a real network drop it is pure silence, so the higher value only adds detection latency, not false negatives. Document that the abort fires only pre-headers, so we never leave a mid-stream "zombie" socket in the pool, and that the textbook fix is dispatcher-level undici timeouts, which this zero-dependency guard stands in for. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
Author
|
Closing this: sandbox testing falsified the diagnosis. I reproduced the failure in Docker (silent packet blackhole via iptables between a pooled client and upstream). Findings:
A replacement PR with the evidence and the actual fix (probe revalidation for throttled accounts) is coming shortly. |
MagicalTux
added a commit
that referenced
this pull request
Jul 6, 2026
After a host network drop/reconnect, Node's shared fetch connection pool holds dead keep-alive sockets. With no time limit on a request, a retry lands on a dead socket and hangs forever — every account and retry hits the same poison, so the proxy stays wedged until a manual restart. Bound each stage so a stuck request fails fast, letting Node evict the dead socket so the client's retry reconnects cleanly: - upstream-fetch.js: a headers-only deadline (time-to-first-byte) on both the direct-fetch and sx-tunnel paths, disarmed the instant headers arrive so long streaming bodies are never cut (TEAMCLAUDE_UPSTREAM_HEADERS_TIMEOUT_MS, default 120s). - server.js: a body-idle watchdog in streamResponse that resets per chunk and fires only when the stream goes silent mid-body (a drop after headers) — covers both fetch paths since both yield a web ReadableStream (TEAMCLAUDE_UPSTREAM_BODY_TIMEOUT_MS, default 120s). On timeout the truncated stream is torn down rather than cleanly ended, so the client retries instead of accepting a partial response. - server.js: broaden the transient-error classification to cover the new timeout codes plus undici's headers/body timeouts. - oauth.js: bound token refresh (TEAMCLAUDE_REFRESH_TIMEOUT_MS, default 30s); a hung refresh is coalesced across callers and would otherwise wedge every request for that account. Tests cover fast-fail on a dead socket, same-origin eviction+reconnect, a slow body not being cut once headers arrive, and the mid-stream body watchdog firing fast while leaving healthy slow streams alone. Based on #73 by @teocns; headers-timeout + refresh-timeout reimplemented from that PR, extended here with the zero-dependency mid-stream body watchdog (the PR's dispatcher-level alternative would add the undici runtime dep and miss the sx-tunnel path). Closes #73 Co-authored-by: teocns <teocns@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After the network drops and comes back, teamclaude keeps failing every request
until you restart it. Accounts show as throttled or errored and Claude reports
API errors, even though nothing is actually rate limited.
The cause is a poisoned connection pool. teamclaude reuses a shared pool of
connections to the API across all requests and all accounts. When the network
drops the connections die, but nothing tells teamclaude they died, and because a
request has no time limit it waits on a dead connection forever instead of
failing. A hung request never errors, so the dead connection is never dropped
from the pool, and every retry and every account keeps landing on the same poison.
sequenceDiagram participant C as Claude participant T as teamclaude participant P as Connection pool participant A as API Note over P,A: network drops, pooled connections die silently C->>T: request T->>P: get a connection P-->>T: dead connection (looks alive) T->>A: send request on dead connection Note over T,A: no response, no time limit A--xT: nothing ever comes back (hangs) C->>T: times out, retries T->>P: get a connection P-->>T: another dead connection Note over T,A: hangs again, same poison Note over C,A: only restarting teamclaude builds a fresh poolFix
Add time limits so a stuck request fails quickly instead of hanging. The failure
lets teamclaude drop the dead connection from the pool, the client retries, and
the next request connects fresh. No restart needed.
covers the start of the response, so long replies keep streaming normally. It
measures time to the first byte, which streaming delivers within seconds, so a
slow or overloaded upstream that still answers (including a 429) is unaffected.
Both limits are adjustable with
TEAMCLAUDE_UPSTREAM_HEADERS_TIMEOUT_MSandTEAMCLAUDE_REFRESH_TIMEOUT_MS. No new dependencies.sequenceDiagram participant C as Claude participant T as teamclaude participant P as Connection pool participant A as API C->>T: request T->>A: send request on dead connection Note over T,A: no response within 120s T->>P: drop dead connection from pool T-->>C: fail fast (error) C->>T: retry T->>A: send request on a fresh connection A-->>T: response T-->>C: responseNotes
Recovery is per-socket: each fast failure evicts one dead connection, so after a
flap with several in-flight requests it can take a few failed-then-retried
requests to fully drain. It always converges without a restart.
Tests added in
test/upstream-timeout.test.jscover fast-fail on a dead socket,same-origin socket eviction and reconnect, and that a slow response body is not
cut off once headers arrive. Full suite passes (141) and lint is clean.