Repository navigation
gateway: a whole reply behind a stream's keepalive says the vendor di… - #1077
Open
TryWorld2026 wants to merge 1 commit into
Open
TryWorld2026 wants to merge 1 commit into
TryWorld2026 wants to merge 1 commit into
Conversation
TryWorld2026
force-pushed
the
fix/keepalive-then-whole-json-reply
branch
from
October 7, 2026 00:59
63dccd8 to
44fba43
Compare
…d not stream An agent that asked for a stream and heard nothing from its vendor for longer than keepHeldAfter is sent the stream's 200 and SSE comments while the vendor has yet to answer (yetone#947), which also promises it a stream. A vendor that ignores stream and answers with a whole application/json 200 had that reply held for a stream that never was, and release told it as the stream's error with http.StatusText(200) as the fallback: the agent read "200 OK: {…its answer…}" as an error, and the ledger booked a success, so the conversation stuck to an account that never answered. release's own comment calls this path "an error status" — it is guarded by h.alive.sent && !h.stream, which is about what went out, not about the status, so a 2xx reached it too. Say what the vendor did instead: not stream one, the words a request nothing went to ahead of it is answered 502 for, and which provider.APIError already puts the vendor's own reason in front of. An error status keeps its own text, so a 429 or a 503 held this way is told exactly as before. keepQuiet is left as it is: the yetone#947 keepalive is what tells the agent the reply is a stream, and taking it away to dodge this would leave a slow vendor's agent with nothing at all for as long as it is quiet — and an agent that asked for no stream gets no keepalive anyway (watch only runs for one that did), so its whole reply always went past as it came. Verified: TestQuietThenWholeJSONSaysTheVendorDidNotStream fails without the change with the answer as the message of an error in an announced 200, and passes with it. TestQuietThenWholeJSONNoStream and TestQuietThenWholeJSONFromResponsesOnly are the other half of the same vendor (an agent that asked for no stream, and a provider with no Chat endpoint). TestKeepAliveBeforeTheVendorsHeaders, the yetone#947 check, passes unchanged both before and after — it is upstream's, and an earlier draft of this change broke it, which is why the keepalive is left alone. TestQuietKeptAliveOnlySoLong, TestSilentHeldStreamKeptAlive, TestQuietStreamMidReplyKeptAlive, TestSilentHeldStreamStillFailsOver, TestQuietStreamsKeptAliveEveryProtocol and TestTranslatedStreamKeepsClientAlive all pass. go vet -tags nogui ./internal/gateway and the windows, darwin and linux builds pass; gofmt is clean on LF-normalized copies, the worktree being CRLF.
TryWorld2026
force-pushed
the
fix/keepalive-then-whole-json-reply
branch
from
October 7, 2026 13:29
44fba43 to
66538bc
Compare
This branch has not been deployed
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.
gateway: a whole reply behind a stream's keepalive says the vendor did not stream
What is wrong
An agent that asked for a stream and heard nothing from its vendor for longer
than
keepHeldAfter(15s) is sent the stream's 200 and SSE comments while thevendor has yet to answer (#947). Those comments promise the agent a stream. When
the vendor ignores
streamand answers with a wholeapplication/json200instead, that reply is held for a stream that never was, and
releasetold it asthe stream's error with
http.StatusText(200)as the fallback:data: {"error":{"message":"OK: {\"id\":\"chatcmpl-1\", …the whole answer…}","type":"api_error"}}So the agent is handed a 200 in an error whose message opens with
OK:andthen carries its own answer. It is not an error, it says it is one, and it says
it succeeded.
What changed
release's own comment calls that path "an error status", but its guard ish.alive.sent && !h.stream— about what went out, not about the status — so a2xx reached it too. It now says what the vendor did instead:
is answered 502 for, with
provider.APIErrorputting the vendor's own reason infront of it and the body behind, as it already does everywhere else;
exactly as before.
keepQuietis not touched. An earlier draft of this change narrowed it toh.streamso that nothing was said ahead of a reply that might be whole; thatfixed the wording and broke
TestKeepAliveBeforeTheVendorsHeaders(3/3, everyrun burning its 5s deadline), because it took the #947 keepalive away from a slow
vendor's agent and left it with nothing at all for as long as the vendor was
quiet. An agent that asked for no stream gets no keepalive anyway —
watchrunsonly for one that did — so its whole reply never had this problem.
Semantic change (Gateway routing and fallback)
application/json200 arriving behind the stream's keepalivewas told as that stream's error with
OK:in front of it.vendor answering a request nothing went to ahead of it is given, with its own
words in front.
keepaliveLongeststop; how anon-streaming request is answered; everything about an error status.
usage ledger books the 200 as it did. Giving the agent the answer as a
completion would mean encoding a whole reply as one event of the announced
stream, which is a design decision — see the note at the end.
docs/subsystems/gateway-routing.md(updated in this PR — one sentence at the end of the keepalive bullet);
implementation
releaseininternal/gateway/fallback.go.Verification
All on the Windows box.
TestQuietThenWholeJSONSaysTheVendorDidNotStreamfailswith the
200 OK: {…}event above; with it, it passes. The test guards bothhalves: it requires
did not streamin the answer and refusesOK:in it.TestQuietThenWholeJSONNoStreamandTestQuietThenWholeJSONFromResponsesOnlyare the other half of the same vendor: an agent that asked for no stream, and a
provider with no Chat endpoint at all. Both pass before and after — they pin
the paths around the changed one, and only the first test is the reproducer.
TestKeepAliveBeforeTheVendorsHeaders— upstream's [Bug] 上游 SSE 响应被缓冲且静默保活不独立运行,Cloudflare 后的 Remote Magpie 可在 125 秒超时 #947 check, untouched bythis PR — passes before and after. It is what the earlier draft of the fix
broke.
TestQuietKeptAliveOnlySoLong,TestSilentHeldStreamKeptAlive,TestQuietStreamMidReplyKeptAlive,TestSilentHeldStreamStillFailsOver,TestQuietStreamsKeptAliveEveryProtocol,TestTranslatedStreamKeepsClientAlive.go vet -tags nogui ./internal/gatewayand the windows, darwin and linuxbuilds pass; gofmt is clean on LF-normalized copies, the worktree being CRLF
under
core.autocrlf.One failure in the full
internal/gatewayrun is this box's own and reproducesidentically on the base commit:
TestSweepBridgeProjectsTakesOnlyTheBridgesFoldersneeds a symlink privilege Windows does not grant here. It was the only
--- FAILin the run.
TestPluginStreamStalledClientKeepsOtherRequestsMovingis a separatetiming flake on this box — it failed once in an earlier run of the same package
and passed this time, including under
-count=3, and it is unrelated to thischange.
The bigger question, if you want it
The keepalive and a whole reply cannot both be delivered as they stand: the
comments have already committed the response to
text/event-stream, so a wholebody that arrives afterwards can only be an event. This PR makes that event
honest. Making it carry the answer — start it, one content event, stop it —
would give the agent the reply instead of a reason, cost a new encoder path per
protocol, and is a behaviour the maintainer should pick rather than one this PR
should assume.