Skip to content

fix(gateway): stop applying the total timeout to streamed responses - #210

Merged
argszero merged 1 commit into
mainfrom
fix/stream-total-timeout
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/stream-total-timeout

Conversation

@argszero

Copy link
Copy Markdown
Owner

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 saw error 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:

PROBE headers status=200 OK at 2.409375ms
PROBE chunks=3 last_chunk_at=90.011245167s saw_done=false err="error decoding response body" total=120.004955084s

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, and upstream_stream_client(timeout) uses connect_timeout(timeout).read_timeout(timeout) with no total deadline (progress is never punished; 120 s of silence still is). AppState gains http_stream alongside http.
  • src/gateway.rs: forward_stream's two outbound call sites now use st.http_stream. forward (non-stream) is unchanged.
  • No configuration or data-structure change, so config/config.example.toml is untouched.

Tests

  • cargo test — 207 passed (205 before; +2 new tests)
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • New unit tests added

The 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 reads st.http_stream: had it still read st.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):

mutation expected red observed
forward_stream back on st.http (pre-fix behaviour) truncation test passed=1 failed=1 red=[sse_slow_stream_is_cut_by_a_total_deadline]
stream factory keeps a total deadline survival test passed=1 failed=1 red=[sse_slow_stream_survives_a_read_timeout]

Checklist

  • Branch name follows the convention (fix/stream-total-timeout)
  • Commit message uses Conventional Commits
  • Single responsibility, minimal change (2 production lines + tests)

Notes / scope

  • Not a money change: billing reads the upstream usage, and the non-stream path is untouched. The truncated call is not billed either — fwd.chain(finalize) never polls finalize after a body error. That is the same shape as the existing sse_client_disconnect_skips_settle test (settling without upstream usage is deliberately not implemented, and this PR does not change that).
  • Still-open question left alone on purpose: the non-stream 120 s total deadline stays as is — it bounds a hung upstream.

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`.
@argszero
argszero merged commit acc6f49 into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/stream-total-timeout branch September 13, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant