Return a real HTTP status when a stream fails before its first event - #952
Conversation
If the first event from an upstream provider is an error, we currently fall through to `break None`, commit HTTP 200, open an SSE body, and deliver the failure as an in-band error frame. The client sees a successful response that contains an error - and because `[DONE]` is still appended afterwards, a client keying on `[DONE]` reads it as a successful empty completion rather than a failure. Response headers are still mutable at that point: no stream has been produced and nothing has been written. So map the error through the existing `map_provider_error` / `map_domain_error_to_status` path and return the real status instead. This costs no latency. The first event is already awaited before the response is constructed, so nothing new is blocking. Reproduced against production on 2026-08-19, streaming google/gemma-4-31B-it. A schema containing `uniqueItems` returns HTTP 200 with a single SSE event carrying `HTTP error 400: Grammar error: Unimplemented keys: ["uniqueItems"]`, followed by `[DONE]`. The same shape occurs for `contains` and `propertyNames`. A partner reported 46 of these in a six-hour window and could not distinguish them from empty successes. This fixes the whole class, not one keyword: any pre-first-event upstream error now surfaces as a real status. `map_provider_error` becomes `pub` so the api crate can reach it. The alternative was duplicating error mapping into api, which is worse. Verified: cargo build, cargo clippy -p services -p api --all-targets -D warnings, and cargo test -p api --test e2e_all --no-run all clean. The e2e tests compile but were not executed here - they require PostgreSQL, which is not available in this environment. No test-pass claim is made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fm9f3maaY2wd6yHH8FEjR5
ReviewNo prior review threads on this PR, so nothing to build on. The core change is correct and well-scoped. The I also confirmed the two existing
|
IronLoop Run · Review🟩 CompletedIronLoop completed the review and posted it to GitHub. ResultRun detailsAutomatic trigger · attempt 1 of 3 · completed in 12m 13s |
|
✅ OpenCodeReview: Review partially complete: 0 finding(s); 3 of 4 selected item(s) failed. |
There was a problem hiding this comment.
IronLoop Review
🟢 No actionable findings
No new actionable defects found in the reviewed change.
Validation
- ✅ Changed-path inspection — The new pre-SSE error branch executes before response construction, uses the established error mappings, and leaves normal stream forwarding intact.
Review details
- Run:
f174b3df-352c-43bc-8a05-c1ec5a4648e2 - Workflow: Review
- Attempts: 1
PierreLeGuen
left a comment
There was a problem hiding this comment.
Replacing the catch-all peek arm with an explicit Some(Err(error)) branch correctly returns a real HTTP status for pre-first-event upstream errors. No defects found.
Checks: cargo +1.92.0 check -p api -p services --all-targets — clean (includes the new e2e_all/first_stream_event.rs module); cargo +1.92.0 clippy -p services -p api --all-targets -- -D warnings — clean; cargo +1.92.0 fmt --all -- --check — passed
d9d6738 to
ebcf80e
Compare
|
The Chutes parser fix that briefly sat on this branch has been split out into #984, so this PR is back to exactly the change you approved: return a real HTTP status when a stream fails before its first event. Branch was force-pushed back to Note the two PRs both touch |
Review feedback: the route should not inspect stream events. It does not need
to - the pool already peeks the first event on every streaming request, to
record the chat_id for sticky routing:
let mut peekable = StreamingResultExt::peekable(stream);
...
if let Some(Ok(event)) = peekable.peek().await {
That peek predates this PR and discards the Err case, handing back a stream
whose first item is an error and forcing the caller to either inspect it or
commit 200 and bury the failure in-band. So the earlier version added a second,
duplicate peek one layer up.
The pool now returns Err from the peek it already performs, and the route's arm
is removed, restoring that loop to its shape on main. Observable behaviour is
unchanged: a stream whose first upstream event is an error still returns a real
HTTP status via the existing error path, the same one that already yields 400
for an unknown model. Net effect is one fewer inspection than before.
The orphaned-pending-client cleanup still runs on the new error path - the
`if !pinned` block executes before the early return - so an Err cannot leak a
pinned connection. The error is cloned out of the peek so its status and
is_external survive for map_provider_error, which classifies on both (a 404
from a third-party provider maps differently from a 404 from our own vLLM).
map_provider_error reverts to pub(crate); it was only made pub for the route
arm this removes, and has no callers outside the services crate.
Verified: the e2e assertions in first_stream_event.rs are unchanged - only the
layer that detects the error moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fm9f3maaY2wd6yHH8FEjR5
…us' into fix/stream-first-event-http-status
|
Reworked per feedback: the route no longer inspects stream events. Pushed as Why the route peek was removableThe pool already peeks the first event on every streaming request, to record the chat_id for sticky routing — let mut peekable = StreamingResultExt::peekable(stream);
…
if let Some(Ok(event)) = peekable.peek().await {That predates this PR, and it discarded the The pool now returns What the diff is now
Behaviour is unchangedA stream whose first upstream event is an error still returns a real HTTP status through the existing error path — the same one that already yields 400 for an unknown model. The e2e assertions were not touched; only the layer that detects the error moved. Two things I checked because they were the risky partsThe orphaned-pending-client cleanup still runs. The The error is cloned out of the peek, so Verification
On the earlier CI failureUnrelated flake. |
PierreLeGuen
left a comment
There was a problem hiding this comment.
Moving first-event error classification into the pool's existing sticky-routing peek correctly turns a pre-first-event upstream failure into a real HTTP status instead of a 200 SSE with an in-band error frame and [DONE].
Optional follow-ups:
crates/services/src/inference_provider_pool/mod.rs:3321— The new arm treats every first-itemErras fatal to the request, but the SSE parser does not treat per-event parse/conversion failures as terminal. Fix: Narrow the early return to error kinds that are terminal for the stream.
Checks: cargo +1.92.0 test -p services --lib test_first_stream_error_is_returned_before_stream — 1 passed; cargo +1.92.0 test -p services --lib inference_provider_pool — 100 passed, 0 failed; cargo +1.92.0 test -p services near_5xx_falls_back_to_chutes_within_one_request --lib — passed
The bug
When the first event from an upstream provider is an error, we fall through to
break None, commit HTTP 200, open an SSE body, and deliver the failure as an in-band error frame. Worse,[DONE]is still appended afterwards — so a client keying on[DONE]reads the whole thing as a successful empty completion, not a failure.Response headers are still mutable at that point. No stream has been produced and nothing has been written to the client.
Reproduced against production
2026-08-19, streaming
google/gemma-4-31B-it:minItems/maxItems)[DONE]uniqueItemsHTTP error 400: Grammar error: Unimplemented keys: ["uniqueItems"], then[DONE]containspropertyNamesA partner reported 46 of these in a six-hour window on 2026-07-23 — every one of them indistinguishable from an empty success without parsing the frame body. The error string in their report is character-for-character identical to what still reproduces today.
The change
17 lines. Replace
_ => break Nonewith an explicitSome(Err(error))arm that maps the error through the existingmap_provider_error→map_domain_error_to_statuspath and returns the real status.Nonekeeps its previous behaviour.This costs no latency — the first event is already awaited before the response is constructed, so nothing new blocks.
It fixes the whole class, not one keyword: any pre-first-event upstream error now surfaces as a real HTTP status. The
uniqueItemscase is just the one a customer happened to report.map_provider_errorbecomespubso the api crate can call it. The alternative was duplicating error mapping into api, which is worse.Deliberately not in this PR
An earlier version also added a JSON-Schema pre-flight that stripped four llguidance-unsupported keywords and hard-rejected nine others. It was cut, and should not be restored as written:
completions.rs:1403, before alias resolution at:1443and long before provider selection — so it applied llguidance's limits to every Chat Completions request, including those bound for backends that don't use llguidance. The concrete collision is Gemini, whose converter deliberately forwards schemas verbatim viaresponseJsonSchemaand has a regression test requiring that preservation (gemini/converter.rs:1364-1407).That policy belongs at provider dispatch, where the backend is known, and should hard-reject rather than silently strip. Worth a separate PR.
Verification
cargo build— cleancargo clippy -p services -p api --all-targets -- -D warnings— cleancargo test -p api --test e2e_all --no-run— compilesTwo tests added: one asserting a pre-first-event error returns 400 with
uniqueItemsin the message andcontent-type: application/json, one control asserting a normal first chunk is still first and unmodified.The e2e tests were not executed — they need PostgreSQL, which wasn't available in this environment. I'm not claiming a test pass I didn't observe; CI should be the judge.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Fm9f3maaY2wd6yHH8FEjR5