Repository navigation
fix(gateway): stop applying the total timeout to streamed responses - #210
Merged
Merged
Conversation
The outbound reqwest client was built with `.timeout(120s)` and the streaming forward used that same client. `.timeout()` is a **total** deadline for the whole request including the response body, so a stream that keeps producing data was cut off at 120 s wall-clock. Measured with a fake upstream dripping one frame every 30 s (six frames, ~180 s planned): only 3 chunks arrived, the last at 90.0 s, and the call ended at 120.005 s with `error decoding response body` - `[DONE]` never arrived. Long answers and reasoning models routinely exceed 120 s, so this truncated normal traffic. `read_timeout()` (per read, reset after every successful read) - the semantics streaming actually wants - was never set anywhere in the tree, so the streaming path had stall detection *and* a total cap where it should have only the former. Split the single policy into two explicit ones: - non-stream `upstream_client` keeps `.timeout(120s)` (a hung upstream stays bounded); - stream `upstream_stream_client` uses `.connect_timeout(120s).read_timeout(120s)` with no total deadline - progress is never punished, 120 s of silence still is. `AppState` now carries `http` (non-stream) and `http_stream` (streaming); `forward` is unchanged and `forward_stream` uses `http_stream` at both of its call sites. Tests: a fake upstream drips 15 SSE frames 100 ms apart (~1.5 s), then a usage frame and `[DONE]`, driven through the real router. With the production stream factory at a 200 ms limit the stream must arrive complete and settle once; with the non-stream factory (a total deadline, i.e. the pre-fix behaviour) it must be truncated and must not settle. The pair pins the single discriminating property, and the second arm also proves the gateway really reads `http_stream`.
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
The gateway's outbound HTTP client was built with
.timeout(120s), and the streaming forward used that same client..timeout()in reqwest is a total request deadline — it wraps the response body too — so an SSE stream that keeps producing data was killed at 120 s wall-clock and the client sawerror decoding response body(a timeout reported as a decode failure).Reproduced with a fake upstream dripping one SSE frame every 30 s (6 frames, ~180 s planned), driven through the real router and the real
AppState::new:Only 3 of 6 frames arrived,
[DONE]never did, and the call ended at exactly 120.005 s. Long answers and reasoning models routinely exceed 120 s, so this truncated normal traffic.read_timeout()— the per-read deadline that resets after every successful read, i.e. the semantics streaming actually wants — was never set anywhere in the tree, so the streaming path had stall detection and a total cap where it should have had only the former.Related Issue
None — no issue exists for this. Reported as a runtime reproduction in the PR body instead.
Changes
src/routes/mod.rs: one policy number (UPSTREAM_TIMEOUT, the 120 s the code already used) plus two explicit constructors —upstream_client(timeout)keeps the total deadline for non-stream responses, andupstream_stream_client(timeout)usesconnect_timeout(timeout).read_timeout(timeout)with no total deadline (progress is never punished; 120 s of silence still is).AppStategainshttp_streamalongsidehttp.src/gateway.rs:forward_stream's two outbound call sites now usest.http_stream.forward(non-stream) is unchanged.config/config.example.tomlis untouched.Tests
cargo test— 207 passed (205 before; +2 new tests)cargo fmt --check— cleancargo clippy --all-targets -- -D warnings— cleanThe pair drives a fake upstream that drips 15 SSE frames 100 ms apart (~1.5 s), then a usage frame and
[DONE], through the real router:sse_slow_stream_survives_a_read_timeout— with the production stream factory at a 200 ms limit (≪ the 1.5 s stream) the stream must arrive complete (all 15 frames +[DONE]) and settle once;sse_slow_stream_is_cut_by_a_total_deadline— with the non-stream factory (a total deadline at 200 ms, i.e. the pre-fix behaviour) the stream must be truncated and must not settle. This arm also proves the gateway really readsst.http_stream: had it still readst.http, nothing would truncate.Both arms use the production constructors, so the real construction is exercised — only 200 ms instead of 120 s.
A/B with teeth (each mutation injected alone, both restored byte-identically):
forward_streamback onst.http(pre-fix behaviour)passed=1 failed=1 red=[sse_slow_stream_is_cut_by_a_total_deadline]passed=1 failed=1 red=[sse_slow_stream_survives_a_read_timeout]Checklist
fix/stream-total-timeout)Notes / scope
fwd.chain(finalize)never pollsfinalizeafter a body error. That is the same shape as the existingsse_client_disconnect_skips_settletest (settling without upstream usage is deliberately not implemented, and this PR does not change that).