Skip to content

WIP Fix: recover from network drops without restarting#73

Closed
teocns wants to merge 3 commits into
KarpelesLab:masterfrom
teocns:fix/network-recovery-timeouts
Closed

WIP Fix: recover from network drops without restarting#73
teocns wants to merge 3 commits into
KarpelesLab:masterfrom
teocns:fix/network-recovery-timeouts

Conversation

@teocns

@teocns teocns commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

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 pool
Loading

Fix

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.

  • API requests now fail if no response starts within 120 seconds. The limit only
    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.
  • Token refresh now fails after 30 seconds instead of hanging.

Both limits are adjustable with TEAMCLAUDE_UPSTREAM_HEADERS_TIMEOUT_MS and
TEAMCLAUDE_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: response
Loading

Notes

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.js cover 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.

teocns and others added 2 commits July 5, 2026 12:40
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>
@teocns teocns marked this pull request as draft July 6, 2026 05:08
@teocns teocns changed the title Fix: recover from network drops without restarting WIP Fix: recover from network drops without restarting Jul 6, 2026
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>
@teocns teocns closed this Jul 6, 2026
@teocns

teocns commented Jul 6, 2026

Copy link
Copy Markdown
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:

  • Node's fetch pool self-heals after a network drop. New requests reconnect instantly once the network returns, in the same process. No restart needed at the socket layer.
  • Idle pooled sockets are closed by undici after about 4 seconds anyway, so an idle drop leaves nothing to poison.
  • The real "works only after restarting teamclaude" wedge is elsewhere: a 429 burst can put every account into an in-memory rate-limit hold (up to 1 hour). One request can throttle all accounts via failover. Nothing revalidates the hold while it lasts, so teamclaude keeps refusing even when the upstream is healthy again, and a restart "fixes" it only because the hold is not persisted.

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>
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.

2 participants