fix(proxy): stream Server-Sent Events instead of buffering them for capture#37
Merged
Merged
Conversation
abezzub-dr
added a commit
to abezzub-dr/gatekeeper
that referenced
this pull request
Jun 23, 2026
abezzub-dr
added a commit
to abezzub-dr/gatekeeper
that referenced
this pull request
Jun 23, 2026
50d2388 to
73be96e
Compare
abezzub-dr
added a commit
to abezzub-dr/gatekeeper
that referenced
this pull request
Jun 23, 2026
73be96e to
997c49a
Compare
The response-body log sampler (captureBody) did a blocking io.ReadFull of up to MaxBodySize (8 KB) inside the forward path, before the response reached the client. For any incrementally-produced text response — Server-Sent Events, application/x-ndjson, chunked JSON — this withheld the status line and every chunk (including upstream keepalive pings sent during a long time-to-first-token) until 8 KB accumulated, or until the whole stream ended for sub-8 KB responses. A streaming client received nothing, tripped its first-byte timeout, and retried in a loop. Direct (un-proxied) clients were unaffected because events flowed incrementally. Add capturingBody, which tees up to MaxBodySize into a buffer as the downstream consumer reads, so capture never blocks the forward path and covers every text content type (the same isTextContentType gate as before, now non-blocking). Both response paths use it. captureBody's blocking read is retained for request bodies, which are already fully available. On the intercept path the copy is owned by httputil.ReverseProxy, so the canonical log line for a text response is emitted from the body's Close — carrying the streamed sample and full duration. ReverseProxy closes the body unconditionally, so it fires exactly once; protocol upgrades (101) and non-text responses are logged synchronously instead. The plain-HTTP path captures during its own copy and logs synchronously after it. This does not change the llm-gateway response-policy path, which still reads the full response (io.ReadAll) to evaluate it before forwarding. Tests: capturingBody never reads ahead (regression guard using a non-SSE ndjson stream) and captures a bounded sample while forwarding the full body; intercept tests that assert on the canonical log line wait for the deferred entry.
997c49a to
b72f19f
Compare
dpup
approved these changes
Jun 29, 2026
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.
What this fixes
Streamed responses time out and retry in a loop through the proxy, while the identical request works when sent directly. It surfaces on large or slow-to-start streaming requests — an LLM
/v1/messageswith a big context, or a cache-cold resume — where the upstream takes a while to produce its first bytes. The client shows repeatedAPI error · Retrying, the turn never completes; un-proxied clients are unaffected.Cause
The proxy sampled the response body for logging with a blocking read-ahead (
io.ReadFullof up toMaxBodySize= 8 KB) before forwarding anything to the client. For an incrementally-produced response this withheld the status line and every chunk — including the keepalive pings the upstream sends during a long time-to-first-token — until 8 KB accumulated (or the whole stream ended, for sub-8 KB responses). The client received nothing, tripped its first-byte timeout, and retried. "Slow first byte" became "no bytes at all". This starved any slow text stream — Server-Sent Events,application/x-ndjson, chunked JSON — not just one media type.Fix
Add
capturingBody, which tees up toMaxBodySizeinto a buffer as the downstream consumer reads, so capture never blocks the forward path. Both response paths (TLS-intercept and plain-HTTP) use it for text responses — the sameisTextContentTypegate as before, now non-blocking. The full body streams through untouched; the logged sample is whatever flowed past.captureBody's blocking read is retained for request bodies, which are already fully available.On the intercept path the response copy is owned by
httputil.ReverseProxy, so the canonical log line for a text response is emitted from the body'sClose(carrying the streamed sample and full duration). ReverseProxy closes the body unconditionally, so it fires exactly once; protocol upgrades (101) and non-text responses are logged synchronously instead. The plain-HTTP path captures during its own copy and logs synchronously after it.Scope
This does not change the
llm-gatewayresponse-policy path: when a Keep engine is configured for a host,evaluateAndReplaceLLMResponsereads the full response (io.ReadAll, up tomaxLLMResponseSize) to evaluate it before forwarding, so a stream on that host is still buffered end-to-end. Evaluating a full-response policy inherently needs the whole body; a streaming-awarellm.EvaluateStreampath could lift this later.Tests
TestCapturingBody_NeverBlocksOnSlowStream— a body that emits one record then blocks must not trigger a read-ahead; uses a non-SSEapplication/x-ndjsonstream.TestCapturingBody_StreamsAndCaptures— the full body passes through while a bounded sample is captured;onClosefires once../proxysuite passes under-race;go vetandgofmtclean. Confirmed end-to-end against a live deployment: looping sessions recovered, and a ~1 MB-context turn completed in single-digit seconds.