fix(passthrough): drop retry; complete synthesized-header coverage - #32
Closed
dean0x wants to merge 6 commits into
Closed
fix(passthrough): drop retry; complete synthesized-header coverage#32dean0x wants to merge 6 commits into
dean0x wants to merge 6 commits into
Conversation
…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>
…; silence client-abort warn
CHANGE 1 (L2): When a connection-level error (ECONNRESET / EPIPE / socket hang
up) arrives before any response byte and the request body is buffered, retry
exactly once on a fresh socket (agent: false, bypassing the stale pool entry).
If both attempts fail, res.destroy() presents a transport failure rather than
a relay-synthesised HTTP 502 the origin cannot produce.
CHANGE 2 (L3): Add x-subswitch-synthesized: 1 to every response the relay
generates — 504 (timeout), 502 (connection failure), 503 (concurrency gate),
413 (body too large), 500 (internal proxy error), 400 (ambiguous / unknown
provider), 404 (unknown /__subswitch path), 200 (health). Proxied origin
responses, including upstream error statuses such as 429, do not receive the
header.
CHANGE 3: Set settled = true in res.on("close") before calling
upstream.destroy(), so the error event emitted by destroy() on the next tick
does not produce a spurious anthropic_upstream_error warn or attempt to write
a 502 into an already-closed response. Also set settled = true in the error
handler before writing the 502 for defence against theoretically possible
double-emission.
Tests: 8 new integration tests (L2 retry bounded/success/both-fail/no-retry-
after-headers; L3 header present/absent; Change 3 client-abort silence).
Co-Authored-By: Claude <noreply@anthropic.com>
…horitativeness
Remove the upstream retry block (L2): the ECONNRESET predicate cannot
distinguish a stale pooled socket from an origin that received the
request and began processing it, creating a billing-duplicate window
that spans request-write to first byte. Node's keep-alive agent already
evicts destroyed sockets and the Anthropic SDK retries connection errors
above the relay, so the relay retry is both unsafe and redundant.
- Remove the retry request, the anthropic_upstream_retry log event, the
isConnectionError predicate, and the applyRequestHeaders helper (now
single-use; inlined into the main path)
- Remove the four L2 tests
Fix the x-subswitch-synthesized marker header (L3):
- Strip the header from proxied upstream responses (added to HOP_BY_HOP)
so an origin that sets it cannot impersonate the relay; adds strip test
- Add the marker to respondJson by default so all 12+ codex-leg
respondJson/respondProxyError call sites are covered in one line
- Add the marker to the codex-leg SSE writeHead(200) (the one remaining
synthesized site not going through respondJson)
- Replace per-call-site { "x-subswitch-synthesized": "1" } spreads in
server.ts with a synthesizedHeaders() helper for correct-by-default
enforcement on future synthesized response sites
- Add two codex-leg marker tests (streaming and non-streaming)
- Update CHANGELOG and add operator-facing README section documenting
the wire contract
Keep Change 3 (client-abort warn fix) unchanged; add comment explaining
the timeout handler asymmetry.
Co-Authored-By: Claude <noreply@anthropic.com>
dean0x
force-pushed
the
feat/stale-socket-retry-and-synthesis-header
branch
from
August 19, 2026 06:16
f22f69d to
b49fd24
Compare
dean0x
added a commit
that referenced
this pull request
Aug 19, 2026
…precation test (items 10,13,14,15,16)
Item 10 — unknown_provider severity: "fail" → "info" (ADR-010)
An unknown provider qualifier does not block the request — subswitch
forwards it to Anthropic unchanged and it works. Calling it a failure
contradicts ADR-010. ambiguous stays "fail" because that conflict is
subswitch-derived and will 404 at the origin.
- src/agent-scan.ts: severity literal + JSDoc updated
- src/router.ts: comment updated to reflect informational status
- test/unit/agent-scan.test.ts: add severity assertion to unknown_provider
test; replace hand-written failingKinds set with severity-derived check
- test/unit/doctor.test.ts: +2 tests — unknown_provider exit-0 and
info-tag rendering, with positive control (unresolvable → exit 1)
Item 13 — stale prose removed, end-state documented
- src/codex-auth.ts: remove "32 concurrency slots / 503s" references
- src/provider-transport.ts: remove "concurrency slot leaks" reference
- test/unit/codex-auth.test.ts: same fixes in RELI-04/05 doc comments
- test/unit/provider-transport.test.ts: same fix
- README.md: remove deprecated headerTimeoutMs/streamIdleTimeoutMs rows;
remove deleted admission-gate rows (maxInFlightBytes, maxQueueDepth,
maxQueueWaitMs, maxConcurrentRequests); add dedicated "Deprecated config
keys" table; document SERVER_TUNING values and keepAliveTimeout 300s
rationale (PF-018); document drainRejectedUpload and 64 KiB maxHeaderSize;
document count_tokens codex estimate is deliberate; fix x-subswitch-
synthesized list (remove "529 concurrency gate", add 431); fix example
config (remove stale limits.maxInFlightBytes)
- CHANGELOG.md: fix "529 concurrency gate" reference in PR #32 entry;
replace stale maxConcurrentRequests deprecation entry with accurate
description of all six deprecated keys
Item 14 — memory bench committed
- test/tools/memory-concurrent-uploads.bench.ts: fires N concurrent bodies
at the proxy, prints RSS/heap deltas, documents the reference measurement
(100 concurrent × 3.01 MiB → +1002 MiB RSS, +4.9 MiB heap). Excluded
from npm test globs (flat non-recursive, .bench.ts not .test.ts).
Item 15 — CLI deprecation warning test
- test/unit/cli.test.ts: runServeUntilReady helper spawns the real CLI,
waits for the ready banner, SIGTERMs, captures stderr. Two tests:
deprecated key present → warning on stderr; clean config → no warning.
Non-vacuous: mutation (suppress errOut loop) turns positive test RED.
Item 16 — 404 synthesized marker already covered
- Contradiction in phase 3 handoff resolved: the x-subswitch-synthesized: 1
assertion on 404 was added in phase 2 and confirmed present. No change
needed. Full coverage: 413, 502, 504, 500, 404, 431, health 200.
Test count: 640 → 644 (+4: 2 doctor unknown_provider + 2 CLI deprecation)
Mutation table:
impl severity "info"→"fail" | 3 tests RED (agent-scan + 2 doctor)
CLI errOut loop removed | 1 test RED (cli deprecation positive case)
Co-Authored-By: Claude <noreply@anthropic.com>
Owner
Author
|
Superseded by #33. All commits from this branch are contained in |
dean0x
added a commit
that referenced
this pull request
Aug 20, 2026
…n, synthesized-response marker, token clobber guard (#33) * fix(codex-auth): replace string timestamp comparison with refresh-token identity check The concurrent-refresh guard in `persistTokens` used lexicographic `>` on `last_refresh` strings, which is wrong: ISO-8601 format divergence (e.g., fractional-second precision) or a same-second write makes the comparison unreliable. If the guard missed, subswitch would clobber the Codex CLI's rotated refresh_token with its own already-consumed token, causing an `invalid_grant` failure on the next cycle with no downstream rescue. Fix 1: replace the timestamp comparison with a refresh-token identity check (`fileNow.tokens.refresh_token !== refreshToken`). This is format-independent and is the established idiom in the `invalid_grant` retry path at line 291. A secondary `Date.parse` numeric comparison is kept for the same-second case. Fix 2: add an early return when `fileIsNewer` is true but `materialFrom` fails on the newer file. Previously, control fell through into the merge and wrote anyway, defeating the guard even when it had correctly fired. Fixes #26 Co-Authored-By: Claude <noreply@anthropic.com> * fix(passthrough): bound connectTimeoutMs to TCP establishment only; fix 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> * feat(passthrough): introduce headerTimeoutMs for origin-scale TTFB budget 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> * fix(codex-auth): correct changelog, comments, regression test, and materialFrom fallback - CHANGELOG: version header from [Unreleased] to [0.2.1] - 2026-08-19 so the release-notes extractor can locate the entry (RELEASE-FLOW.md:137) - Comments: reword 'same-second case' to 'cross-format ordering' in both the CHANGELOG entry and the codex-auth.ts inline comment; the Date.parse secondary signal fires on format mismatches (e.g. '.500Z' vs 'Z'), not same-second writes where identical strings produce equality. Add NaN note (Change 5). - Regression test: fix the format-diverged fixture so lexicographic order is genuinely wrong while chronological order is right. Baseline '2027-01-15T08:00:00.000Z', file writes epoch-millis '1800000005000' ('1' < '2' lexicographically → string guard misses; identity check fires). Empirically confirmed: FAILS on main, PASSES on branch. Add Falsifier: comment per convention. - materialFrom fallback: when fileIsNewer is true but materialFrom fails on the newer file, serve the just-refreshed tokens from memory rather than returning an error. No write occurs (clobber protection intact); the request now succeeds. Update the corresponding test to assert success + no write. Co-Authored-By: Claude <noreply@anthropic.com> * test(passthrough)+fix(config): add streamIdleTimeoutMs coverage; raise 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> * fix(errors): 413 uses request_too_large type, add missing AnthropicErrorType variants - Map body_too_large → request_too_large (was invalid_request_error) in proxyErrorToAnthropic; status code remains 413 - Add not_found_error and request_too_large to AnthropicErrorType union so callers can type-check against all Anthropic-spec error types - Add unit test asserting the correct mapping (L4 regression test) * fix(routing): unknown colon-prefixed model qualifiers fail open to Anthropic When a model name contains a colon prefix that is not a registered provider id (e.g. "claude-sonnet-9:preview"), the relay was returning a relay-invented 400 error. This violates the passthrough principle — api.anthropic.com would have accepted or rejected the request on its own terms. Change: in the unknown_provider switch arm of dispatch(), forward to the Anthropic upstream and emit a warn-level log instead of returning 400. Real codex: prefix routing is unaffected. Adds routing-behavior tests: L1 (fail-open for unknown qualifier) and the existing F6g updated to assert the request reaches Anthropic, not 400. * feat(admission): replace count-based rejection with byte-budget queueing Replace the 32-request concurrent-count gate (which could 503 under normal load) with a byte-budget admission gate that queues rather than rejects: - New config knobs: limits.maxInFlightBytes (default 2 GiB), limits.maxQueueDepth (1000), limits.maxQueueWaitMs (60 000 ms) - limits.maxConcurrentRequests deprecated (kept for backward compat) - anthropic.maxUpstreamSockets default raised from 32 → 256 - Admission uses content-length header; POST /v1/messages without content-length falls back to maxBodyBytes (conservative, no bypass) - Single-request progress: an oversized request is always admitted when the server is idle (inFlightBytes === 0) - Budget released on res.finish (keep-alive safe) AND res.close with a once-guard to prevent double-decrement - Client disconnect while queued removes the entry and drains the queue without leaking the reservation - Queue overflow returns HTTP 529 overloaded_error (not 503) - /__subswitch/* exempt from the gate Integration tests cover: admitted under budget, queueing-not-rejection, single-request progress, disconnect-while-queued, counter-returns-to-zero, queue-exhaustion 529, health never gated. * fix(admission): FIFO guard, Result types, reconcile chunked reservation, test non-vacuity Required fixes from adversarial review: 1. FIFO / anti-starvation (BLOCKING): add queue.length === 0 guard to acquireSlot immediate-admission condition. New arrivals must join the queue when waiters are already present — prevents small requests from barging past a large queued one and causing starvation-to-529. 2. Rewrite vacuous b1 test (BLOCKING): previous "queueing" test used budget=100 with 10-byte bodies (nothing ever queued) and asserted status < 529 (passes on 504). New test: budget=10, asserts parkedRes===null before release (req2 must be queued), then asserts res2.status===200 and body.ok===true. 3. Add queued-state assertion in b2 test (BLOCKING): assert parked.parkedRes===null before controller1.abort() so the test fails when queueing is disabled (req2 would be immediately admitted and parkedRes would be non-null). 4. Assert inFlightBytes invariant (BLOCKING): replace Math.max silent clamp with delta-check + logger.log("error", "inFlightBytes_underflow", ...) so a double-release is visible in operator logs. Still clamps to prevent counter going negative; justified in comment. 5. Reconcile chunked reservation (BLOCKING): for POST /v1/messages without content-length, getReservationBytes reserves maxBodyBytes (32 MiB) as an anti-bypass estimate. After bufferBody() returns, shrink inFlightBytes and the slot to the actual body size. reservationBytes is now let so the releaseSlot closure sees the updated amount. Documented in JSDoc, README, CHANGELOG. Recommended fixes: 6. Request identity assertions: add distinct ?id= query params and assert parkedUrl?.includes(...) in tests b1, b2, d, e so tests verify WHICH request reached upstream, not just that something arrived. 7. Positive control in test (g): before the health check, fire a second POST and assert it queues (parkedRes===null after 30ms), proving the gate is engaged before verifying health bypasses it. 8. Real-registry L1 test: add "claude-sonnet-9:preview" through the real registry (no synthetic resolver), asserting Anthropic routing (200) and byte-identical response body forwarding. 9. acquireSlot returns Result<void, SlotError>: converts from throw/reject to discriminated Result type per codebase convention. DisconnectedWhileQueued sentinel class removed; kind "disconnected" now handled via Result.error.kind. Route logged as "disconnected_while_queued" to prevent spurious route:anthropic/status:200 log entries for unserved requests. 10. Dead code removed: QueueEntry.reject field (never called by drainQueue), not_found_error from AnthropicErrorType union (appeared only in type declaration, unused in src/ or test/). 11. Bad log line: DisconnectedWhileQueued early-return now sets route = "disconnected_while_queued" before returning so request_complete does not emit anthropic/200 for a request that was never served. Non-vacuity verified: b1 queued-assertion fails with huge budget, b2 queued- assertion fails with huge budget, h (FIFO) only-A-assertion fails without the queue.length guard. Co-Authored-By: Claude <noreply@anthropic.com> * fix(passthrough): arm connectTimeoutMs on socket during connect phase 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> * fix(passthrough): retry stale socket once; mark synthesized responses; silence client-abort warn CHANGE 1 (L2): When a connection-level error (ECONNRESET / EPIPE / socket hang up) arrives before any response byte and the request body is buffered, retry exactly once on a fresh socket (agent: false, bypassing the stale pool entry). If both attempts fail, res.destroy() presents a transport failure rather than a relay-synthesised HTTP 502 the origin cannot produce. CHANGE 2 (L3): Add x-subswitch-synthesized: 1 to every response the relay generates — 504 (timeout), 502 (connection failure), 503 (concurrency gate), 413 (body too large), 500 (internal proxy error), 400 (ambiguous / unknown provider), 404 (unknown /__subswitch path), 200 (health). Proxied origin responses, including upstream error statuses such as 429, do not receive the header. CHANGE 3: Set settled = true in res.on("close") before calling upstream.destroy(), so the error event emitted by destroy() on the next tick does not produce a spurious anthropic_upstream_error warn or attempt to write a 502 into an already-closed response. Also set settled = true in the error handler before writing the 502 for defence against theoretically possible double-emission. Tests: 8 new integration tests (L2 retry bounded/success/both-fail/no-retry- after-headers; L3 header present/absent; Change 3 client-abort silence). Co-Authored-By: Claude <noreply@anthropic.com> * fix(passthrough): drop retry; fix synthesized-header coverage and authoritativeness Remove the upstream retry block (L2): the ECONNRESET predicate cannot distinguish a stale pooled socket from an origin that received the request and began processing it, creating a billing-duplicate window that spans request-write to first byte. Node's keep-alive agent already evicts destroyed sockets and the Anthropic SDK retries connection errors above the relay, so the relay retry is both unsafe and redundant. - Remove the retry request, the anthropic_upstream_retry log event, the isConnectionError predicate, and the applyRequestHeaders helper (now single-use; inlined into the main path) - Remove the four L2 tests Fix the x-subswitch-synthesized marker header (L3): - Strip the header from proxied upstream responses (added to HOP_BY_HOP) so an origin that sets it cannot impersonate the relay; adds strip test - Add the marker to respondJson by default so all 12+ codex-leg respondJson/respondProxyError call sites are covered in one line - Add the marker to the codex-leg SSE writeHead(200) (the one remaining synthesized site not going through respondJson) - Replace per-call-site { "x-subswitch-synthesized": "1" } spreads in server.ts with a synthesizedHeaders() helper for correct-by-default enforcement on future synthesized response sites - Add two codex-leg marker tests (streaming and non-streaming) - Update CHANGELOG and add operator-facing README section documenting the wire contract Keep Change 3 (client-abort warn fix) unchanged; add comment explaining the timeout handler asymmetry. Co-Authored-By: Claude <noreply@anthropic.com> * test(integration): fix stale 413 error type assertion in passthrough test Commit 12d05af changed the synthesized 413 body to use `request_too_large` (matching Anthropic's own API taxonomy) and updated the unit test, but the matching assertion in test/integration/passthrough.test.ts was missed. Update `body.error.type` assertion from `invalid_request_error` to `request_too_large` to match the current source. Co-Authored-By: Claude <noreply@anthropic.com> * feat(transparency): items 1-5,11,12 + example cleanup Remove the relay-invented admission gate, arm no timer after TCP connect, add a structural terminal-outcome latch, harden the logger field set, add soft-deprecation warnings for retired config keys, and clean up fixtures. Item 1 — Shared deprecation mechanism (config.ts, cli.ts) - DEPRECATED_KEYS table (6 entries: anthropic.headerTimeoutMs, anthropic.streamIdleTimeoutMs, limits.maxConcurrentRequests, limits.maxInFlightBytes, limits.maxQueueDepth, limits.maxQueueWaitMs) - detectDeprecatedConfigKeys() — pure, prototype-safe - LoadConfigResult.deprecatedKeys field - serve() warns per entry after config_loaded - Fix LEGACY_KEY_MOVES: limits.streamIdleTimeoutMs → providers.codex only Item 2 — Delete admission gate (server.ts) - Remove SlotError, QueueEntry, inFlightBytes, queue, drainQueue, getReservationBytes, acquireSlot and all call sites (gate 519-542, release slot 553-574, chunked reconciliation 594-617) - Fix bufferBody memory defect: chunks.length = 0 after Buffer.concat Item 3 — Remove headerTimeoutMs / streamIdleTimeoutMs (anthropic-passthrough.ts) - Remove fields from PassthroughOptions - Remove upstream.setTimeout(streamIdleTimeoutMs) in response callback - Socket handler: arm connectTimeoutMs only while connecting, disarm on connect, arm NOTHING afterwards (ADR-010) Item 4 — Structural terminal-outcome latch (anthropic-passthrough.ts) - Replace bare `settled` flag with settle() one-way latch - All four terminal handlers (response, timeout, error, client-close) use it Item 5 — Server knobs + clientError handler (server.ts) - SERVER_TUNING const (requestTimeout, headersTimeout, keepAliveTimeout, maxRequestsPerSocket, maxHeaderSize) - attachClientErrorHandler — Anthropic-shaped 400/431 + x-subswitch-synthesized: 1 - createProxyServer applies all tuning values Item 11 — Logger completeness guard (logger.ts) - Remove inFlightBytes and reservationBytes from LogFields - Add satisfies readonly (keyof LogFields)[] to FIELD_KEYS - Add _FieldKeysComplete compile-time exhaustiveness check Item 12 — Fixture drift fix (test/unit/doctor.test.ts, test/integration/server-wiring.test.ts) - Remove deprecated fields from makeTestConfig / makeMinimalConfig Example config cleanup - Remove headerTimeoutMs, streamIdleTimeoutMs, maxInFlightBytes, maxQueueDepth, maxQueueWaitMs from subswitch.config.example.json Test rewrites - concurrency.test.ts: delete gate suite (P0-2b…P0-2h), rewrite P0-2a as "N concurrent POSTs all reach upstream" (630 tests, was 639) - passthrough.test.ts: rewrite 4 timeout tests for ADR-010, fix L3 504 test to use 192.0.2.1 + connectTimeoutMs, fix client-abort test - config.test.ts: update defaults assertions, replace gate tests with soft-deprecation tests, add DEPRECATED_KEYS invariant test * docs: add handoff for passthrough-hardening phase 1 * feat(passthrough): items 6-9 — 413 drain, header fidelity, error taxonomy, ambiguous fail-open Item 6 — 413 delivery race (drainRejectedUpload): Replace req.destroy() + connection:close with drainRejectedUpload(req, res) on the body_too_large path. Calling destroy() with unread inbound bytes sends RST; the client may see ECONNRESET before reading the 413. drainRejectedUpload() calls req.resume() to drain remaining upload data so the TCP teardown uses FIN instead, giving the client time to read the response. A 2-second unref'd safety timer bounds resource use. Item 7 — Header fidelity (directional strip): Split the single HOP_BY_HOP set into HOP_BY_HOP (RFC 7230 §6.1, both directions) and RESPONSE_STRIP (adds x-subswitch-synthesized, response direction only). filterRawHeaders now takes a required `strip` parameter so call sites must declare direction. x-subswitch- synthesized is no longer stripped from client requests — stripping it was relay-invented behaviour prohibited by ADR-010. Item 8 — Error taxonomy: 8a: Route /__subswitch/* 404 through toAnthropicErrorBody("not_found_error", ...) with a fixed message (no path reflection). Add not_found_error to AnthropicErrorType. 8b: Remove body_too_large and client_disconnected from ProxyError into a local BufferBodyError type in server.ts. This is a compile-time enforcement: the compiler now prevents calling proxyErrorToAnthropic with either removed variant. 8c: Fix codex-leg test title — "aggregation !ok maps to 502 (no message_start in stream)" → "unrepresentable upstream stream format (no message_start) maps to 502". 8d: Set route = "internal_error" in dispatch().catch() and update the error message to identify the fault as a proxy fault, not an upstream failure. Item 9 — Ambiguous family fails open: Change the "ambiguous" route case from returning a relay-invented 400 to forwarding to Anthropic (same policy as unknown_provider). Preserves the ambiguous_model_name warn log with both provider names in the model field. Rationale: a relay-invented 400 naming our own provider registry is an ADR-010 violation; PROVIDER_IDS only has "codex" today so this branch is currently unreachable, but the fix applies the correct policy before a second provider ships. Mutation-proven tests: - filterRawHeaders request-direction strip: RED when x-subswitch-synthesized is in HOP_BY_HOP - 404 Anthropic shape: RED when body reverts to JSON.stringify({error:"not found"}) - route=internal_error: RED when assignment is removed from dispatch().catch() - F7 fail-open: RED (2 failures) when ambiguous reverts to 400 Applying: ADR-010 (relay transparency), ADR-008 (credential redaction at render site), PF-011 (mutation-proof tests), PF-012 (non-vacuity argument documented in comments). * test(passthrough-hardening): phase 3 — fixture hardening, SERVER_TUNING pins, coverage gaps Item A (12-REDONE): replace hand-coded makeTestConfig()/makeMinimalConfig() with defaultConfig() derived from loadConfig empty-config pattern, eliminating the 32-vs-256 maxUpstreamSockets drift. Non-vacuity guards in both files prove the fixtures cannot silently diverge from real schema defaults. Item B — five new behaviour-pinning tests (mutation-proven RED/GREEN): B1: SERVER_TUNING properties actually wired onto the http.Server B2: Keep-Alive: timeout=300 header present on responses B3: 6.5 s idle socket reused (keepAliveTimeout=300_000 > Node default 5 s) B4: raw TCP request with 65 KiB header receives Anthropic-shaped 431 JSON with x-subswitch-synthesized: 1 (not Node's bare status line) B5: upstream ECONNRESET produces exactly 1 anthropic_upstream_error event Item C — coverage gaps: C6: x-subswitch-synthesized: 1 asserted on 502 (passthrough) and health 200 C7: request_complete log event fields verified from a live request C8: F6g test now captures logger; unknown_provider_qualifier warn asserted C9: example-config test gains deprecatedKeys:[] + maxUpstreamSockets spot-check C10: stale 529/concurrency comment at passthrough.test.ts ~670 replaced with accurate synthesized-response coverage map Item D (verified): 413 drain race — mutation-proven: restoring req.destroy() makes the "413 race: large upload" test fail reliably; drainRejectedUpload() is correct. Mutation table: B1 RED, B2 RED, B3 RED, B4 RED, D RED — all restored GREEN. B5 settle() latch not deterministically provable via single ECONNRESET (noted). 640/640 tests pass. * feat(passthrough-hardening): phase 4 — severity, prose, bench, CLI deprecation test (items 10,13,14,15,16) Item 10 — unknown_provider severity: "fail" → "info" (ADR-010) An unknown provider qualifier does not block the request — subswitch forwards it to Anthropic unchanged and it works. Calling it a failure contradicts ADR-010. ambiguous stays "fail" because that conflict is subswitch-derived and will 404 at the origin. - src/agent-scan.ts: severity literal + JSDoc updated - src/router.ts: comment updated to reflect informational status - test/unit/agent-scan.test.ts: add severity assertion to unknown_provider test; replace hand-written failingKinds set with severity-derived check - test/unit/doctor.test.ts: +2 tests — unknown_provider exit-0 and info-tag rendering, with positive control (unresolvable → exit 1) Item 13 — stale prose removed, end-state documented - src/codex-auth.ts: remove "32 concurrency slots / 503s" references - src/provider-transport.ts: remove "concurrency slot leaks" reference - test/unit/codex-auth.test.ts: same fixes in RELI-04/05 doc comments - test/unit/provider-transport.test.ts: same fix - README.md: remove deprecated headerTimeoutMs/streamIdleTimeoutMs rows; remove deleted admission-gate rows (maxInFlightBytes, maxQueueDepth, maxQueueWaitMs, maxConcurrentRequests); add dedicated "Deprecated config keys" table; document SERVER_TUNING values and keepAliveTimeout 300s rationale (PF-018); document drainRejectedUpload and 64 KiB maxHeaderSize; document count_tokens codex estimate is deliberate; fix x-subswitch- synthesized list (remove "529 concurrency gate", add 431); fix example config (remove stale limits.maxInFlightBytes) - CHANGELOG.md: fix "529 concurrency gate" reference in PR #32 entry; replace stale maxConcurrentRequests deprecation entry with accurate description of all six deprecated keys Item 14 — memory bench committed - test/tools/memory-concurrent-uploads.bench.ts: fires N concurrent bodies at the proxy, prints RSS/heap deltas, documents the reference measurement (100 concurrent × 3.01 MiB → +1002 MiB RSS, +4.9 MiB heap). Excluded from npm test globs (flat non-recursive, .bench.ts not .test.ts). Item 15 — CLI deprecation warning test - test/unit/cli.test.ts: runServeUntilReady helper spawns the real CLI, waits for the ready banner, SIGTERMs, captures stderr. Two tests: deprecated key present → warning on stderr; clean config → no warning. Non-vacuous: mutation (suppress errOut loop) turns positive test RED. Item 16 — 404 synthesized marker already covered - Contradiction in phase 3 handoff resolved: the x-subswitch-synthesized: 1 assertion on 404 was added in phase 2 and confirmed present. No change needed. Full coverage: 413, 502, 504, 500, 404, 431, health 200. Test count: 640 → 644 (+4: 2 doctor unknown_provider + 2 CLI deprecation) Mutation table: impl severity "info"→"fail" | 3 tests RED (agent-scan + 2 doctor) CLI errOut loop removed | 1 test RED (cli deprecation positive case) Co-Authored-By: Claude <noreply@anthropic.com> * simplify(tests): remove phase-transition residue from integration tests Four items cleaned up after the four-author wave: - server-wiring.test.ts: duplicate section header block (first shorter version before the extended PF-010 block) — genuine duplication from parallel work, second block is the authoritative version. - server-wiring.test.ts: "were removed in phase 1 (commit 0a00a42)" replaced with a description of the current end-state reason. - passthrough.test.ts: "as of phase 3 — C10 correction" parenthetical dropped from the synthesized-response coverage inventory comment. - passthrough.test.ts: "Change 3:" prefix removed from section comment and test name for the client-abort test. Functionality unchanged, 644/644 pass. * fix: address self-review issues — two ADR-010 defects, one dead bound, four disarmed controls P0 — upstream socket leak on mid-stream client abort (anthropic-passthrough.ts) res.on("close") gated upstream.destroy() on the settle() latch, but the response callback claims that latch as soon as headers are relayed, so a client aborting mid-stream skipped the teardown entirely. pipe() does not propagate destination teardown to the source, leaving the upstream response half-read and its socket permanently outside the agent's free pool. Measured: 5 aborts -> 5 held sockets. At maxUpstreamSockets (256) the pool exhausts and every later request queues in http.Agent forever — an unbounded hang no origin produces, and precisely the leak ADR-010 warns the removed streamIdleTimeoutMs no longer covers. Introduced by this PR's item 4. settle() now arbitrates only the warn log and client-visible outcome. P0 — inbound timeouts reported as 400 instead of 408 (server.ts) Attaching a clientError listener suppresses Node's canned reply, so the handler added for 400/431 shaping also swallowed the 408 that requestTimeout and headersTimeout produce (both arrive as ERR_HTTP_REQUEST_TIMEOUT on Node 22) and re-emitted it as 400 "malformed request" — a status the origin never sends for a slow client, plus a wrong diagnosis. Statuses now come from a table keyed on the clientError code. PF-021 records this response as un-interceptable; measured, that holds for Node's DEFAULT reply only. P1 — drainRejectedUpload's 2 s bound never fired (server.ts) Disarmed on res "close", which fires a tick after the 413 is written, cancelling the timer ~2 ms after arming. A client that ignored the 413 and kept uploading was never cut off: the bound existed only in its comment (PF-019). Disarm now comes only from req "end"/"close"/"error". P1 — four disarmed test controls (PF-011/PF-012) - SERVER_TUNING values were compared only to SERVER_TUNING; requestTimeout 600s -> 2s and keepAliveTimeout 300s -> 5s (the PF-018 regression) both shipped green. Added independent literal pins alongside the wiring assertions. - The clientError 400 arm had no test at all — status, type, message and the client_error warn were all freely mutable. Added B4c. - agent-scan severity test named retired/preview_only/provider_unconfigured but asserted only the last; promoting the others to "fail" (a PF-006 CI break) passed green. All four kinds now asserted. - CLI deprecation assertion OR'd the structured record with the human notice, so dropping config_key_deprecated to debug stayed green. Split into two. - DEPRECATED_KEYS "completeness" test derives both sides from the table; its comment claimed it catches removals. Scope corrected, real guard named. P2 — stale prose that asserted bounds the code does not have connectTimeoutMs JSDoc still described a headerTimeoutMs re-arm (PF-019's exact failure mode); maxUpstreamSockets JSDoc justified itself by the deleted byte gate; README described requestTimeout as bounding the request lifetime when it bounds receipt only; the 404 arm carried a duplicated comment block. Also: bufferBody no longer holds (and re-concatenates) up to maxBodyBytes after a 413. New tests: B4b (408 preserved), B4c (400 fallback), B6 (abort reclaims socket), B7 (drain bound fires). Each proven RED against the specific defect it catches. Suite: 648/648 (644 baseline + 4). typecheck clean. * fix(self-review): resolve four out-of-remit issues — CHANGELOG contradictions, tautological guards, concurrency N=20, duplicate F7 test FIX 1 — CHANGELOG [0.2.1]: rewrote the section to describe net effect only. Removed the "Byte-based admission with queueing" Changed entry and the limits.maxInFlightBytes / maxQueueDepth / maxQueueWaitMs Added entries — features added and removed within the same unreleased cycle should not appear as either additions or deprecations. Fixed the connectTimeoutMs entry title (removed "new headerTimeoutMs knob") and body (removed the three-budget design description that referenced deprecated knobs as current behavior). The Deprecated section now lists all six deprecated-and-ignored keys together with a clear "remove them" instruction. Added missing items: 404 error-shape fix, route=internal_error labeling, SERVER_TUNING / keepAliveTimeout, and ambiguous model family fail-open. Fixed the maxUpstreamSockets rationale to not mention admission gates. Nothing anywhere in the file describes a synthesized 529 or an admission/queueing gate as current behavior. FIX 2 — Tautological non-vacuity guards (avoids PF-011, PF-012): • test/unit/doctor.test.ts: deleted the "defaultConfig — non-vacuity guard" describe block. The guard compared defaultConfig() against an identical loadConfig() call — both sides are the same function with the same args, so the assertion cannot fail. Protection lives in config.test.ts literals. • test/integration/server-wiring.test.ts: replaced spread-vs-base comparisons with literal value assertions (port=4141, connectTimeoutMs=10_000, etc.). MUTATION PROOF: changing DEFAULT_PORT to 4142 turns the guard RED; the old spread-vs-base test would have remained green (both sides change together). FIX 3 — test/integration/concurrency.test.ts residue: • Raised N from 2 to 20 (one-fifth of the ~100-sub-agent product thesis). All 20 requests fire simultaneously; test fails if any are blocked. • ParkingUpstream redesigned to multi-slot (arrivedCount / releaseAll) — removes the dead parkedUrl scaffolding entirely. • Both tests now have explicit positive controls: the N-concurrent test releases all 20 and asserts 200; the health test releases the parked POST and asserts it also returns 200, confirming it was genuinely in-flight. MUTATION PROOF: adding a gate that rejects requests > 1 turns the test RED. FIX 4 — test/integration/routing-behavior.test.ts duplicate F7: Removed the second F7 test ("forwarded ambiguous request reaches the Anthropic upstream"). It only re-asserted status 200 and upstream reach, both already covered (with the stronger ambiguous_model_name warn-log check) by the first F7 test. The first test remains; F7 / ambiguous-fail-open coverage is intact. MUTATION PROOF: reverting the ambiguous branch to return 400 turns the remaining F7 test RED. Final: 646/646 green (648 - 1 doctor guard - 1 F7 duplicate = 646). Co-Authored-By: Claude <noreply@anthropic.com> * fix(alignment): six residual items from post-QA alignment review FIX 1 — routing-behavior.test.ts: replace vacuous notEqual(400) with equal(200) at the F6g test; update the stale non-vacuity comment in the L1-real block that still referenced notEqual. FIX 2 — agent-scan.test.ts: add severity assertions to the unresolvable and ambiguous tests that previously checked kind only — no test anywhere was asserting severity "fail" for ambiguous. Add a doctor-level test in doctor.test.ts that proves ambiguous → severity "fail" → failures++ → exit 1. Mutation-proved: flipping ambiguous to "info" turns both tests RED. FIX 3 — anthropic-passthrough.ts: correct the JSDoc for maxUpstreamSockets from the hard-error path limits.maxUpstreamSockets to the correct key anthropic.maxUpstreamSockets. FIX 4 — passthrough.test.ts: update the x-subswitch-synthesized strip comment from HOP_BY_HOP to RESPONSE_STRIP, reflecting the item-7 split that separated response-only stripping from the bidirectional set. FIX 5 — untrack .devflow/docs/handoff-passthrough-hardening.md which was force-added past .gitignore in commit 6d29d4f. File remains on disk. FIX 6 — README: add a factual note under the deprecated-keys table explaining that with headerTimeoutMs and streamIdleTimeoutMs removed, a connected-but- silent upstream produces no response until the client's own timeout (deliberate per ADR-010 — matches direct-to-origin behavior). Co-Authored-By: Claude <noreply@anthropic.com> * docs(knowledge): update cli-ux feature knowledge base * docs(knowledge): update codex-leg feature knowledge base * feat(config): remove six soft-deprecated keys — BREAKING in 0.2.1 The six keys that were soft-deprecated (accepted but ignored) in the passthrough-hardening wave are now fully removed: - anthropic.headerTimeoutMs - anthropic.streamIdleTimeoutMs - limits.maxConcurrentRequests - limits.maxInFlightBytes - limits.maxQueueDepth - limits.maxQueueWaitMs A config containing any of these now produces a hard error via the existing LEGACY_KEY_MOVES pre-parse path, naming every offending key and citing the 0.2.1 removal. Example output (all six present): subswitch: outdated config layout — move `anthropic.headerTimeoutMs` to `(removed in 0.2.1 — delete this key; …)`; move `limits.maxConcurrentRequests` to `(removed in 0.2.1 — delete this key; …)`; … Changes: - src/config.ts: removed six .optional() fields from AnthropicSchema and LimitsSchema; removed DeprecatedConfigKey interface, DEPRECATED_KEYS constant, detectDeprecatedConfigKeys function; removed deprecatedKeys from LoadConfigResult and from ok({…}); added six removed keys to LEGACY_KEY_MOVES with actionable removal messages. - src/cli.ts: removed deprecation-warning loop from serve(). - test/unit/cli.test.ts: deleted (entire file was deprecation tests). - test/unit/config.test.ts: replaced four soft-deprecation tests with two hard-rejection tests proving the keys are now rejected. - test/integration/server-wiring.test.ts: removed deprecatedKeys assertion. - test/integration/routing-behavior.test.ts: removed maxInFlightBytes from a test config (now rejected by the pre-parse gate). - README.md, CHANGELOG.md: deprecated → removed, BREAKING. Test delta: 647 → 643 (-6 deleted + 2 added = net -4, all accounted for). Co-Authored-By: Claude <noreply@anthropic.com> * fix(config): distinct phrasing for moved vs removed legacy keys LEGACY_KEY_MOVES entries that represent genuine key moves render as: move `limits.streamIdleTimeoutMs` to `providers.codex.streamIdleTimeoutMs` Entries for keys removed in 0.2.1 now render as: delete `limits.maxConcurrentRequests` — the admission gate was removed in 0.2.1 (ADR-010) Previously all entries used the "move X to Y" template, producing grammatically broken output like "move `limits.maxConcurrentRequests` to `(removed in 0.2.1 — delete this key; …)`". Changes: - src/config.ts: introduce `LegacyKeyEntry` discriminated union (`{ kind: "moved", path, to }` | `{ kind: "removed", path, reason }`); rewrite LEGACY_KEY_MOVES entries as plain objects; update detectLegacyConfigKeys return type; update loadConfig renderer to branch on `kind`. - src/init.ts: update planConfigWrite renderer identically. - test/unit/config.test.ts: update `f.replacement` assertions to `f.kind` / `f.to`; update two BREAKING-rejection tests to assert delete-phrasing and the absence of move-phrasing; add a third test proving mixed moved+removed in one config produces one coherent message with each phrased correctly; all three are mutation-proved. Test delta: 643 → 644 (+1 mixed moved+removed test). Co-Authored-By: Claude <noreply@anthropic.com> * docs: audit and fix three doc gaps against current code CONTRIBUTING.md — add test harness section documenting flat non-recursive globs (test/unit/*.test.ts, test/integration/*.test.ts), 30 s hard per-test timeout, run-alone requirement (wall-clock assertions), no lint script, and bench exclusion (test/tools/*.bench.ts excluded by dir + suffix). README.md — fix x-subswitch-synthesized relay-management bullet: the label "/__subswitch/404" implied a specific endpoint; corrected to describe the actual behavior (any unrecognized /__subswitch/* path returns 404 with a fixed body; the requested path is not reflected). CHANGELOG.md [0.2.1] — add missing Changed entry for doctor unknown_provider severity: was "fail" in 0.2.0 (exits 1 alone), now "info" (no non-zero exit on its own). ambiguous stays "fail". Co-Authored-By: Claude <noreply@anthropic.com> * refactor(errors): export SYNTHESIZED_HEADER chokepoint Resolves I-010. `x-subswitch-synthesized` was a bare string literal at six emitter sites across four modules — server.ts synthesizedHeaders(), server.ts's raw HTTP/1.1 wire head in the clientError handler, provider-transport.ts respondJson, codex-handler.ts's streaming 200, and the passthrough 504/502 — plus a seventh in RESPONSE_STRIP, the response-direction stripper whose correctness depends on matching all six. A rename that reached the emitters but not the stripper would let an origin-set marker pass through to the client while the relay's own responses carried a different name: the exact impersonation RESPONSE_STRIP exists to block, and invisible to every test because the tests restate the literal too. SYNTHESIZED_HEADER and SYNTHESIZED_MARKER are now exported from errors.ts — the module that owns the client-visible response shape and which all four emitting modules already import. Every emitter and RESPONSE_STRIP derive from them, including the interpolated raw wire head. The literal now appears in src/ once, plus one prose comment in a file outside this batch's remit. Applies ADR-008 (one chokepoint per invariant) and ADR-010, which records the synthesized marker as that reasoning's third application. Control: test/unit/errors.test.ts U3.3 pins both constants to their documented README wire spelling with independent restated literals, so a rename is caught in exactly one place. Proven RED (avoids PF-011) against the named mutation — SYNTHESIZED_HEADER changed to "x-subswitch-generated" produced `not ok 1 - U3.3`, expected 'x-subswitch-synthesized', got 'x-subswitch-generated'. Existing tests asserting the literal header on responses are unchanged: they assert the wire contract and are correct as written. Co-Authored-By: Claude <noreply@anthropic.com> * fix(codex-auth): escalate unpersisted rotated refresh token to error When fileIsNewer is true but materialFrom fails on the newer file, the branch serves in-memory tokens without persisting them. If the token endpoint returned a rotated refresh_token, that credential is silently lost at process exit — leaving the on-disk token stale and the next OAuth call doomed to invalid_grant. Mirror the write-failure branch (RELI-02): emit authFileWriteFailed at error when tokens.refresh_token is defined, so operators see the divergence before the next cycle fails. Regression test added (RED on current code, GREEN after fix). Closes I-036. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(config): legacy-key table end-state — drop never-shipped rows, share renderer, rename, normalise codex.models I-009: four `kind: "removed"` rows named keys that were introduced and removed inside this unreleased branch (anthropic.headerTimeoutMs 5ff696d, and limits.maxInFlightBytes/maxQueueDepth/maxQueueWaitMs f0b31d6, all removed in 26d237d; tags are v0.1.0/v0.2.0 only). No released config can carry them, so the rows were unreachable guidance instructing operators to audit six keys when only two are reachable. Deleted, per "leave the end-state, not the transition". LimitsSchema and FileConfigSchema are z.strictObject, so those paths remain a hard load error via Zod's unrecognized-key message — now pinned by a test (avoids PF-020: key REMOVAL is the breaking direction, and strict parsing already covers it, so no legacy-table row is owed). The two genuine v0.2.0 removals are kept, with reasons and assertions moved 0.2.1 -> 0.3.0 per the owner's release decision. I-024: LEGACY_KEY_MOVES -> LEGACY_KEY_ENTRIES. Every sibling identifier was already renamed to a neutral term (LegacyKeyEntry, LegacyKeyRemoval, detectLegacyConfigKeys); the table alone still claimed to be only about moves while holding removals. I-025: codex.models was hand-appended outside the table with no version and no ADR. Moved into the table as a `removed` row, kept LAST so the rendered message stays byte-stable, with its reason normalised to the sibling shape — removed in 0.2.0 (ADR-006), the compiled-in registry becoming the routable set. I-072: the loadConfig error prefix "outdated config layout in ..." described a layout problem before a list of delete/move instructions. Now "unsupported config keys in ...". Applied to init.ts's sibling message too — same condition, and leaving one of the pair behind would have created the divergence this batch is closing. The frozen 0.2.0 quote in CHANGELOG.md is untouched (avoids PF-023). I-080: the LegacyKeyEntry renderer ternary was written verbatim in config.ts and init.ts with no exhaustive arm, so a third `kind` compiled clean and rendered as a *delete* instruction in both. Replaced with one exported renderLegacyKeyEntry() carrying a `const _exhaustive: never` arm, matching server.ts:632-635. Proven: an isolated copy with a third kind fails tsc with TS2322 "not assignable to type 'never'". Both call sites now render through it, and init's copy — previously untested — has coverage. RED before GREEN: 7 assertions failed against the prior code (0.2.1 version strings, the Zod-message path for limits.maxInFlightBytes, codex.models' missing version/ADR, the error prefix, and both shared-renderer assertions); 130/130 pass after. tsc --noEmit clean. * fix(logger): strip all control characters at the renderToken chokepoint Widens the strip in renderToken from CR+LF-only to the full C0 control range (U+0000-U+001F), DEL (U+007F), and C1 controls (U+0080-U+009F). ESC, BEL, and C1 bytes previously passed through verbatim, enabling OSC 0 window-title rewrites and cursor-movement sequences that overwrite previously printed log lines -- defeating the anti-forgery claim in the comment block. Fix is at the single renderToken chokepoint so both the field-value path and the event-token path are covered without additional call sites (applies ADR-008). RED proven before fix: ESC and BEL survived renderToken on the current code. Avoids PF-011 by running the failing test before implementing the fix. Existing CR+LF stripping tests continue to pass -- both are within the C0 range. Resolves: I-048 Co-Authored-By: Claude <noreply@anthropic.com> * test(logger): move FIELD_KEYS completeness proof to logger.types.test.ts The codebase convention: module-level compile-time proofs live in the *.types.test.ts family (config.types.test.ts, provider-auth.types.test.ts, provider-events.types.test.ts); in-src `const _exhaustive: never` guards are reserved for function-local switch exhaustiveness checks. The _FieldKeysComplete type and _fieldKeysComplete const that were at module level in src/logger.ts are moved to the new test/unit/logger.types.test.ts, following the exact shape of the existing types-test files. FIELD_KEYS is exported so the types test can import and reference it. The `as const satisfies readonly (keyof LogFields)[]` annotation on FIELD_KEYS itself stays in src/logger.ts (it constrains the array declaration, not a standalone proof). Deliberate-omission verification: temporarily removing "sessionKey" from FIELD_KEYS produces: test/unit/logger.types.test.ts(29,9): error TS2322: Type 'true' is not assignable to type 'never'. Resolves: I-063 Co-Authored-By: Claude <noreply@anthropic.com> * test(bench): measure in-flight peak in an isolated proxy; assert a ceiling; add bench:memory The concurrent-upload bench reported a post-settle, post-GC trough and called it a peak, and its "2 GiB at ~683 concurrent" headline was cited as load-bearing evidence (PF-025). It also asserted nothing: all 100 requests could fail and the process still exited 0 (I-008). Rewritten so every number it prints is one it actually measured: - The proxy runs in a child process (this same file re-entered with --proxy-child, built through the CLI's own loadConfig -> buildDeps -> createProxyServer -> listenServer composition). The load generator and fake origin stay in the parent, so the sampled process.memoryUsage() is the relay's and nothing else's. - All N uploads are parked at the fake origin (mirroring startParkingUpstream in test/integration/concurrency.test.ts) and a 25 ms sampler keeps the running maximum across the whole parked window. - Both global.gc() calls are gone. Peak sampling makes them unnecessary, and they were silent no-ops under the documented invocation anyway. - The payload is a real Anthropic messages body padded to an exact wire size, so JSON.parse, the UTF-8 string and the parsed object graph are exercised. - CONCURRENCY (32), BODY_MIB (defaults to limits.maxBodyBytes, i.e. the ceiling) and CEILING_MIB_PER_REQ are env knobs, documented in the header. - Exits 1 if any request misses 200 after release, if fewer than N park, if the sampler starves, or if peak RSS per in-flight request exceeds the ceiling. Prints a one-line PASS/FAIL verdict plus succeeded/failed counts. Measured on Apple M1 Max / Node v22.22.3 at 32 x 32 MiB: peak RSS 2200 MiB, 66.2 MiB per in-flight request, 2.07x amplification over wire bytes, 2 GiB crossed at ~31 concurrent requests at maxBodyBytes -- not 683. The REFERENCE NUMBERS block is replaced with these figures and states plainly that they do not justify the absence of an aggregate bound: that is the deployment-envelope decision in ADR-010 (single local user, tens of agents, real bodies far below the 32 MiB per-request ceiling), and this bench exists to catch regressions in per-request cost. Also adds the bench:memory npm script and points CONTRIBUTING's bench instructions at it with the env knobs (I-066). Not added to CI or the npm test globs. applies ADR-010; avoids PF-025 Co-Authored-By: Claude <noreply@anthropic.com> * test: align describe titles, drop tombstones, reconcile 504/502 strictness I-030: retitle four describe() blocks in passthrough.test.ts to match the suite-wide convention — subject first, tag in trailing parentheses (B5, C7, B6, B7). Previous titles had a leading tag prefix that differed from all other describe blocks in the file and in server-wiring.test.ts. I-031: delete two tombstone comments that described a removal rather than the end state (routing-behavior.test.ts: "Second F7 variant removed…"; doctor.test.ts: "defaultConfig non-vacuity guard describe block was deleted…"). Git holds the history; the comments added noise. I-041: reconcile the strict 504 assertion in the connect-timeout test with its sibling at the synthesized-marker test, which already accepts 504||502 and explains why (ENETUNREACH on cloud/corporate egress). Changed assert.equal(status, 504) to assert.ok(status === 504 || status === 502) with the documented reason, giving both tests the same strictness for the same fixture. Co-Authored-By: Claude <noreply@anthropic.com> * test: strengthen health-under-parked-POST, drain-bound margin, codex-leg status assertions I-042: add maxUpstreamSockets: 1 to the health-under-in-flight-POST test so the parked POST holds the only upstream connection. Health answering under that condition proves the endpoint does not need an upstream socket — a falsifiable property (if health queued behind the parked POST the test would hang to the 30 s deadline). Retitle to state the property actually asserted. I-075: tighten the B7 drain-bound assertion from < 6_000 ms to > 1_500 && < 3_000 ms. The documented bound is 2 s (drainRejectedUpload); 6_000 ms is 3× slack and would pass even if the timer were set to 5 s. The tighter bound tolerates CI jitter (±1 s) while catching a bound regression; the lower side catches a spurious instant close. I-077: add response.status === 200 assertion to the "codex: prefix still resolves" routing test. Previously a 500 or 502 on the Codex leg would pass silently. The assertion bites: the Codex leg currently returns 502 (pre-existing src/ behavior, outside this batch's scope); the test is now RED and correctly visible, whereas before the vacuous pass hid the failure. Co-Authored-By: Claude <noreply@anthropic.com> * fix(server): per-request clientError guard; extract inbound-policy.ts Resolves I-004, I-005, I-014, I-016. I-004 — the clientError guard was per-SOCKET, not per-REQUEST. `socket.bytesWritten` is cumulative over the socket's lifetime, so after the first response on a keep-alive connection it is permanently non-zero and `bytesWritten === 0` is permanently false. Every later client error was silently destroyed: no 408, no 431, no Anthropic body, no synthesized marker, not even the `client_error` warn. Because registering the listener has already suppressed Node's canned reply, the intercept converted a reply into NO reply — strictly worse than the bare status line it replaced. This branch makes reuse the steady state (keepAliveTimeout 300 s, maxRequestsPerSocket 0), so it was the common case, not an edge. The fix keeps a WeakMap baseline of bytesWritten stamped when each response FINISHES, and replies iff bytesWritten equals that baseline — the public equivalent of Node's own private `_httpMessage._header` check. Only 'finish' stamps it: a response aborted mid-write must keep the old baseline so the guard still sees unflushed bytes and destroys. I-005 — the handler annotated its socket parameter as net.Socket where @types/node declares stream.Duplex. Listener parameters are bivariant, so that was an `as` in all but name, invisible to a grep for assertions; a non-net.Socket Duplex would make bytesWritten undefined, fail `=== 0`, and take the whole 400/408/431 taxonomy dark with no compile error. It is now annotated Duplex with an `instanceof net.Socket` narrowing, so that case is an explicit deliberate destroy. Proven load-bearing: removing the narrowing yields `TS2339: Property 'bytesWritten' does not exist on type 'Duplex'`. `err` is NodeJS.ErrnoException (optional members only, so satisfied by any Error), which drops the cast without reintroducing an unsound narrowing. I-014/I-016 — inbound HTTP transport policy moves out of server.ts (665 lines, four concerns) into src/inbound-policy.ts with ONE entry point, applyInboundPolicy(server, logger), owning SERVER_TUNING, CLIENT_ERROR_RESPONSES and the clientError handler. The separate attachClientErrorHandler export is deleted (it had zero importers across src/, test/ and e2e/). PF-021's lesson is that the timeouts and the response taxonomy for their expiry are ONE policy: exporting the halves separately let a caller apply tuning without the handler — PF-021's original defect verbatim — or the handler without the tuning, shaping 408s around Node's 60 s default. One call makes that unrepresentable. maxHeaderSize stays in server.ts's createServer options: it is constructor-only. server.ts is now 557 lines; the raw HTTP/1.1 wire serialization no longer lives there. Applies ADR-008 and ADR-010; avoids PF-021 and PF-011. Controls (avoids PF-011 — each proven RED by RUNNING it, not predicting): B4r/B4br repeat the 431 and 408 cases on a socket that has already completed one successful request/response, and additionally assert the `client_error` warn fires. Against the per-socket predicate both failed with "connection closed before a second, shaped response on the reused connection; received 0 byte(s)" — reproducing the measured symptom exactly. A first RED attempt failed on the FIXTURE instead (the helper did not understand the health endpoint's chunked framing); that was corrected so the controls fail for the reason they claim. Both GREEN after the fix; 41/41 in scope. SERVER_TUNING's literal-value pins are preserved unchanged, with the import path updated. Its JSDoc prose is left as-is for the batch that owns I-015/I-026/I-062. Co-Authored-By: Claude <noreply@anthropic.com> * test(routing): give the codex-prefix routing test a Responses-shaped upstream so its 200 assertion is satisfiable I-077: the fake Codex upstream was returning Anthropic-shaped SSE events (message_start, content_block_stop, message_delta, message_stop). The codex leg translates OpenAI Responses-API SSE, so those events were all silently ignored, the aggregator found no message_start, and returned 502. Replace the body with a minimal valid Responses-API stream (response.created → response.completed) that the translator converts to message_start + message_delta + message_stop, allowing aggregateFrames to assemble a 200 response. Co-Authored-By: Claude <noreply@anthropic.com> * fix(config): strict reasoningCache schema; drop unused ProviderFileConfig; fix maxBodyBytes doc; dedupe defaults test I-039: tighten reasoningCache from z.object to z.strictObject — a typo'd leaf key (e.g. maxEntires) now hard-errors instead of silently reverting to defaults (avoids PF-010; non-breaking per PF-020). Regression test added under TS-02 describe block: RED against z.object, GREEN after fix. I-053: delete export type ProviderFileConfig — zero consumers in src/ or test/ (noUnusedLocals cannot see exported types; grep confirmed before and after). I-067: fix JSDoc on maxBodyBytes — drop "Codex" from "before the Codex routing decision"; server.ts calls bufferBody for every POST /v1/messages* and forwards the buffered body on the Anthropic leg too, so "before the routing decision" is the correct text and config.ts was the drifted copy. I-045: delete duplicate anthropic.maxUpstreamSockets default assertion that sat under the "Removed keys" banner (line ~333) — the assertion at line ~30 already covers it. Co-Authored-By: Claude <noreply@anthropic.com> * fix(errors): origin-consistent 404/413 mapping; drop dead overloaded_error; status-order the union I-034 (ADR-010): add 404 → not_found_error and 413 → request_too_large arms to upstreamStatusToAnthropicError so the same HTTP status carries the same error.type whether the relay or the origin produced it. I-018: overloaded_error had zero producers (verified: upstream 529 falls to api_error via the status >= 500 arm; Anthropic-leg 529 passes through raw headers, never touches the mapper). git grep -n overloaded_error confirmed only docs and the definition itself. Delete the member and its retention comment; add a doc note that 5xx including 529 maps to api_error. I-029: not_found_error was appended last in the union but belongs between permission_error (403) and request_too_large (413) in status order. I-064: not_found_error's comment named its one consumer (/__subswitch/* 404), making it stale the moment a second 404 site appears. State the meaning instead: resource or path not found; the origin's 404 error type. I-013: CHANGELOG [0.3.0] — add maxHeaderSize: 64 * 1024 to the SERVER_TUNING Changed entry (fifth constant, determines 431 threshold); add not_found_error alongside request_too_large in the Added bullet. Co-Authored-By: Claude <noreply@anthropic.com> * release: 0.3.0 — bump version literals, pin CHANGELOG heading in version.test.ts, end-state BREAKING notes Owner decision: the BREAKING config-key removal ships as a minor bump, matching the project's own precedent for a config-format break (0.1.0 -> 0.2.0). - version.test.ts now pins all five release literals, not two: src/version.ts, package.json, both package-lock.json version fields, and the newest `## [X.Y.Z]` heading in CHANGELOG.md. Proven RED against the pre-bump tree (CHANGELOG 0.2.1 vs literals 0.2.0) and re-proven non-vacuous on the lockfile arm by mutation. Closes the sync gap PF-014 left open, where the only assertion compared two files that are always co-edited and so could not fail. - package.json, both package-lock.json fields, and src/version.ts -> 0.3.0. - README BREAKING note states the end state: two keys removed (anthropic.streamIdleTimeoutMs, limits.maxConcurrentRequests), not six. The other four were born and died inside this branch and never shipped in 0.2.0, so no config that has ever existed can contain them. The changelog link was a bare `#fragment` resolving inside README against a wrong slug; it now points at CHANGELOG.md#030---2026-08-19. The matching CHANGELOG edits (heading retitle + BREAKING list trimmed to the two shipped keys) were made in the working tree and swept into 3ab02ce by a concurrent `git commit --only -- CHANGELOG.md`; content is intact in HEAD. The `removed in 0.3.0` strings in src/config.ts stay hardcoded rather than being derived from SUBSWITCH_VERSION: a frozen historical fact must not follow the current version (avoids PF-023). Addresses I-002, I-012, I-021, I-049, and the user-facing half of I-009. * docs(inbound-policy): accurate tuning rationale; idempotent apply; prototype-safe error lookup I-015 (avoids PF-018): keepAliveTimeout JSDoc replaced with the real rationale. Node's 5 s default closes an idle inbound keep-alive socket at the exact moment a client starts its next POST, producing an ECONNRESET the relay cannot retry for a non-idempotent request; 300 s makes that window negligible. The previous claim ("matches the Anthropic keep-alive pool") was false: the outbound agent has no idle-socket timeout and governs a disjoint socket set. I-026: The applyInboundPolicy inline comment now states the 4+1 split explicitly. Four timer/counter knobs are set after construction; maxHeaderSize is constructor-only (no http.Server property) and is passed in the createServer options object in server.ts. The SERVER_TUNING JSDoc already stated this correctly; the inline comment was the inconsistency. I-062: SERVER_TUNING JSDoc now notes that the member names mirror http.Server's own property names verbatim and are unrelated to providers.*.requestTimeoutMs. I-074 (avoids PF-021): applyInboundPolicy is now idempotent via a module-level WeakSet<http.Server> guard. A second call on the same server returns early; without the guard, double-calling would register two clientError listeners and double-reply into the same socket. Tested: server.listenerCount("clientError") === 1 after two calls. I-055: Extract responseForClientError(code) using Object.hasOwn so prototype properties ("constructor", "__proto__") cannot masquerade as response descriptors. The bare bracket lookup reaches Object.prototype for those names; ?? does not fire (truthy function value); the socket would receive "HTTP/1.1 undefined undefined". Matches Object.hasOwn usage at config.ts and models.ts. RED/GREEN: 4 failures proved before fix, 7/7 pass after. I-078: socket.end(head + body, () => socket.destroy()) ensures destroy() runs only after the kernel accepts the write. The bare sequential pattern races the buffer and can truncate the synthesized body before the client reads it. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(server): drop dead header-override params; flush before destroy; name the drain bound I-017: synthesizedHeaders() extra param deleted. All four call sites pass zero args; the docblock demonstrated { connection: "close" } which is exactly the header this branch removed from the 413 response with an 11-line comment explaining why it was wrong. A future contributor following the docblock would repeat the mistake. I-056 (ADR-010): respondJson extraHeaders spread order corrected so the synthesized marker is always last and cannot be overridden by a caller. A call site in codex-handler.ts passes retry-after via extraHeaders, so the param itself is kept; the ordering fix makes the marker non-overridable by construction. The param deletion is listed in Observed, not fixed. I-058: respondJson docblock now scopes the "always synthesized" premise to the translating (Codex) leg. The Anthropic passthrough leg's upstream responses are not marked by this helper; only relay-generated errors on that leg are synthesized. The module name is generic so the scoping note is load-bearing for future providers. I-060: REJECTED_UPLOAD_DRAIN_MS = 2_000 extracted as a named module-level const colocated with drainRejectedUpload. The 2_000 literal was the only inline numeric bound on a branch where every other bound is named. The constant is colocated (not in SERVER_TUNING) because a later batch will move drainRejectedUpload to provider-transport.ts and the constant must travel with it. The B7 test window (1.5-3 s) remains valid. Co-Authored-By: Claude <noreply@anthropic.com> * docs(knowledge): correct cli-ux and codex-leg KBs — single legacy-key table, removal not deprecation, new chokepoin…
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.
Summary
Removes the upstream retry (L2) that was reproducing billing duplicates against the real Anthropic API, and fixes the
x-subswitch-synthesizedmarker header so it is both complete (covers all synthesized responses including the codex leg) and authoritative (stripped from proxied responses so an origin cannot impersonate it).Changes
Drop the retry (L2):
err.codecannot distinguish a stale pooled socket from an origin that began processing the request. Reproduced duplicate billed POSTs in pre-merge testing.test/integration/passthrough.test.ts:169passes with no retry). The Anthropic SDK retries connection errors above the relay.anthropic_upstream_retrylog event,isConnectionErrorpredicate,applyRequestHeadershelper (inlined — was only kept for retry path).Fix
x-subswitch-synthesized(L3):HOP_BY_HOPinfilterRawHeaders— an origin setting the header cannot have it forwarded to clients.respondJsondefault: marker included by default so all 12+ codex-leg call sites (bothrespondJsonandrespondProxyError) are covered in one edit. The codex leg translates Codex→Anthropic for every byte; synthesized is always correct here.res.writeHead(200, ...)incodex-handler.ts(the one path not going throughrespondJson).synthesizedHeaders()helper inserver.ts: replaces per-call-site{ "content-type": "...", "x-subswitch-synthesized": "1" }spreads — new synthesized response sites are correct by default.## x-subswitch-synthesizedsection documents the operator-facing wire contract.Keep Change 3 (client-abort warn fix): unchanged. Added comment explaining the timeout handler's asymmetry (why it doesn't check
settledbeforeupstream.destroy()).Breaking Changes
None.
x-subswitch-synthesizedis newly present on codex-leg responses (it was claimed in the previous CHANGELOG entry but only partially implemented). Proxied responses no longer forward the header if an upstream sets it.Reviewer Focus Areas
src/anthropic-passthrough.ts: retry removal and header strip (HOP_BY_HOP)src/provider-transport.ts:respondJsonnow always emits the markersrc/server.ts:synthesizedHeaders()helpersrc/codex-handler.ts: SSE writeHead addition