Skip to content

fix(passthrough): bound connectTimeoutMs to TCP establishment; fix duplicate error log - #30

Closed
dean0x wants to merge 4 commits into
mainfrom
fix/issue-27-timeout-semantics
Closed

fix(passthrough): bound connectTimeoutMs to TCP establishment; fix duplicate error log#30
dean0x wants to merge 4 commits into
mainfrom
fix/issue-27-timeout-semantics

Conversation

@dean0x

@dean0x dean0x commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes two bugs in the Anthropic passthrough timeout logic that caused spurious 504s for long-running requests and duplicate warn log entries on timeout.

Fixes #27

Changes

Defect A — connectTimeoutMs now bounds only TCP establishment

upstream.setTimeout(connectTimeoutMs) was armed immediately after http.request() and never re-armed on connect, so it effectively measured time-to-first-byte. On a keep-alive pooled socket there is no connect phase at all, making the 10 s default a hard cap on upstream think-time and producing spurious 504s for long-running claude-opus-4-class requests.

Fix (src/anthropic-passthrough.ts): fold a rearm into the existing 'socket' listener:

  • Fresh connection: re-arm to streamIdleTimeoutMs on the 'connect' event
  • Pooled/reused socket: socket.connecting is false, so re-arm immediately — no 'connect' event fires on a reused socket

The mid-stream re-arm already in the response callback is left in place.

Config doc update (src/config.ts): the connectTimeoutMs JSDoc comment is updated to describe the tightened semantics explicitly.

Defect B — eliminated duplicate anthropic_upstream_error warn on pre-header timeout

The timeout handler called upstream.destroy() before writing the 504. Destroying an in-flight ClientRequest emits 'error' (ECONNRESET) on the next tick. The error handler's guard if (responded) return was inert on the pre-header path (responded was still false), so both anthropic_upstream_timeout and anthropic_upstream_error were logged and res.end() was called after res.destroy().

Fix: replace responded with a settled flag set by whichever handler wins the race (timeout 504 writer or the response callback). The 504 is also written before upstream.destroy() to narrow the race window.

Breaking Changes

None. Default timeout values are unchanged. The behavior change (header-wait budget widened from 10 s to streamIdleTimeoutMs = 300 s) is intentional and fixes the reported regression.

Reviewer Focus Areas

  • src/anthropic-passthrough.ts lines 85–135: settled flag placement and socket listener rearm logic — confirm both socket.connecting branches are reachable and correct
  • Timeout handler reorder (log → write 504 → set settleddestroy()) — confirm no use-after-end path survives
  • Three new regression tests in test/integration/passthrough.test.ts: think-time test, single-warn test, pooled-socket test

dean0x and others added 3 commits August 19, 2026 01:00
…ix duplicate error log

Fixes #27

Defect A: upstream.setTimeout(connectTimeoutMs) was armed immediately after
http.request() and never re-armed on connect, so it measured time-to-first-byte
rather than connection establishment time.  On a keep-alive pooled socket there is
no connect phase at all, making the 10 s budget a hard cap on upstream think-time
and causing spurious 504s for long-running requests.

Fix: fold a rearm into the existing 'socket' listener.  When socket.connecting is
true (fresh connection), re-arm to streamIdleTimeoutMs on the 'connect' event.
When socket.connecting is false (pooled socket), re-arm immediately — no 'connect'
event fires on a reused socket.  The existing re-arm inside the response callback
(for mid-stream idle semantics) is left in place.

Defect B: the timeout handler called upstream.destroy() before writing the 504.
destroy() on an in-flight ClientRequest emits 'error' (ECONNRESET) on the next tick.
The error handler's `responded` guard was inert on the pre-header path (responded was
still false), so both anthropic_upstream_timeout and anthropic_upstream_error were
logged and res.end() was called after res.destroy().

Fix: replace the `responded` guard in the error handler with a new `settled` flag
that is set by whichever handler responds first (timeout 504 writer or the response
callback).  The 504 is also written before upstream.destroy() to narrow the race
window.

Adds three regression tests: think-time > connectTimeoutMs succeeds; exactly one warn
event on genuine timeout; pooled-socket path arms streamIdleTimeoutMs immediately.

Co-Authored-By: Claude <noreply@anthropic.com>
…dget

