Repository navigation
[Router] Derive error status from a failure class; preserve the worker's status (1/3) - #39463
Merged
Merged
Conversation
This was referenced Sep 14, 2026
Merged
Kangyan-Zhou
added this pull request to stack #39890
September 17, 2026 03:30
ShangmingCai
approved these changes
Sep 17, 2026
| let worker = reqwest::Url::parse("http://host:1/").unwrap(); | ||
| // (label, error, expected status, expected x-router-error-code, | ||
| // expected x-router-upstream-status) | ||
| let cases: Vec<(&str, ApiError, StatusCode, &str, Option<&str>)> = vec![ |
Collaborator
There was a problem hiding this comment.
nit: This table is a Vec, so unlike the matches nothing forces a new variant into it; the "complete contract" table goes stale on the first variant added after this lands.
Collaborator
|
/tag-and-rerun-ci |
…r's status The router's HTTP status for a router-originated error is now a pure function of a new `ErrorClass`, and the precise condition travels in `x-router-error-code`. Two defects fall out of that: - `UpstreamTimeout` mapped to 502 while `StaleRequestExpired` mapped to 504, even though both are "the router gave up waiting". They now share `ErrorClass::Timeout` and cannot drift apart; `UpstreamTimeout` becomes 504. - A mid-body drop discarded the worker's real status. Its code is now `upstream_body_incomplete` and the worker's status is echoed in a new `x-router-upstream-status` header, routed through an exhaustive `ApiError::upstream_status()` so a future variant that synthesizes a status over a responding worker has to decide whether it echoes one. The variant's `Display` and its client-facing message name an incomplete body rather than an error status, since the status it carries is usually a worker 200 that then dropped its body. A worker's own response is still forwarded verbatim and carries neither header — that absence is how a gateway tells engine-origin from router-origin. The dashboard's status panel no longer reads 504 as "stale-cancel": after this change three different conditions produce one, and `worker_requests_total` is what tells them apart. Tests: a table pinning the (status, error-code, upstream-status) triple for every `ApiError` variant; the 502->504 change on both timeout paths; the non-streaming mid-body drop echoing the worker status; and the asymmetric streaming half, where headers already went out as 200 so the client keeps the 200 with no router headers at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xa7Ey1ujKRSrfw4CMrAF5
Kangyan-Zhou
force-pushed
the
router-4d-1-error-class
branch
from
September 18, 2026 04:00
80f848b to
95cccdb
Compare
Collaborator
|
/rerun-failed-ci |
ShangmingCai
marked this pull request as ready for review
September 18, 2026 07:49
ShangmingCai
added a commit
that referenced
this pull request
Sep 19, 2026
#39460's three commits landed on main as #39463 / #39464 / #39465, reworked in review, so this branch's own copies of them are superseded. The merge is resolved to main's version of everything they touched, plus this branch's abort commit rebuilt on top — the resulting tree is identical to rebasing 505ef50 straight onto main, so none of the pre-review code can come back. Three collisions needed real decisions: * `Proxy` no longer has one `client`. #39006 split it into `default_client` / `h2c_client`, selected per worker by `WireProtocol`. `abort_guard_for` now takes the worker's `protocol` and the streaming arm's abort uses `client_for(protocol)`, so the abort rides the same client as the forward it covers. `admin_client()` would have been wrong: it is the negotiating client, which cannot reach an h2c-only worker on a cleartext port — the abort would have silently failed on exactly the fleets that set `--enable-http2`. * `RequestProbe` is no longer `#[derive(Deserialize)]`. Main hand-writes the visitor for the sampling array, so the `#[serde(default)] rid` field was inert. `rid` is now a `ProbeKey::Rid` variant, deliberately NOT a `RoutingKey`: the router does not route on it, so a repeated `rid` stays last-wins like the engine's own `json.loads` instead of becoming a 400. An explicit `null` still reads as absent. * `build_outgoing_body` gained main's `sampling` parameter alongside `rid`. Note the interaction: #39002 added `splice_top_level`, a raw-bytes injection that skips the parse entirely, taken when only sampling is injected. A minted `rid` never overwrites a client key either — `resolve_engine_rid` returns `None` the moment the caller sets its own — so it has the same splice-safe shape, but `splice_top_level` writes `(SamplingField, Number)` members and so cannot carry it. Until that is generalized, minting a rid pulls every plain-mode request back onto the full `serde_json::Value` round-trip that #39002 had just removed. Flagged in the code; worth a decision before merge. Seven main-side tests pinned "the forwarded body equals the request exactly", which a minted `rid` breaks. They now strip the router-minted `rid` and assert its shape separately, keeping each test's actual subject (no `input_ids` added, `messages` preserved). `h2c_forward.rs` gained an explicit `None` abort rid. cargo fmt --check, cargo clippy --all-targets -- -D warnings, and cargo test --lib --tests (758 + 7 + 65 + 132 = 962 passed, 0 failed) are clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Motivation
A router-originated error picks its HTTP status per
ApiErrorvariant, at thepoint the variant is written, with nothing tying the choice to what the
condition actually is. Two defects live in that gap, and both are visible to a
gateway or a client sitting in front of the router:
UpstreamTimeoutanswers 502 whileStaleRequestExpiredanswers 504, though both mean "the router gave upwaiting". A retry policy or an alert keyed on 504 sees only half of the
router's give-ups, and which half depends on which timer fired first.
— usually 200 — and then its body dies mid-flight, the client gets a bare
502 and the one fact worth having, what the worker had actually said, is
gone. The error's code (
upstream_status) and message("upstream returned an error status") also describe a failure that did not
happen: the status it is carrying is generally a success.
Modifications
Status becomes a pure function of a new
ErrorClass. EachApiErrorvariant declares its class (
BadRequest,NotFound,Upstream,NoTarget,Timeout,Internal) andErrorClass::status()is the only place a status ischosen.
UpstreamTimeoutandStaleRequestExpirednow shareErrorClass::Timeout, so they answer the same 504 and cannot drift apartagain. The precise condition stays machine-readable in the existing
x-router-error-code, which is what a caller should branch on — the status isdeliberately coarse.
The worker's own status is preserved, in a new
x-router-upstream-statusheader. It is produced by
ApiError::upstream_status(), an exhaustive matchrather than a catch-all, so a future variant that synthesizes a status over a
worker that did respond has to decide whether it echoes one. The mid-body
drop's code becomes
upstream_body_incomplete, and itsDisplayandclient-facing message now name an incomplete body instead of an error status.
A worker's own response is still forwarded verbatim and carries neither
header. That absence is the contract: a gateway tells engine-origin from
router-origin by whether
x-router-error-codeis present at all.The streaming half is asymmetric on purpose. Once the response head has gone
out as 200 the status cannot be recalled, so a stream that dies mid-body leaves
the client with its 200 and no router headers; the error is recorded, not
re-signalled.
Dashboard: the status panel no longer reads 504 as "stale-cancel". After
this change three different conditions produce one — the stale deadline, the
router's own upstream timeout, and a worker 504 forwarded unchanged — and
worker_requests_total{outcome}is what tells them apart.Tests
(status, error-code, upstream-status)triple for everyApiErrorvariant, so a new variant cannot quietly join the contract.UpstreamTimeoutwas502).
x-router-upstream-status.router headers at all.
cargo testgreen on this branch — 559 lib, 4 bin, 58 component, 94 proxy.cargo clippy --all-targets -- -D warningsclean;pre-commitclean.Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_012xa7Ey1ujKRSrfw4CMrAF5
CI States
Latest PR Test (Base): ✅ Run #35305340928
Latest PR Test (Extra): ✅ Run #35321306980
Latest PR Test (AMD ROCm 10): ➖ No AMD PR run found for this commit.