diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e1e43c..f831c1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [0.2.1] - 2026-08-19
+
+### Fixed
+
+- **Anthropic passthrough — `connectTimeoutMs` now genuinely bounds TCP connection
+ establishment; new `headerTimeoutMs` knob bounds time to first byte** (fixes #27).
+ Previously `upstream.setTimeout(connectTimeoutMs)` was called on the `ClientRequest`
+ object, but Node's implementation defers that call via an internal `'connect'`
+ listener — so it fired only after TCP connect, in the same tick as the
+ `headerTimeoutMs` rearm, meaning `connectTimeoutMs` was never in force for any
+ measurable interval. Measured against a blackholed IP (`192.0.2.1`, TEST-NET-1)
+ with `connectTimeoutMs=700` and `headerTimeoutMs=5000`, the request previously
+ failed after **75 019 ms** (the macOS kernel TCP timeout) — neither budget fired.
+ The fix arms the timer **directly on the socket** inside the `'socket'` event
+ handler, before `'connect'` fires, so the connect-phase budget is in force for
+ the full TCP handshake window. The same measured scenario now fails at ~700 ms.
+ On HTTPS, `'connect'` fires after TCP establishment but before the TLS handshake,
+ so TLS negotiation falls under `headerTimeoutMs`, not `connectTimeoutMs` — this is
+ documented in the JSDoc. The fix also introduces a three-budget design:
+ `connectTimeoutMs` (10 s, TCP establishment only), `headerTimeoutMs` (660 s
+ default, connect→response-headers — defaults to 60 s above Anthropic's own ~600 s
+ server-side ceiling; the extra headroom accounts for the relay's clock starting at
+ TCP connect while the origin's starts only after the full request body is received,
+ so equal budgets would let the relay pre-empt the origin by the upload time plus
+ RTT), and `streamIdleTimeoutMs` (300 s, headers→stream-end, reset by every chunk).
+ **A hung upstream that accepts the TCP connection but never sends headers now takes
+ up to `headerTimeoutMs` (default 660 s) to fail, not 10 s** — tune this knob down
+ if you need faster detection of stalled upstreams.
+
+- **Anthropic passthrough — `x-subswitch-synthesized: 1` header marks every relay-generated response; stripped from proxied responses** (stacks on #30).
+ Previously a relay fault (502 connection failure, 504 timeout, 500 internal proxy error, 413 body too large,
+ 503 concurrency gate) was indistinguishable from an upstream outage on the client side. Every response the
+ relay synthesises itself now carries `x-subswitch-synthesized: 1`; this covers the Anthropic-leg error
+ paths, all codex-leg responses (both streaming SSE and aggregated JSON — the codex leg translates
+ Codex→Anthropic format so every byte it returns is relay-synthesised), and every relay-generated error in
+ `server.ts`. On the proxied path the header is actively stripped from upstream responses via the hop-by-hop
+ filter so an origin that sets it cannot impersonate the relay. Bodies and status codes are unchanged — the
+ header is purely additive. See the new `## x-subswitch-synthesized` section in the README for the
+ operator-facing wire contract.
+
+- **Anthropic passthrough — client abort no longer logs a spurious `anthropic_upstream_error` warn** (stacks on #30).
+ `res.on("close")` was calling `upstream.destroy()` without first setting `settled = true`. The destroy
+ emits `'error'` on the next tick; with `settled = false` the error handler logged `anthropic_upstream_error`
+ and attempted to write a 502 into an already-closed response. A client hanging up is a normal event;
+ it now sets `settled = true` before the destroy so the error handler returns early. Additionally, the
+ error handler now sets `settled = true` before writing the 502 so a theoretically possible second
+ `'error'` emission cannot double-write.
+
+- **Anthropic passthrough — duplicate `anthropic_upstream_error` warn eliminated**
+ (fixes #27). When a pre-header timeout fired, the timeout handler called
+ `upstream.destroy()` before writing the 504. Destroying an in-flight
+ `ClientRequest` causes it to emit `error` (ECONNRESET) on the next tick; the
+ error handler's `responded` guard was inert on the pre-header path, so both
+ `anthropic_upstream_timeout` and `anthropic_upstream_error` were logged. On
+ `main` the actual response sequence was `writeHead(504)` → `res.end()`
+ (synchronous, in the timeout handler) → next tick, error handler sees
+ `headersSent === true` → `res.destroy()`; the client received a complete 504 body
+ and the real defect was the duplicate warn log only. A new `settled` flag is now
+ set by whichever handler responds first; the error handler returns early when
+ `settled` is true, ensuring exactly one warn event per timeout.
+
## [0.2.0] - 2026-08-09
### Upgrading from 0.1.0
diff --git a/README.md b/README.md
index 060a985..b422170 100644
--- a/README.md
+++ b/README.md
@@ -277,8 +277,9 @@ All keys and their defaults:
| `port` | `4141` | Port the proxy listens on |
| `logLevel` | `"info"` | Log verbosity: `debug`, `info`, `warn`, or `error` |
| `anthropic.baseUrl` | `"https://api.anthropic.com"` | Anthropic passthrough base URL |
-| `anthropic.connectTimeoutMs` | `10000` (10 s) | **Anthropic leg only** — TCP connection timeout (see note below) |
-| `anthropic.streamIdleTimeoutMs` | `300000` (5 min) | Anthropic stream idle timeout |
+| `anthropic.connectTimeoutMs` | `10000` (10 s) | **Anthropic leg only** — TCP connection establishment timeout (see note below) |
+| `anthropic.headerTimeoutMs` | `660000` (11 min) | **Anthropic leg only** — time from TCP connect to first response byte; defaults to 60 s above Anthropic's own ~600 s server-side ceiling so the relay never fires before the origin does (the relay's clock starts earlier than the origin's — see note below) |
+| `anthropic.streamIdleTimeoutMs` | `300000` (5 min) | Anthropic stream idle timeout (headers→stream-end, reset by every chunk) |
| `anthropic.maxUpstreamSockets` | `32` | **Anthropic leg only** — max sockets in the keep-alive pool (see note below) |
| `anthropic.allowInsecureBaseUrl` | `false` | **Security opt-in** — when false (the default), `subswitch serve` refuses to start if `anthropic.baseUrl` points at a host other than `api.anthropic.com`. Set to `true` only when routing through a trusted proxy in front of Anthropic's API. Loopback addresses are always exempt. |
| `providers.codex.baseUrl` | `"https://chatgpt.com/backend-api/codex"` | Codex backend base URL — override to route subswitch through the wire recorder |
@@ -297,11 +298,11 @@ All keys and their defaults:
| `limits.pingIntervalMs` | `15000` (15 s) | Interval between SSE ping frames sent to clients during long Codex streams |
| `limits.maxConcurrentRequests` | `32` | In-flight request ceiling; requests above this limit receive a 503 |
-> **Why `connectTimeoutMs` and `maxUpstreamSockets` are Anthropic-leg-only**: the
+> **Why `connectTimeoutMs`, `headerTimeoutMs`, and `maxUpstreamSockets` are Anthropic-leg-only**: the
> Anthropic passthrough uses a node:http agent with an explicit keep-alive pool, so
-> both knobs have meaningful effect there. The Codex leg uses Node's global `fetch`
-> (undici's global dispatcher), which `maxUpstreamSockets` does not control — shipping
-> them as per-provider keys would be config that bounds nothing on the Codex side.
+> all three knobs have meaningful effect there. The Codex leg uses Node's global `fetch`
+> (undici's global dispatcher), which these knobs do not control — shipping them as
+> per-provider keys would be config that bounds nothing on the Codex side.
### `subswitch models --json`
@@ -394,6 +395,36 @@ is not a TTY (useful in terminals that misreport TTY state); `CI` — also suppr
color and disables interactive `init` prompts (treated as a non-interactive
environment).
+## `x-subswitch-synthesized`
+
+Every HTTP response that subswitch generates itself — rather than proxying
+verbatim from an upstream — carries the response header:
+
+```
+x-subswitch-synthesized: 1
+```
+
+This header is present on:
+
+- **Anthropic-leg relay errors**: 502 (upstream connection failure), 504
+ (upstream timeout), 413 (request body too large), 503 (concurrency gate),
+ 500 (internal proxy error).
+- **Codex-leg responses**: every byte returned on the codex leg is synthesized
+ by the relay (it translates OpenAI Responses format → Anthropic Messages
+ format), so the header is present on both streaming and non-streaming codex
+ responses, and on all codex-leg error responses.
+- **Relay management endpoints**: `/__subswitch/health`, `/__subswitch/404`,
+ and all other relay-internal routes.
+
+The header is **absent** on responses proxied verbatim from the Anthropic
+origin — including upstream errors (429 rate-limit, 529 overloaded, 500
+upstream internal error, etc.). The header is also **stripped** from any
+upstream response that carries it, so the marker is authoritative: its presence
+means the relay synthesised the response; its absence means the upstream did.
+
+Operators can use this header in load-balancer health rules, log filters, or
+alerting to distinguish relay faults from upstream outages.
+
## Testing
```sh
diff --git a/src/anthropic-passthrough.ts b/src/anthropic-passthrough.ts
index 892ab98..fcae3df 100644
--- a/src/anthropic-passthrough.ts
+++ b/src/anthropic-passthrough.ts
@@ -12,6 +12,9 @@ import type { Logger } from "./logger.js";
*
* Per RFC 7230 §6.1. `host` is also excluded: Node sets it on the outbound
* connection. `connection` is managed by the keep-alive agent.
+ *
+ * `x-subswitch-synthesized` is also stripped from proxied responses so that
+ * the marker is authoritative: only the relay itself can assert it.
*/
const HOP_BY_HOP = new Set([
"host",
@@ -24,6 +27,7 @@ const HOP_BY_HOP = new Set([
"trailer",
"transfer-encoding",
"upgrade",
+ "x-subswitch-synthesized",
]);
/**
@@ -53,7 +57,29 @@ const filterRawHeaders = (rawHeaders: readonly string[]): string[] => {
export interface PassthroughOptions {
readonly baseUrl: string;
+ /**
+ * Bounds TCP connection establishment only (milliseconds).
+ * The timer is armed directly on the socket (not via `ClientRequest.setTimeout`,
+ * which defers internally and cannot bound the connect phase). Once TCP connects,
+ * the timer is re-armed to `headerTimeoutMs`.
+ *
+ * On HTTPS connections, `'connect'` fires after TCP establishment but before the
+ * TLS handshake, so TLS negotiation falls under `headerTimeoutMs`, not this budget.
+ *
+ * For pooled/keep-alive sockets (no connect phase), `headerTimeoutMs` is armed
+ * immediately and this budget has no effect.
+ */
readonly connectTimeoutMs: number;
+ /**
+ * Bounds the connect→response-headers phase (time to first byte), in milliseconds.
+ * Armed on socket connect (or immediately for pooled sockets).
+ * Re-armed to `streamIdleTimeoutMs` once headers arrive.
+ */
+ readonly headerTimeoutMs: number;
+ /**
+ * Bounds the headers→stream-end phase, in milliseconds.
+ * Reset by every received chunk.
+ */
readonly streamIdleTimeoutMs: number;
readonly logger: Logger;
/** Maximum sockets in the keep-alive pool. Config key: limits.maxUpstreamSockets. */
@@ -75,16 +101,17 @@ export const createAnthropicForwarder = (options: PassthroughOptions): Anthropic
// Create a keep-alive agent for persistent connections to the upstream.
// This matches Claude Code's own direct connection behaviour (parity).
- //
- // Residual risk: a stale pooled socket can ECONNRESET a POST; the existing
- // upstream.on("error") handler returns a clean 502 and Claude Code retries.
- // This is the same behaviour as any keep-alive HTTP client.
const agentOpts: http.AgentOptions = { keepAlive: true, maxSockets: options.maxUpstreamSockets, scheduling: "lifo" };
const agent = options.agent ?? (target.protocol === "https:" ? new https.Agent(agentOpts) : new http.Agent(agentOpts));
return (req, res, body) => {
const path = `${basePath}${req.url ?? "/"}`;
- let responded = false;
+ // `settled` is set by whichever handler wins the race — the response
+ // callback (headers received) or the timeout handler (504 written).
+ // The error handler uses it as its early-return guard so that a
+ // destroy() issued by the timeout handler does not produce a duplicate
+ // `anthropic_upstream_error` warn after the 504 has already been sent.
+ let settled = false;
const upstream = client.request(
{
@@ -100,11 +127,13 @@ export const createAnthropicForwarder = (options: PassthroughOptions): Anthropic
// (cross-name position of interleaved duplicates is not guaranteed).
},
(upstreamRes) => {
- responded = true;
+ settled = true;
upstream.setTimeout(options.streamIdleTimeoutMs);
// Response direction: writeHead accepts a flat [name, value, ...] array
// directly (Node's _storeHeader Array branch), preserving the upstream's
// original header casing, order, and duplicates byte-for-byte.
+ // filterRawHeaders strips x-subswitch-synthesized so an origin that sets
+ // it cannot impersonate the relay's synthesized-response marker.
res.writeHead(upstreamRes.statusCode ?? 502, filterRawHeaders(upstreamRes.rawHeaders));
res.socket?.setNoDelay(true);
upstreamRes.pipe(res);
@@ -112,8 +141,48 @@ export const createAnthropicForwarder = (options: PassthroughOptions): Anthropic
},
);
- upstream.setTimeout(options.connectTimeoutMs);
- upstream.on("socket", (socket) => socket.setNoDelay(true));
+ // Timer arming — three-budget design:
+ //
+ // 1. connectTimeoutMs — bounds TCP establishment, armed DIRECTLY on the
+ // socket (not via upstream.setTimeout). ClientRequest.setTimeout()
+ // defers internally via its own 'connect' listener, so it would fire
+ // only after connect — in the same tick as the headerTimeoutMs rearm,
+ // leaving connectTimeoutMs never actually in force.
+ //
+ // Node v22's internal socket-timeout handler (onTimeout) skips
+ // req.emit('timeout') when socket.connecting is true, so we must
+ // propagate the timeout manually via upstream.emit('timeout').
+ // On HTTPS, 'connect' fires after TCP but BEFORE the TLS handshake, so
+ // TLS negotiation falls under headerTimeoutMs, not connectTimeoutMs.
+ //
+ // 2. headerTimeoutMs — armed on 'connect' (or immediately for a pooled
+ // socket where no 'connect' event will ever fire). Bounds the window
+ // from TCP-connected to first response byte. upstream.setTimeout() in
+ // the connected state propagates normally via Node's internal handler.
+ //
+ // 3. streamIdleTimeoutMs — armed in the response callback once headers
+ // arrive; reset by every received chunk.
+ upstream.on("socket", (socket) => {
+ socket.setNoDelay(true);
+ if (socket.connecting) {
+ socket.setTimeout(options.connectTimeoutMs);
+ const onConnectTimeout = () => {
+ socket.removeListener("connect", onConnect);
+ socket.setTimeout(0); // disarm before manual propagation
+ upstream.emit("timeout"); // triggers our handler → 504
+ };
+ const onConnect = () => {
+ socket.removeListener("timeout", onConnectTimeout);
+ socket.setTimeout(0); // cancel connect budget
+ upstream.setTimeout(options.headerTimeoutMs);
+ };
+ socket.once("timeout", onConnectTimeout);
+ socket.once("connect", onConnect);
+ } else {
+ // Pooled/keep-alive socket — no connect phase; arm header budget immediately.
+ upstream.setTimeout(options.headerTimeoutMs);
+ }
+ });
// Request direction: build a Map from the filtered rawHeaders so that
// duplicates (same lowercase key, different values) are preserved as array
@@ -140,29 +209,52 @@ export const createAnthropicForwarder = (options: PassthroughOptions): Anthropic
}
upstream.on("timeout", () => {
- upstream.destroy();
+ // Log and write the 504 BEFORE destroy() to narrow the race window with
+ // the 'error' handler. destroy() on an in-flight ClientRequest emits
+ // 'error' (ECONNRESET) on the next tick; setting `settled = true` here
+ // prevents that error from producing a duplicate warn log.
options.logger.log("warn", "anthropic_upstream_timeout", { path: req.url ?? "/" });
if (!res.headersSent) {
- res.writeHead(504, { "content-type": "application/json" });
+ settled = true;
+ res.writeHead(504, { "content-type": "application/json", "x-subswitch-synthesized": "1" });
res.end(toAnthropicErrorBody("api_error", "upstream timed out"));
} else {
res.destroy();
}
+ upstream.destroy();
});
upstream.on("error", () => {
- if (responded) return;
+ if (settled) return;
+ // Log and surface as 502 only when the client response is still open.
+ // Set settled before writing so a second 'error' emission (practically
+ // unreachable after socket destroy, but possible in theory) cannot
+ // double-write.
options.logger.log("warn", "anthropic_upstream_error", { path: req.url ?? "/" });
if (!res.headersSent) {
- res.writeHead(502, { "content-type": "application/json" });
+ settled = true;
+ res.writeHead(502, { "content-type": "application/json", "x-subswitch-synthesized": "1" });
res.end(toAnthropicErrorBody("api_error", "upstream connection failed"));
} else {
res.destroy();
}
});
+ // Change 3: set settled before destroy() so the error event emitted by
+ // destroy() on the next tick does not trigger a spurious
+ // anthropic_upstream_error warn or attempt to write a 502 into an already-
+ // closed response. A client abort is an entirely normal event — it must
+ // produce no warn log and no synthetic HTTP response.
+ //
+ // Note: the timeout handler does not check `settled` before calling
+ // upstream.destroy() because it sets `settled = true` itself before the
+ // destroy, so by the time the 'error' event fires on the next tick the
+ // guard has already been set and the error handler returns early.
res.on("close", () => {
- if (!res.writableFinished) upstream.destroy();
+ if (!res.writableFinished) {
+ settled = true;
+ upstream.destroy();
+ }
});
if (body !== undefined) {
diff --git a/src/codex-handler.ts b/src/codex-handler.ts
index 72f303d..258ea15 100644
--- a/src/codex-handler.ts
+++ b/src/codex-handler.ts
@@ -349,7 +349,7 @@ export const createCodexHandler =
(deps: CodexHandlerDeps<
resetIdle();
if (wantStream) {
- res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" });
+ res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", "x-subswitch-synthesized": "1" });
res.socket?.setNoDelay(true);
// res is written manually rather than placed inside pipeline(): on an
// upstream error, pipeline destroys every stream it owns, which would
diff --git a/src/config.ts b/src/config.ts
index 78e2e1c..8633114 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -59,9 +59,36 @@ const AnthropicSchema = z
.url()
.refine(requireHttpsOrLoopback, { message: `anthropic.baseUrl ${HTTPS_REQUIRED_MESSAGE}` })
.default("https://api.anthropic.com"),
- /** Connection timeout for all upstream requests to the Anthropic leg. */
+ /**
+ * TCP connection-establishment timeout for the Anthropic leg (milliseconds).
+ * Bounds only the time to establish a new TCP connection; the timer is armed
+ * directly on the socket (not via `ClientRequest.setTimeout`, which defers
+ * internally and cannot bound the connect phase).
+ * Once TCP connects the timer is re-armed to `headerTimeoutMs`.
+ *
+ * On HTTPS connections, `'connect'` fires after TCP establishment but before
+ * the TLS handshake, so TLS negotiation falls under `headerTimeoutMs`.
+ *
+ * Has no effect on reused pooled sockets (no connect phase).
+ */
connectTimeoutMs: z.number().int().positive().default(10_000),
- /** Stream idle timeout for the Anthropic passthrough. */
+ /**
+ * Time from TCP connection established to first response byte (headers), in
+ * milliseconds. Bounds only the connect→response-headers phase; once headers
+ * arrive the timer is re-armed to `streamIdleTimeoutMs` for the stream body.
+ * Defaults to 660 000 ms — 60 s above Anthropic's own ~600 s server-side
+ * ceiling — so the relay never fires before the origin does on a legitimate
+ * long-running request (e.g. a non-streaming Opus completion with large
+ * max_tokens). The extra 60 s headroom exists because the relay's clock
+ * starts at TCP connect whereas the origin's starts when it has received the
+ * full request body; equal budgets with an earlier start would let the relay
+ * pre-empt the origin by the request-upload time plus RTT.
+ */
+ headerTimeoutMs: z.number().int().positive().default(660_000),
+ /**
+ * Stream idle timeout for the Anthropic passthrough (milliseconds).
+ * Bounds the headers→stream-end phase; reset by every received chunk.
+ */
streamIdleTimeoutMs: z.number().int().positive().default(300_000),
/** Maximum sockets in the keep-alive pool for the Anthropic passthrough. */
maxUpstreamSockets: z.number().int().positive().default(32),
@@ -296,6 +323,7 @@ export interface Config {
readonly anthropic: {
readonly baseUrl: string;
readonly connectTimeoutMs: number;
+ readonly headerTimeoutMs: number;
readonly streamIdleTimeoutMs: number;
readonly maxUpstreamSockets: number;
/**
@@ -588,6 +616,7 @@ export const resolveConfig = (file: FileConfig): Config => ({
anthropic: {
baseUrl: file.anthropic.baseUrl,
connectTimeoutMs: file.anthropic.connectTimeoutMs,
+ headerTimeoutMs: file.anthropic.headerTimeoutMs,
streamIdleTimeoutMs: file.anthropic.streamIdleTimeoutMs,
maxUpstreamSockets: file.anthropic.maxUpstreamSockets,
allowInsecureBaseUrl: file.anthropic.allowInsecureBaseUrl,
diff --git a/src/provider-transport.ts b/src/provider-transport.ts
index 614cc2c..a714162 100644
--- a/src/provider-transport.ts
+++ b/src/provider-transport.ts
@@ -6,6 +6,11 @@ import { proxyErrorToAnthropic, toAnthropicErrorBody, type ProxyError } from "./
/**
* Send a JSON response, no-op if headers were already sent.
+ *
+ * Every response emitted by a provider handler is synthesized by the relay
+ * (the codex leg translates Codex→Anthropic; no byte is forwarded verbatim).
+ * `x-subswitch-synthesized: 1` is therefore always correct here and is
+ * included by default so callers cannot forget it.
*/
export const respondJson = (
res: ServerResponse,
@@ -14,7 +19,7 @@ export const respondJson = (
extraHeaders: Record = {},
): void => {
if (res.headersSent) return;
- res.writeHead(status, { "content-type": "application/json", ...extraHeaders });
+ res.writeHead(status, { "content-type": "application/json", "x-subswitch-synthesized": "1", ...extraHeaders });
res.end(body);
};
diff --git a/src/server.ts b/src/server.ts
index 90f2a86..2d9d853 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -215,6 +215,7 @@ export const buildDeps = (config: Config, logger: Logger = createConsoleLogger(c
forwardAnthropic: createAnthropicForwarder({
baseUrl: config.anthropic.baseUrl,
connectTimeoutMs: config.anthropic.connectTimeoutMs,
+ headerTimeoutMs: config.anthropic.headerTimeoutMs,
streamIdleTimeoutMs: config.anthropic.streamIdleTimeoutMs,
maxUpstreamSockets: config.anthropic.maxUpstreamSockets,
logger,
@@ -308,6 +309,20 @@ const peekModel = (parsed: unknown): string | undefined => {
return result.success ? result.data.model : undefined;
};
+/**
+ * Base headers for every response the relay generates itself (as opposed to
+ * responses proxied verbatim from an upstream). The `x-subswitch-synthesized`
+ * marker is included here so callers cannot forget it and future synthesized
+ * response sites are correct by default.
+ *
+ * Pass extra headers (e.g. `{ connection: "close" }`) as `extra`.
+ */
+const synthesizedHeaders = (extra: Record = {}): Record => ({
+ "content-type": "application/json",
+ "x-subswitch-synthesized": "1",
+ ...extra,
+});
+
export const createProxyServer = (deps: ServerDeps): Server => {
const { config, logger } = deps;
let activeRequests = 0;
@@ -333,11 +348,11 @@ export const createProxyServer = (deps: ServerDeps): Server => {
// /__subswitch/* namespace: handled locally, never forwarded upstream.
if (pathname.startsWith("/__subswitch/")) {
if (req.method === "GET" && pathname === "/__subswitch/health") {
- res.writeHead(200, { "content-type": "application/json" });
+ res.writeHead(200, synthesizedHeaders());
res.end(buildHealthBody(config));
return;
}
- res.writeHead(404, { "content-type": "application/json" });
+ res.writeHead(404, synthesizedHeaders());
res.end(JSON.stringify({ error: "not found" }));
return;
}
@@ -348,7 +363,7 @@ export const createProxyServer = (deps: ServerDeps): Server => {
res.on("close", () => { activeRequests--; });
if (activeRequests > config.limits.maxConcurrentRequests) {
route = "rate_limited";
- res.writeHead(503, { "content-type": "application/json" });
+ res.writeHead(503, synthesizedHeaders());
res.end(toAnthropicErrorBody("overloaded_error", "too many concurrent requests — try again shortly"));
return;
}
@@ -363,7 +378,7 @@ export const createProxyServer = (deps: ServerDeps): Server => {
const body = await bufferBody(req, config.limits.maxBodyBytes);
if (!body.ok) {
if (body.error.kind === "body_too_large") {
- res.writeHead(413, { "content-type": "application/json", connection: "close" });
+ res.writeHead(413, synthesizedHeaders({ connection: "close" }));
res.end(toAnthropicErrorBody("invalid_request_error", body.error.message));
req.destroy();
}
@@ -416,7 +431,7 @@ export const createProxyServer = (deps: ServerDeps): Server => {
// Two providers claim the same family name. Reject with 400 naming both.
route = "ambiguous";
logger.log("warn", "ambiguous_model_name", { model: decision.name });
- res.writeHead(400, { "content-type": "application/json" });
+ res.writeHead(400, synthesizedHeaders());
res.end(
toAnthropicErrorBody(
"invalid_request_error",
@@ -430,7 +445,7 @@ export const createProxyServer = (deps: ServerDeps): Server => {
// "kimee:k2" — provider prefix not in PROVIDER_IDS. Reject with 400.
route = "unknown_provider";
logger.log("warn", "unknown_provider_qualifier", { model: decision.qualifier });
- res.writeHead(400, { "content-type": "application/json" });
+ res.writeHead(400, synthesizedHeaders());
res.end(
toAnthropicErrorBody(
"invalid_request_error",
@@ -452,7 +467,7 @@ export const createProxyServer = (deps: ServerDeps): Server => {
dispatch().catch((cause: unknown) => {
logger.log("error", "request_failed", { path: pathname, errorCode: cause instanceof Error ? cause.name : "unknown" });
if (!res.headersSent) {
- res.writeHead(500, { "content-type": "application/json" });
+ res.writeHead(500, synthesizedHeaders());
res.end(toAnthropicErrorBody("api_error", "internal proxy error"));
} else if (!res.writableEnded) {
res.destroy();
diff --git a/subswitch.config.example.json b/subswitch.config.example.json
index dfdac41..ca75f21 100644
--- a/subswitch.config.example.json
+++ b/subswitch.config.example.json
@@ -4,6 +4,7 @@
"anthropic": {
"baseUrl": "https://api.anthropic.com",
"connectTimeoutMs": 10000,
+ "headerTimeoutMs": 660000,
"streamIdleTimeoutMs": 300000,
"maxUpstreamSockets": 32,
"allowInsecureBaseUrl": false
diff --git a/test/integration/codex-leg.test.ts b/test/integration/codex-leg.test.ts
index a03be77..7f0346e 100644
--- a/test/integration/codex-leg.test.ts
+++ b/test/integration/codex-leg.test.ts
@@ -914,4 +914,44 @@ describe("codex leg", () => {
assert.equal(sent["model"], "gpt-9-sol", "config override target must be sent upstream");
assert.equal(anthropic.requests.length, 0, "config override must route to Codex, not Anthropic");
});
+
+ // ---------------------------------------------------------------------------
+ // L3: x-subswitch-synthesized marker on codex-leg responses
+ // ---------------------------------------------------------------------------
+ //
+ // Every byte the codex leg returns is synthesized by the relay (Codex SSE is
+ // translated to Anthropic format; nothing is forwarded verbatim). Both the
+ // streaming path (res.writeHead(200)) and the non-streaming path (respondJson)
+ // must carry x-subswitch-synthesized: 1.
+
+ it("L3: codex-leg streaming response carries x-subswitch-synthesized: 1", async () => {
+ const rig = await setupRig(sseHandler(loadSse("text-only.sse")));
+ const response = await postMessages(rig.subswitch, loadRequest("simple-text.json"));
+ assert.equal(response.status, 200);
+ // Without fix: null (SSE writeHead had no marker).
+ // With fix: "1" (added to the SSE writeHead in codex-handler.ts).
+ assert.equal(
+ response.headers.get("x-subswitch-synthesized"),
+ "1",
+ "codex-leg streaming response must carry x-subswitch-synthesized: 1",
+ );
+ await response.text(); // drain
+ });
+
+ it("L3: codex-leg non-streaming response carries x-subswitch-synthesized: 1", async () => {
+ const rig = await setupRig(sseHandler(loadSse("text-only.sse")));
+ const response = await postMessages(
+ rig.subswitch,
+ JSON.stringify({ model: "gpt-5.5", max_tokens: 64, messages: [{ role: "user", content: "hi" }] }),
+ );
+ assert.equal(response.status, 200);
+ // Without fix: null (respondJson did not include the marker).
+ // With fix: "1" (added to respondJson default headers in provider-transport.ts).
+ assert.equal(
+ response.headers.get("x-subswitch-synthesized"),
+ "1",
+ "codex-leg non-streaming response must carry x-subswitch-synthesized: 1",
+ );
+ await response.json(); // drain
+ });
});
diff --git a/test/integration/passthrough.test.ts b/test/integration/passthrough.test.ts
index 499650a..19ced38 100644
--- a/test/integration/passthrough.test.ts
+++ b/test/integration/passthrough.test.ts
@@ -1,5 +1,9 @@
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
+import http from "node:http";
+import type { AddressInfo } from "node:net";
+import type { LogLevel } from "../../src/logger.js";
+import { createAnthropicForwarder } from "../../src/anthropic-passthrough.js";
import { startSubswitch, startFakeUpstream, rawHttpRequest, type SubswitchInstance, type FakeUpstream } from "./fake-upstreams.js";
const cleanups: (() => Promise)[] = [];
@@ -335,4 +339,483 @@ describe("anthropic passthrough", () => {
"response headers must reach client in original upstream order with original casing",
);
});
+
+ // ---------------------------------------------------------------------------
+ // Timeout semantics — regression tests for issue #27
+ // ---------------------------------------------------------------------------
+ //
+ // connectTimeoutMs must bound only TCP connection establishment. Once
+ // connected (or when a pooled socket is reused), the timer is re-armed to
+ // streamIdleTimeoutMs so that upstream think-time beyond connectTimeoutMs
+ // is not spuriously cut off with a 504.
+
+ it("does not 504 when upstream think-time exceeds connectTimeoutMs but is under headerTimeoutMs", async () => {
+ // Upstream delays its response by 200 ms — intentionally longer than
+ // connectTimeoutMs (50 ms) but shorter than headerTimeoutMs (500 ms).
+ // Before the fix the 50 ms timer fired immediately after connect and
+ // produced a 504; after the fix the timer is re-armed to headerTimeoutMs
+ // (500 ms) on connect, so the 200 ms delay is within budget.
+ const anthropic = await startFakeUpstream((_req, res) => {
+ setTimeout(() => {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ id: "msg_think_time" }));
+ }, 200);
+ });
+ const subswitch = await startSubswitch({
+ anthropic: {
+ baseUrl: anthropic.url,
+ connectTimeoutMs: 50,
+ headerTimeoutMs: 500,
+ streamIdleTimeoutMs: 500,
+ },
+ });
+ cleanups.push(subswitch.close, anthropic.close);
+
+ const response = await fetch(`${subswitch.url}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ });
+ assert.equal(
+ response.status,
+ 200,
+ "response must be 200 — connectTimeoutMs must not cut off upstream think-time after connect",
+ );
+ const body = (await response.json()) as { id: string };
+ assert.equal(body.id, "msg_think_time");
+ });
+
+ it("headerTimeoutMs fires and emits exactly one warn when upstream stalls before sending headers (no duplicate anthropic_upstream_error)", async () => {
+ // Upstream accepts the connection but never sends headers.
+ // After headerTimeoutMs (100 ms) the timeout handler fires, writes a
+ // 504, and calls upstream.destroy(). That destroy() makes the ClientRequest
+ // emit 'error'; the error handler must return early (via `settled`) and must
+ // NOT log a second anthropic_upstream_error warn.
+ const captured: Array<{ level: LogLevel; event: string }> = [];
+ const anthropic = await startFakeUpstream((_req, _res) => {
+ // Never responds — stall indefinitely so the header timer fires.
+ });
+ const subswitch = await startSubswitch(
+ {
+ anthropic: {
+ baseUrl: anthropic.url,
+ connectTimeoutMs: 50,
+ headerTimeoutMs: 100,
+ },
+ },
+ {
+ logger: {
+ log(level, event) {
+ captured.push({ level, event });
+ },
+ },
+ },
+ );
+ cleanups.push(subswitch.close, anthropic.close);
+
+ const response = await fetch(`${subswitch.url}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ });
+ assert.equal(response.status, 504, "timed-out upstream must produce a 504");
+ await response.text(); // drain
+
+ // Allow one extra event-loop turn for any stray error events to land.
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const upstreamEvents = captured.filter((e) => e.event.startsWith("anthropic_upstream"));
+ assert.equal(upstreamEvents.length, 1, `expected exactly 1 upstream warn event, got: ${JSON.stringify(upstreamEvents)}`);
+ assert.equal(upstreamEvents[0]!.event, "anthropic_upstream_timeout", "the single warn must be anthropic_upstream_timeout");
+ assert.equal(upstreamEvents[0]!.level, "warn");
+ });
+
+ it("re-arms headerTimeoutMs immediately on a pooled (keep-alive) socket", async () => {
+ // The pooled socket path takes the `else rearm()` branch because
+ // socket.connecting is false on a reused socket — no 'connect' event fires.
+ // Verify that a second request whose upstream think-time exceeds
+ // connectTimeoutMs but is under headerTimeoutMs still succeeds.
+ let requestIndex = 0;
+ const anthropic = await startFakeUpstream((_req, res) => {
+ const idx = requestIndex++;
+ if (idx === 0) {
+ // First request: respond immediately to establish the pooled socket.
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ id: "first" }));
+ } else {
+ // Second request: delay 200 ms — longer than connectTimeoutMs (50 ms),
+ // shorter than headerTimeoutMs (500 ms). On a reused socket the
+ // `else rearm()` branch must arm the 500 ms headerTimeoutMs budget immediately.
+ setTimeout(() => {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ id: "pooled" }));
+ }, 200);
+ }
+ });
+ const subswitch = await startSubswitch({
+ anthropic: {
+ baseUrl: anthropic.url,
+ connectTimeoutMs: 50,
+ headerTimeoutMs: 500,
+ streamIdleTimeoutMs: 500,
+ },
+ });
+ cleanups.push(subswitch.close, anthropic.close);
+
+ const postOpts = {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ } as const;
+
+ // First request: builds the pooled connection.
+ const r1 = await fetch(`${subswitch.url}/v1/messages`, postOpts);
+ assert.equal(r1.status, 200);
+ await r1.text();
+
+ // Second request: reuses the pooled socket (else rearm() branch).
+ const r2 = await fetch(`${subswitch.url}/v1/messages`, postOpts);
+ assert.equal(
+ r2.status,
+ 200,
+ "pooled socket: headerTimeoutMs must be armed immediately via else rearm(), not cut off at connectTimeoutMs",
+ );
+ const body2 = (await r2.json()) as { id: string };
+ assert.equal(body2.id, "pooled");
+
+ // Both requests must have used one TCP connection (keep-alive reuse).
+ assert.equal(anthropic.connectionCount, 1, "keep-alive: both requests must share one TCP connection");
+ });
+
+ it("streamIdleTimeoutMs fires when the stream goes idle after headers are received", async () => {
+ // headerTimeoutMs (5 s) cannot be the knob that fires here — only streamIdleTimeoutMs
+ // (100 ms) can. We send 6 chunks ~50 ms apart (~300 ms of active streaming) before
+ // stalling. If chunks did NOT reset the idle timer, the 100 ms budget would fire
+ // during the active-streaming window and the body would be truncated before we receive
+ // all 6 chunks — the assertion on chunk count would then catch it. This pins the
+ // "reset by every received chunk" invariant as a falsifiable property, not mere prose.
+ const captured: Array<{ level: LogLevel; event: string }> = [];
+ const anthropic = await startFakeUpstream((_req, res) => {
+ res.writeHead(200, { "content-type": "text/event-stream" });
+ let sent = 0;
+ const interval = setInterval(() => {
+ sent++;
+ res.write(`event: ping\ndata: ${sent}\n\n`);
+ if (sent >= 6) {
+ // Stop sending — stream goes idle; res.end() is never called.
+ clearInterval(interval);
+ }
+ }, 50);
+ });
+ const subswitch = await startSubswitch(
+ {
+ anthropic: {
+ baseUrl: anthropic.url,
+ connectTimeoutMs: 50,
+ headerTimeoutMs: 5_000,
+ streamIdleTimeoutMs: 100,
+ },
+ },
+ {
+ logger: {
+ log(level, event) {
+ captured.push({ level, event });
+ },
+ },
+ },
+ );
+ cleanups.push(subswitch.close, anthropic.close);
+
+ const response = await fetch(`${subswitch.url}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ });
+
+ // The upstream sent headers before stalling — client must see 200.
+ assert.equal(response.status, 200, "upstream sent headers before going idle");
+
+ // Collect the body; the server calls res.destroy() once streamIdleTimeoutMs fires,
+ // so the body is truncated mid-stream. Node fetch (undici) rejects the read with
+ // a network error on a destroyed socket.
+ //
+ // Timing bound: after the last chunk (~300 ms of streaming), the 100 ms idle
+ // timer fires, so the body read must complete (by throwing) within 2 s. If
+ // streamIdleTimeoutMs were huge (e.g. 10 s), the read would hang for 10 s and
+ // this assertion would fail — making the test non-vacuous.
+ const bodyReadStart = Date.now();
+ let receivedBody = "";
+ let bodyThrew = false;
+ try {
+ receivedBody = await response.text();
+ } catch {
+ bodyThrew = true;
+ }
+ const bodyElapsedMs = Date.now() - bodyReadStart;
+
+ // The body must be incomplete: either the read threw (connection reset) or the
+ // received text contains the streamed chunks but no clean termination.
+ assert.ok(
+ bodyThrew || receivedBody.includes("event: ping"),
+ "body must be truncated (threw) or contain the partial SSE chunks",
+ );
+ assert.ok(bodyThrew, "reading the body must throw — res.destroy() tears down the socket mid-stream");
+ assert.ok(
+ bodyElapsedMs < 2_000,
+ `body read must complete within 2 s (elapsed: ${bodyElapsedMs} ms) — streamIdleTimeoutMs must fire promptly, not after a huge delay`,
+ );
+
+ // Allow one event-loop turn for any stray events to settle.
+ await new Promise((resolve) => setTimeout(resolve, 150));
+
+ // Exactly one streamIdleTimeoutMs warn must be emitted. headerTimeoutMs (5 s)
+ // cannot have fired — the total test duration is well under 5 s.
+ const timeoutEvents = captured.filter((e) => e.event === "anthropic_upstream_timeout");
+ assert.equal(
+ timeoutEvents.length,
+ 1,
+ `expected exactly 1 anthropic_upstream_timeout event, got: ${JSON.stringify(timeoutEvents)}`,
+ );
+ assert.equal(timeoutEvents[0]!.level, "warn");
+ });
+
+ // ---------------------------------------------------------------------------
+ // connectTimeoutMs non-vacuity — proves the timer fires during TCP connect
+ // ---------------------------------------------------------------------------
+ //
+ // This test requires a host/port that accepts SYN packets but never sends
+ // SYN-ACK, keeping socket.connecting === true for the duration of the budget.
+ // 192.0.2.1 (TEST-NET-1, RFC 5737) is used: it is documentation-only, has no
+ // real host, and on systems with a default route the SYN is forwarded but
+ // never answered — a true blackhole.
+ //
+ // CI note: on isolated containers with NO default route the kernel returns
+ // ENETUNREACH immediately, producing a 502 error rather than a 504 timeout.
+ // If this test fails with 502 on CI, the host has no route to 192.0.2.1 and
+ // the blackhole approach is not viable there.
+ //
+ // Non-vacuity: without the fix (upstream.setTimeout instead of socket.setTimeout),
+ // this test hangs until the macOS/Linux kernel TCP timeout (~75 s) or the
+ // 30 s --test-timeout limit, confirming the test discriminates the two states.
+
+ it("connectTimeoutMs fires during TCP connect to a non-routable upstream (192.0.2.1)", async () => {
+ const CONNECT_MS = 250;
+ const captured: Array<{ level: LogLevel; event: string }> = [];
+
+ // Call createAnthropicForwarder directly so we can use plain HTTP without
+ // the config-layer HTTPS-or-loopback validation. The agent test seam is
+ // not needed here — the real socket must connect (and fail) so socket.connecting
+ // is genuinely true during the budget window.
+ const forwarder = createAnthropicForwarder({
+ baseUrl: "http://192.0.2.1:80",
+ connectTimeoutMs: CONNECT_MS,
+ headerTimeoutMs: 30_000,
+ streamIdleTimeoutMs: 30_000,
+ maxUpstreamSockets: 1,
+ logger: {
+ log(level: LogLevel, event: string) {
+ captured.push({ level, event });
+ },
+ },
+ });
+
+ const server = http.createServer((req, res) => forwarder(req, res));
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const { port } = server.address() as AddressInfo;
+
+ const start = Date.now();
+ const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "test" }),
+ });
+ const elapsed = Date.now() - start;
+
+ await new Promise((resolve) => server.close(() => resolve()));
+
+ assert.equal(
+ response.status,
+ 504,
+ `expected 504 (connect timeout after ${CONNECT_MS} ms); got ${response.status} after ${elapsed} ms. ` +
+ `If 502: 192.0.2.1 returned ENETUNREACH (no default route — host has no path to TEST-NET-1). ` +
+ `If 504 but elapsed >> ${CONNECT_MS}: connectTimeoutMs did not actually bound TCP connect.`,
+ );
+
+ // Without the fix this hangs ~75 000 ms (macOS kernel TCP timeout) or until
+ // the 30 s test timeout — well above the 4× upper bound below.
+ assert.ok(
+ elapsed < CONNECT_MS * 4,
+ `connect should time out at ~${CONNECT_MS} ms; elapsed ${elapsed} ms is too high — ` +
+ `connectTimeoutMs is not bounding TCP connect (unfixed code hangs ~75 000 ms or test-timeout)`,
+ );
+ assert.ok(
+ elapsed >= CONNECT_MS * 0.5,
+ `timer fired before the connectTimeoutMs budget (elapsed: ${elapsed} ms < ${CONNECT_MS * 0.5} ms)`,
+ );
+
+ // The settled de-dup guard must hold on the connect-timeout path too:
+ // exactly one anthropic_upstream_timeout warn, no anthropic_upstream_error.
+ const upstreamEvents = captured.filter((e) => e.event.startsWith("anthropic_upstream"));
+ assert.equal(
+ upstreamEvents.length,
+ 1,
+ `expected exactly 1 upstream warn event; got: ${JSON.stringify(upstreamEvents)}`,
+ );
+ assert.equal(upstreamEvents[0]!.event, "anthropic_upstream_timeout");
+ assert.equal(upstreamEvents[0]!.level, "warn");
+ });
+
+ // ---------------------------------------------------------------------------
+ // L3: x-subswitch-synthesized marker header
+ // ---------------------------------------------------------------------------
+ //
+ // Every response the relay generates itself — 502 (connection failure),
+ // 504 (timeout), 500 (internal proxy error), 413 (body too large), 503
+ // (concurrency gate) — carries x-subswitch-synthesized: 1 so operators can
+ // distinguish relay faults from upstream faults. Responses proxied from the
+ // origin (including upstream errors such as 429) must NOT carry this header.
+ // Additionally, x-subswitch-synthesized is stripped from proxied responses
+ // so that an origin setting it cannot impersonate the relay's marker.
+
+ it("L3: x-subswitch-synthesized: 1 is present on a relay-synthesised 504 timeout response", async () => {
+ const anthropic = await startFakeUpstream((_req, _res) => {
+ // Never responds — triggers headerTimeoutMs.
+ });
+ const subswitch = await startSubswitch({
+ anthropic: { baseUrl: anthropic.url, headerTimeoutMs: 80 },
+ });
+ cleanups.push(subswitch.close, anthropic.close);
+
+ const response = await fetch(`${subswitch.url}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ });
+
+ // Without fix: header absent. With fix: "1".
+ assert.equal(response.status, 504);
+ assert.equal(
+ response.headers.get("x-subswitch-synthesized"),
+ "1",
+ "relay-synthesised 504 must carry x-subswitch-synthesized: 1",
+ );
+ });
+
+ it("L3: x-subswitch-synthesized is ABSENT on a successfully proxied origin response", async () => {
+ const { subswitch } = await setup((_req, res) => {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ id: "ok" }));
+ });
+
+ const response = await fetch(`${subswitch.url}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ });
+
+ assert.equal(response.status, 200);
+ // Must be absent — the origin produced this response, not the relay.
+ assert.equal(
+ response.headers.get("x-subswitch-synthesized"),
+ null,
+ "proxied origin 200 must NOT carry x-subswitch-synthesized",
+ );
+ });
+
+ it("L3: x-subswitch-synthesized is ABSENT on a proxied origin ERROR response such as 429", async () => {
+ // A 429 that the upstream produced must reach the client unchanged —
+ // the relay must not inject x-subswitch-synthesized on responses it merely forwarded.
+ const { subswitch } = await setup((_req, res) => {
+ res.writeHead(429, { "content-type": "application/json", "retry-after": "30" });
+ res.end(JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: "too many requests" } }));
+ });
+
+ const response = await fetch(`${subswitch.url}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ });
+
+ assert.equal(response.status, 429);
+ // Without the marker: null. The relay must only mark what IT produces.
+ assert.equal(
+ response.headers.get("x-subswitch-synthesized"),
+ null,
+ "proxied upstream 429 error must NOT carry x-subswitch-synthesized",
+ );
+ });
+
+ it("L3: x-subswitch-synthesized set by the upstream is STRIPPED from the proxied response", async () => {
+ // An upstream that sets x-subswitch-synthesized must not have it forwarded
+ // to the client — the relay strips it so the marker is authoritative (only
+ // the relay can assert it).
+ const { subswitch } = await setup((_req, res) => {
+ res.writeHead(200, { "content-type": "application/json", "x-subswitch-synthesized": "upstream-injected" });
+ res.end(JSON.stringify({ id: "ok" }));
+ });
+
+ const response = await fetch(`${subswitch.url}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ });
+
+ assert.equal(response.status, 200);
+ // Without fix: "upstream-injected" (forwarded verbatim).
+ // With fix: null (stripped by filterRawHeaders adding it to HOP_BY_HOP).
+ assert.equal(
+ response.headers.get("x-subswitch-synthesized"),
+ null,
+ "upstream-set x-subswitch-synthesized must be stripped from the proxied response",
+ );
+ });
+
+ // ---------------------------------------------------------------------------
+ // Change 3: client abort must not produce anthropic_upstream_error warn
+ // ---------------------------------------------------------------------------
+
+ it("Change 3: aborting the client mid-request produces no anthropic_upstream_error warn", async () => {
+ // Upstream never responds (stalls); client aborts after 60 ms.
+ // res.on("close") fires → settled=true → upstream.destroy() → error fires
+ // → settled guard prevents spurious warn and 502 attempt.
+ const captured: Array<{ level: string; event: string }> = [];
+
+ const anthropic = await startFakeUpstream((_req, _res) => {
+ // Stall — never send a response.
+ });
+ const subswitch = await startSubswitch(
+ { anthropic: { baseUrl: anthropic.url, headerTimeoutMs: 10_000 } },
+ { logger: { log(level, event) { captured.push({ level, event }); } } },
+ );
+ cleanups.push(subswitch.close, anthropic.close);
+
+ const controller = new AbortController();
+ const fetchPromise = fetch(`${subswitch.url}/v1/messages`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [] }),
+ signal: controller.signal,
+ });
+
+ // Abort before upstream has a chance to respond.
+ setTimeout(() => controller.abort(), 60);
+
+ try {
+ await fetchPromise;
+ } catch {
+ // Expected: AbortError from the client-side abort.
+ }
+
+ // Allow the event loop to drain so any stray error events settle.
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const warnLogs = captured.filter((e) => e.event === "anthropic_upstream_error");
+ // Without fix: settled=false when res closes → upstream.destroy() → error fires → warn logged.
+ // With fix: settled=true before destroy() → error fires → settled guard returns early → no warn.
+ assert.equal(
+ warnLogs.length,
+ 0,
+ `client abort must not produce anthropic_upstream_error warn; got: ${JSON.stringify(warnLogs)}`,
+ );
+ });
});
diff --git a/test/integration/server-wiring.test.ts b/test/integration/server-wiring.test.ts
index 1e2abb7..b7c4efa 100644
--- a/test/integration/server-wiring.test.ts
+++ b/test/integration/server-wiring.test.ts
@@ -53,6 +53,7 @@ const makeMinimalConfig = (overrides: {
anthropic: {
baseUrl: anthropicBaseUrl,
connectTimeoutMs: 10_000,
+ headerTimeoutMs: 600_000,
streamIdleTimeoutMs: 300_000,
maxUpstreamSockets: 32,
allowInsecureBaseUrl: anthropicAllowInsecureBaseUrl,
diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts
index 3d3da95..0e8152a 100644
--- a/test/unit/config.test.ts
+++ b/test/unit/config.test.ts
@@ -27,6 +27,7 @@ describe("loadConfig", () => {
assert.equal(result.value.config.logLevel, "info");
assert.equal(result.value.config.anthropic.baseUrl, "https://api.anthropic.com");
assert.equal(result.value.config.anthropic.connectTimeoutMs, 10_000);
+ assert.equal(result.value.config.anthropic.headerTimeoutMs, 660_000);
assert.equal(result.value.config.anthropic.streamIdleTimeoutMs, 300_000);
assert.equal(result.value.config.anthropic.maxUpstreamSockets, 32);
assert.equal(result.value.config.providers.codex.baseUrl, "https://chatgpt.com/backend-api/codex");
diff --git a/test/unit/doctor.test.ts b/test/unit/doctor.test.ts
index 6cf6938..d829605 100644
--- a/test/unit/doctor.test.ts
+++ b/test/unit/doctor.test.ts
@@ -132,6 +132,7 @@ const makeTestConfig = (): Config => ({
anthropic: {
baseUrl: "https://api.anthropic.com",
connectTimeoutMs: 10_000,
+ headerTimeoutMs: 600_000,
streamIdleTimeoutMs: 300_000,
maxUpstreamSockets: 32,
allowInsecureBaseUrl: false,