fix(gateway): budget time-to-first-byte, not the whole response (OX-H2) - #40
Merged
Conversation
Audit OX-H2, DECISIONS §66. forwardUpstreamRequest armed AbortSignal.timeout(30000) and handed it to fetch. That is the whole defect: a fetch signal does not stop applying when the promise resolves -- it governs the response body stream too. So for `"stream": true` payloads the body reader rejected ~30s in and the pump called res.destroy(), truncating the answer mid-generation. LLM completions routinely run past 30s, long-form Anthropic streams especially, so this broke exactly the traffic the Gateway intercepts by default. From the client's side it is indistinguishable from the model stopping. An owned AbortController replaces AbortSignal.timeout, cleared in a `finally` on the header phase. AbortSignal.timeout cannot be used because it cannot be un-fired; only a controller you own can be left permanently unaborted, which is what makes the budget TTFB. Once fetch settles -- resolved, timed out or failed -- nothing can fire it again. The 504 mapping survives by aborting with a TimeoutError DOMException, which is the reason AbortSignal.timeout produced. The caller-disconnect signal stays combined via AbortSignal.any, so a client hanging up still aborts the upstream. That is the half a careless fix removes, and it is asserted and mutation-checked: replacing the combined signal with the ttfb controller alone fails that test and only that test. That test needed a second pass to be worth anything. The first version read the upstream's flag AFTER the helper stopped the server -- which closes the upstream socket anyway -- so it would have passed whether or not the hangup propagated. The value is now sampled inside the gateway's lifetime. upstreamTtfbTimeoutMs (GatewayConfig / ProxyHandlerOptions, default 30000) is configurable, as the audit suggested. It is also what makes the defect testable: catching a 30-second bug required a 30-second upstream, which is why no test caught it. The suite drives a real socket in about a second -- mockUpstream short-circuits before fetch and never exercises the signal. Measured, real upstream: 120ms budget with a ~300ms body was truncated before and delivers all 5 chunks plus [DONE] after; a 40ms budget against a ~400ms body likewise, all 8 chunks. Slow headers still return 504. Deliberately no bound on body duration now. If a stalled-mid-stream upstream ever needs one, that wants an idle timer on the pump reset per chunk, not a total-duration budget -- a different change. Verified: typecheck, lint and build clean, 89 files / 817 tests. Both truncation cases confirmed failing against the unfixed tree first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit OX-H2. DECISIONS §66. Second Lane B item.
The defect is one property of
fetchforwardUpstreamRequestarmedAbortSignal.timeout(30000)and handed it tofetch. A fetchsignal does not stop applying when the promise resolves — it governs the response body stream too.
So for
"stream": truepayloads the body reader rejected roughly 30 seconds in, and the pump inserver.tscalledres.destroy(...), truncating the answer mid-generation. LLM completionsroutinely run past 30 seconds, long-form Anthropic streams especially, so this broke precisely the
traffic the Gateway intercepts by default — and from the client's side it is indistinguishable from
the model simply stopping.
The fix, and the line that is the fix
An owned
AbortControllerreplacesAbortSignal.timeout, with the timer cleared in afinallyonthe header phase.
AbortSignal.timeoutcannot be used here because it cannot be un-fired; onlya controller you own can be left permanently unaborted. Once
fetchsettles — resolved, timed out,or failed — nothing can fire it again, and the body phase is governed solely by the
caller-disconnect signal.
The 504 mapping is preserved by aborting with
new DOMException(…, 'TimeoutError')— the catchmatches on
error.name, and that is exactly the reasonAbortSignal.timeoutproduced.The half a careless fix removes
params.options.abortSignal— the client-hangup signal fromres.on('close')— stays combined viaAbortSignal.any. Disarming the timeout must not disarm the disconnect, or a client that walksaway leaves the Gateway pulling a response nobody will read and paying the provider for it.
Asserted, and mutation-checked: replacing the combined signal with
ttfbController.signalalonefails that test and only that test.
That test also needed a second pass to be worth anything. The first version read the upstream's flag
after the helper stopped the gateway — which closes the upstream socket anyway — so it would have
passed whether or not the hangup propagated. The value is now sampled inside the gateway's lifetime.
Measured, against a real upstream over a socket
[DONE], all 5 chunksupstreamTtfbTimeoutMsNew
GatewayConfig/ProxyHandlerOptionsfield, default 30000, as the audit suggested. It is alsowhat makes the defect testable at all: catching a 30-second bug required a 30-second upstream,
which is why no test caught it. This suite runs in about a second.
The tests drive a real local upstream rather than
mockUpstream, becausemockUpstreamshort-circuits before
fetchand never exercises the signal handling the defect lives in.What this does not establish
bounding, that wants an idle timer on the pump — reset per chunk — not a total-duration budget,
and it is a different change.
not what the Gateway elides. Invariant 8 untouched.
Verification
npm run typecheck,npm run lint,npm run buildandnpx vitest runall pass: 89 files /817 tests. Both truncation cases were confirmed failing against the unfixed tree first, with the
slow-header 504 passing throughout — which is what isolates the defect from the new knob.
🤖 Generated with Claude Code