Adds a three-budget design to the Anthropic passthrough:
- connectTimeoutMs (10 s): TCP establishment only — unchanged.
- headerTimeoutMs (600 s): connect→response-headers, re-armed on socket
  connect (or immediately for pooled sockets). Defaults to Anthropic's
  own server-side ceiling so the relay never fires before the origin does.
- streamIdleTimeoutMs (300 s): headers→stream-end, reset per chunk — unchanged.

Previously the socket re-armed to streamIdleTimeoutMs on connect; a
non-streaming Opus completion with large max_tokens that buffers server-side
for several minutes could be cut off at 300 s where a direct connection
would have succeeded. With headerTimeoutMs defaulting to 600 s the relay
is aligned with the origin's own timeout.

Also:
- CHANGELOG: version [Unreleased] → [0.2.1] - 2026-08-19; corrects the
  res.end/res.destroy ordering claim (it was res.destroy after res.end,
  not the reverse); documents the TTFB budget widening.
- Tests: updated three existing timeout tests to exercise headerTimeoutMs
  explicitly; renamed to reflect the new knob.
- Fixes missing headerTimeoutMs in server-wiring and doctor test fixtures.

Co-Authored-By: Claude <noreply@anthropic.com>
…e headerTimeoutMs default to 660 s

Three changes from verification follow-up on #27:

1. Add mid-stream idle test to passthrough.test.ts.  The new test sends 6 SSE
   chunks ~50 ms apart (~300 ms of active streaming) then stalls.  With
   streamIdleTimeoutMs=100ms the idle timer fires after the stall and
   res.destroy() truncates the body.  A 2 s timing bound makes the test
   non-vacuous: with streamIdleTimeoutMs=10_000 the elapsed time reaches
   ~10 261 ms, failing the bound and proving the knob does the work.
   The active-chunk phase also pins the "reset by every received chunk" invariant.

2. Raise headerTimeoutMs default from 600_000 to 660_000 ms.  The relay's clock
   starts at TCP connect; the origin's starts at full-request-received.  Equal
   budgets with an earlier start lets the relay pre-empt the origin by the
   request-upload time plus RTT.  The 60 s of headroom corrects that.  Updated
   in src/config.ts (schema + JSDoc), subswitch.config.example.json, README.md
   table, and CHANGELOG.md.

3. Add assert.equal(result.value.config.anthropic.headerTimeoutMs, 660_000) to
   test/unit/config.test.ts alongside its connectTimeoutMs and streamIdleTimeoutMs
   siblings so the default is not unasserted.

Co-Authored-By: Claude <noreply@anthropic.com>
ClientRequest.setTimeout() defers via an internal 'connect' listener so it
fires only after TCP connect — in the same tick as the headerTimeoutMs rearm —
meaning connectTimeoutMs was never in force for any measurable interval.
Measured before fix: blackholed IP (192.0.2.1) with connectTimeoutMs=700 failed
after 75 019 ms (macOS kernel TCP timeout). Neither budget fired.

Fix: arm the timer directly on the socket inside the 'socket' event handler,
before 'connect' fires. Node v22's internal socket-timeout handler (onTimeout)
skips req.emit('timeout') when socket.connecting is true, so we explicitly
forward socket 'timeout' → upstream.emit('timeout') to trigger the 504 handler.
On connect we cancel the connect timer and re-arm to headerTimeoutMs via the
normal upstream.setTimeout() path (which propagates correctly for connected
sockets). Measured after fix: same scenario fails at ~700 ms. ✓

On HTTPS, 'connect' fires after TCP but before the TLS handshake, so TLS
negotiation falls under headerTimeoutMs, not connectTimeoutMs. Documented in
JSDoc for both PassthroughOptions and config.ts AnthropicSchema.

New test: connectTimeoutMs fires during TCP connect to a non-routable upstream
(192.0.2.1, TEST-NET-1). Without fix the test was cancelled at the 30 s
--test-timeout limit; with fix it passes in ~261 ms. Non-vacuity confirmed
empirically. Exactly one anthropic_upstream_timeout warn is emitted — settled
de-dup holds on the connect-timeout path.

Co-Authored-By: Claude <noreply@anthropic.com>
@dean0x

dean0x commented Aug 19, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #33. All commits from this branch are contained in wave/passthrough-hardening, which carries this work plus the transparency-hardening follow-up. Closing unmerged.

@dean0x dean0x closed this Aug 19, 2026
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.

anthropic.connectTimeoutMs is armed as a socket-inactivity timer, capping time-to-first-byte at 10s and producing spurious 504s

1 participant