Skip to content

fix(frontend): classify backend errors on the streaming path - #13139

Open
GuanLuo wants to merge 2 commits into
mainfrom
gluo/streaming-error-classification
Open

GuanLuo wants to merge 2 commits into
mainfrom
gluo/streaming-error-classification

Conversation

@GuanLuo

@GuanLuo GuanLuo commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Overview:

Once a streaming response commits HTTP 200, every backend error reaches the client as the same frame:

{"error":{"message":"Internal server error","type":"internal_server_error","code":500}}

regardless of what actually failed. A caller's own invalid request is indistinguishable from an engine fault, an overload, or a cancellation. This recovers the category — and only the category.

Details:

Two layers drop the classification:

  1. annotated_to_sse_event (metrics.rs) wraps only dynamo_err.message() — a String — into axum::Error, discarding the typed DynamoError and with it error_type.
  2. openai_stream_error (disconnect.rs) has no type left to read, so it hardcodes SanitizedError::Internal.

This preserves the DynamoError through the SSE conversion and classifies on it, using the same precedence as ErrorMessage::from_anyhow:

error before after
InvalidArgument / Backend(InvalidArgument) Internal server error / internal_server_error / 500 Invalid request / invalid_request_error / 400
queue rejection / overload same generic frame Service temporarily overloaded / service_unavailable / 529
unavailable same generic frame Service temporarily unavailable / service_unavailable / 503
cancelled same generic frame Request cancelled / request_cancelled / 499
anything else generic frame unchanged

Every message stays sanitized — deliberately

An earlier revision of this PR forwarded the rejection's message, reasoning that openai_stream_error ignored its argument only because nothing typed reached it. That was wrong about the history. 97a28ac (fix(http): sanitize HTTP error responses to avoid leaking internals, #10151) deliberately replaced err.to_string() with a static frame and rewrote assert_fault_contract to assert the raw detail appears nowhere in the body; #11640 only moved that behaviour into a pluggable formatter. Nothing here reintroduces it.

Forwarding would also be wider than it sounds: py_err_to_dynamo (backend.rs:1654) maps any Python ValueError/TypeError to Backend(InvalidArgument), so an engine-internal ValueError naming a path or tensor shape would ride out to the client. This path cannot distinguish an authored rejection from an incidental one, so it forwards neither.

Hence SanitizedError::InvalidRequest: a fixed ("Invalid request", invalid_request_error, 400) triple. A streaming client learns the fault was its own — the actual gap — with no backend text crossing the boundary.

The unary path still forwards a rejection's message on 400. It can afford to; that is the established protocol contract there and predates this change. The asymmetry is now stated in the code rather than implied.

code in the frame is advisory — the real status went out with the 200 and cannot be revised.

Where should the reviewer start?

lib/llm/src/http/service/disconnect.rs — openai_stream_error and the doc comment stating why nothing is forwarded. Then SanitizedError::InvalidRequest in error.rs, and the one substantive line in annotated_to_sse_event (metrics.rs) that keeps the typed error reachable.

Validation

  • test_mid_stream_invalid_argument_reports_category_without_message — a Backend(InvalidArgument) fault whose message is ValueError: shape mismatch at /opt/dynamo/engine/worker.py:512 yields Invalid request / invalid_request_error / 400, and asserts neither worker.py nor shape mismatch appears anywhere in the body.
  • test_mid_stream_unknown_backend_error_stays_sanitized — Backend(Unknown) stays Internal server error / 500 with its detail absent.
  • test_annotated_error_event_preserves_typed_error / ..._falls_back_to_comment — the conversion keeps the typed error; the untyped comment fallback is unchanged.
  • test_mid_stream_error_does_not_leak_internal_details passes unmodified — the guard that this does not widen what reaches a client.
  • cargo test -p dynamo-llm --lib — 2057 passed, 0 failed. cargo fmt --all and cargo clippy -p dynamo-llm --no-deps --all-targets -- -D warnings clean.

Verified end to end

Live frontend + worker over the real request plane (etcd + NATS, no GPU), stream: true, no DYN_HTTP_PRE_COMMIT_ERROR_PEEK_MS:

before:  data: {"error":{"message":"Internal server error","type":"internal_server_error","code":500}}
after:   data: {"error":{"message":"Invalid request","type":"invalid_request_error","code":400}}

Non-streaming re-checked on the same build and unchanged — still 400 carrying the backend's full message — confirming the pre-commit path is untouched.

Related Issues

🚫 This PR is NOT linked to an issue:

  • Confirmed — no related issue

🤖 Generated with Claude Code

Once a streaming response commits HTTP 200, every backend error reached
the client as the same frame:

    {"error":{"message":"Internal server error",
              "type":"internal_server_error","code":500}}

regardless of what actually failed. A caller's own invalid request was
indistinguishable from an engine fault or an overload.

Two layers dropped the classification. `annotated_to_sse_event` wrapped
only `dynamo_err.message()` into `axum::Error`, discarding the typed
`DynamoError`; `openai_stream_error` then took `_error` and ignored it,
hardcoding `SanitizedError::Internal`. The second was not laziness --
after the first, nothing typed was left to read.

Preserve the `DynamoError` through the SSE conversion and classify on it,
with the same precedence the pre-commit path uses in `from_anyhow`: a
rejection forwards its message verbatim as `invalid_request_error` / 400,
overload and unavailable and cancelled get their own slugs, and anything
else stays sanitized so internals never reach the caller. The existing
leak test still passes unchanged -- forwarding is gated on the rejection
classification, not on the error merely being typed.

`code` in the frame stays advisory, since the real status went out with
the 200. `message` and `type` are what a streaming client reads.

This is why `DYN_HTTP_PRE_COMMIT_ERROR_PEEK_MS` was needed to get an
actionable error out of a streaming request: it catches the failure
before commit so the typed path runs. That workaround is no longer the
only way to learn what went wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Guan Luo <41310872+GuanLuo@users.noreply.github.com>
@GuanLuo
GuanLuo requested review from a team as code owners August 12, 2026 23:18
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: df8eed0b-7b14-46ca-b856-5de1ea5e5d90

📥 Commits

Reviewing files that changed from the base of the PR and between 427046c and 9bda86c.

📒 Files selected for processing (2)
  • lib/llm/src/http/service/disconnect.rs
  • lib/llm/src/http/service/metrics.rs

Walkthrough

Streaming error handling now preserves typed backend errors and serializes classified failures as OpenAI-compatible SSE frames. Invalid arguments retain their messages and return validation metadata. Unknown errors remain sanitized. Tests cover metadata, fallback behavior, and [DONE] termination.

Changes

Streaming error handling

Layer / File(s) Summary
Preserve typed backend errors
lib/llm/src/http/service/metrics.rs
annotated_to_sse_event now forwards non-empty typed errors and retains comment fallback for untyped errors. Tests verify both paths.
Serialize classified SSE errors
lib/llm/src/http/service/disconnect.rs
Streaming errors now use structured, sanitized frames with classification and status metadata. Tests cover invalid-argument forwarding, unknown-error sanitization, and [DONE] termination.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: ⚪ Minimal · up to 9bda8

The change preserves typed backend errors so streaming clients receive appropriate classifications and safe messages while unknown failures remain sanitized; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: classifying backend errors on the frontend streaming path.
Description check ✅ Passed The description includes all required sections, explains the changes, identifies review areas, and confirms that no related issue exists.

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread lib/llm/src/http/service/disconnect.rs Outdated
Comment on lines +215 to +235
if let Some(invalid_argument) = find_invalid_argument_in_chain(error) {
return (
ErrorType::Validation,
stream_error_frame(
invalid_argument.message(),
"invalid_request_error",
axum::http::StatusCode::BAD_REQUEST.as_u16(),
),
);
}

// Same precedence as `ErrorMessage::from_anyhow`.
let (sanitized, error_type) = if request_was_rejected(error) {
(SanitizedError::Overloaded, ErrorType::Overload)
} else if request_was_unavailable(error) {
(SanitizedError::Unavailable, ErrorType::Unavailable)
} else if request_was_cancelled(error) {
(SanitizedError::Cancelled, ErrorType::Cancelled)
} else {
(SanitizedError::Internal, ErrorType::Internal)
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Streaming errors are sorted differently than non-streaming ones, so an overload or unavailable failure can be reported to the caller as a bad request

The streaming error classifier checks the "caller sent something invalid" case first (find_invalid_argument_in_chain at lib/llm/src/http/service/disconnect.rs:215) instead of after the overload and unavailable checks, so a failure that is both an overload/unavailable and carries an invalid-argument cause is labelled a client mistake and its raw text is passed through, unlike the identical failure on the non-streaming path.
Impact: The same backend failure is described differently (and with a different message) depending on whether the client asked for streaming, and internal text can be forwarded in a case where the non-streaming path sanitizes it.

Precedence mismatch with `ErrorMessage::from_anyhow`

The added doc comment states the classification "mirrors the pre-commit path in backend_error_response" and the inline comment claims "Same precedence as ErrorMessage::from_anyhow". ErrorMessage::from_anyhow (lib/llm/src/http/service/openai.rs:418-470) orders the checks: queue rejection → request_was_rejected (529) → request_was_unavailable (503) → find_invalid_argument_in_chain (400, message forwarded) → request_was_cancelled. lib/llm/src/http/service/sglang_generate.rs:107-119 uses the same order.

The new openai_stream_error evaluates find_invalid_argument_in_chain before the rejected/unavailable checks. For an error chain containing both classifications (e.g. an InvalidArgument wrapped under, or wrapping, a ResourceExhausted/Unavailable cause — match_error_chain walks the whole chain), the streaming path reports invalid_request_error/400 with the raw message while the pre-commit path reports the sanitized overload/unavailable frame.

Suggested change
if let Some(invalid_argument) = find_invalid_argument_in_chain(error) {
return (
ErrorType::Validation,
stream_error_frame(
invalid_argument.message(),
"invalid_request_error",
axum::http::StatusCode::BAD_REQUEST.as_u16(),
),
);
}
// Same precedence as `ErrorMessage::from_anyhow`.
let (sanitized, error_type) = if request_was_rejected(error) {
(SanitizedError::Overloaded, ErrorType::Overload)
} else if request_was_unavailable(error) {
(SanitizedError::Unavailable, ErrorType::Unavailable)
} else if request_was_cancelled(error) {
(SanitizedError::Cancelled, ErrorType::Cancelled)
} else {
(SanitizedError::Internal, ErrorType::Internal)
};
// Same precedence as `ErrorMessage::from_anyhow`.
let (sanitized, error_type) = if request_was_rejected(error) {
(SanitizedError::Overloaded, ErrorType::Overload)
} else if request_was_unavailable(error) {
(SanitizedError::Unavailable, ErrorType::Unavailable)
} else if let Some(invalid_argument) = find_invalid_argument_in_chain(error) {
return (
ErrorType::Validation,
stream_error_frame(
invalid_argument.message(),
"invalid_request_error",
axum::http::StatusCode::BAD_REQUEST.as_u16(),
),
);
} else if request_was_cancelled(error) {
(SanitizedError::Cancelled, ErrorType::Cancelled)
} else {
(SanitizedError::Internal, ErrorType::Internal)
};
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread lib/llm/src/http/service/disconnect.rs Outdated
Comment on lines +209 to +224
fn openai_stream_error(error: &(dyn std::error::Error + 'static)) -> (ErrorType, String) {
use crate::http::service::metrics::{
request_was_cancelled, request_was_rejected, request_was_unavailable,
};
use crate::http::service::openai::find_invalid_argument_in_chain;

if let Some(invalid_argument) = find_invalid_argument_in_chain(error) {
return (
ErrorType::Validation,
stream_error_frame(
invalid_argument.message(),
"invalid_request_error",
axum::http::StatusCode::BAD_REQUEST.as_u16(),
),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Anthropic streaming clients receive an OpenAI-shaped error frame

The Anthropic handler routes its converted stream through monitor_for_disconnects (lib/llm/src/http/service/anthropic.rs:667), which uses openai_stream_error. That was already true before this PR, but the change now means a forwarded invalid-argument message reaches Anthropic clients inside an OpenAI-shaped {"error":{message,type,code}} frame, which does not match Anthropic's error event schema handled elsewhere in that file (lib/llm/src/http/service/anthropic.rs:1030-1060). Widening what is forwarded makes this shape mismatch more visible to callers.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions github-actions Bot added frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` fix labels Aug 12, 2026
// `String` here erases `error_type` and left every streamed backend
// failure — invalid argument, overload, engine fault — looking
// identical to the caller.
if let Some(dynamo_err) = annotated.error.filter(|err| !err.message().is_empty()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typed backend errors with an empty message are still flattened to an untyped string here, so overload/unavailable/cancelled classifications can be lost on the streaming path. Fix: preserve annotated.error whenever it is present, and let the downstream formatter decide whether the message is safe to expose.

🤖 AI Fix

In lib/llm/src/http/service/metrics.rs, inside annotated_to_sse_event, replace if let Some(dynamo_err) = annotated.error.filter(|err| !err.message().is_empty()) { return Err(axum::Error::new(dynamo_err)); } with if let Some(dynamo_err) = annotated.error { return Err(axum::Error::new(dynamo_err)); }.

@GuanLuo
GuanLuo marked this pull request as draft August 12, 2026 23:35
@datadog-official

This comment has been minimized.

The first commit forwarded a rejection's message, on the reasoning that
`openai_stream_error` ignored its error because nothing typed reached it.
That was wrong about history: 97a28ac ("sanitize HTTP error responses
to avoid leaking internals") deliberately replaced `err.to_string()` with
a static frame, and #11640 only moved that behaviour into a formatter.

The exposure is also wider than messages we author. `py_err_to_dynamo`
maps any Python `ValueError`/`TypeError` to `Backend(InvalidArgument)`,
so forwarding on that classification would carry engine-internal text --
paths, tensor shapes -- to a streaming client. This path cannot tell an
authored rejection from an incidental one.

Keep the classification, drop the forwarding: add `SanitizedError::
InvalidRequest` so a rejection reports "Invalid request" /
`invalid_request_error` / 400. A streaming client can now tell its own
bad request from an engine fault, which was the actual gap, without any
backend text crossing the boundary.

The unary path still forwards on 400. It can afford to -- it is the
established protocol contract there and predates this -- but that
asymmetry is now stated in the code rather than implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Guan Luo <41310872+GuanLuo@users.noreply.github.com>
@GuanLuo
GuanLuo marked this pull request as ready for review August 12, 2026 23:46
@glamr-agent

Copy link
Copy Markdown
Contributor
Automated evidence record — validation incomplete

Validation status: incomplete

Evidence summary: 2 of 3 checks validated; 1 needs hardware that was not available here.

AI review assessment (advisory, not an approval): needs_changes. Repository CI and human reviewers decide whether this merges.

Validation result: incomplete. Every static, compile, lint, and unit-test check passed. The live single-GPU streaming request could not run: the GPU driver available in this environment reports CUDA 12080, which is older than the CUDA 13.0 build of the installed PyTorch, so the vLLM worker aborted during CUDA initialization and never started.

Evidence audit: complete — 2 of 3 validated, 1 needs hardware. The command report below comes from recorded runs.

Commands and results [2/3 validated · 1 needs hardware]

Generated from the commands recorded during this run.

Check 1

Builds the changed Dynamo source and confirms that Python can import its compiled extension.

Result: Passed (exit 0)

Command:

Not shown because the exact command contained private run data.

Check 2

Checks the changed Rust crates with cargo check and Clippy.

Result: Passed (exit 0)

Command:

cargo test -p dynamo-llm --lib http::service::disconnect::tests::test_cancelled_classification_survives_an_unset_signal -- --exact --nocapture

Check 3

Starts Dynamo with vLLM on one GPU and sends a real request.

Result: Failed (exit 1)

Command:

bash -c 'source /opt/dynamo/venv/bin/activate; python3 -c "import torch; print(\"torch\", torch.__version__, \"built for CUDA\", torch.version.cuda); print(\"cuda available:\", torch.cuda.is_available()); torch.cuda.init()"'

Details:

The live aggregated streaming smoke could not run: the GPU driver available to this environment reports CUDA 12080 while the installed PyTorch is built for CUDA 13.0, so the vLLM worker aborts in CUDA init with "The NVIDIA driver on your system is too old (found version 12080)" and never registers with the frontend.

@glamr-agent

glamr-agent commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor
review.md

🤖 Automated AI review — advisory. An AI agent's judgment of
whether this change is logically sound based on the code and reported
validation results. This is not an approval. Repository CI and human
reviewers decide whether to merge.

Assessment: needs_changes

This reviews #13139 — "classify mid-stream SSE
errors instead of always reporting Internal" — at head 581b2fac3a778c2e0d110f77c25a9e361aa54c2a.
The premise is right and the fix is in the right two places: annotated_to_sse_event was
flattening Annotated::error to a String, which destroyed the typed chain before
openai_stream_error could read it, so every mid-stream failure landed on
error_type="internal" in dynamo_frontend_requests_total and on internal_server_error
in the inline SSE frame. Preserving the DynamoError and then classifying it is the
correct shape of fix, and the sanitization contract is held — no arm forwards backend text.

Four findings follow. One is a behavioural divergence the code's own comment denies; one is
a latent collision with a sentinel value the PR did not introduce but now makes ambiguous;
two are added tests that pass unchanged with the production change reverted.

Line numbers below are in the post-image of the PR's own diff.

Drift audit

The change was materialized onto current main before review, so the merged result was
read rather than the author's branch. Comparing the materialized diff line-for-line against
the head diff obtained with gh pr diff 13139 --repo ai-dynamo/dynamo: the set of
author-added lines is identical (comm -23 of the two added-line sets is empty), and the
set of removed lines is identical in both directions. No author line was altered,
dropped, or paraphrased in materialization
, so every finding below is a finding against
the PR as written and not against a rebase artifact.

Two benign, non-substantive differences exist and are not findings. SanitizedError on
current main now derives PartialEq, Eq, so the #[derive(...)] context line differs
from the PR's; and the two metrics.rs tests land after
model_ready_metric_tracks_live_routing_catalog rather than before handler_metrics,
because that region moved on main — the union of both sides is present, no test dropped.
Neither changes behaviour.

Findings

1. lib/llm/src/http/service/disconnect.rs:222 — blocking

Claim. The comment // Same precedence as ErrorMessage::from_anyhow. is not accurate.
openai_stream_error tests find_invalid_argument_in_chain first;
ErrorMessage::from_anyhow tests it fourth. An error chain that satisfies both
request_was_rejected and find_invalid_argument_in_chain is therefore reported as an
overload before the response commits and as a client validation error after the first
token, from the same underlying failure.

Evidence. The new ladder, at disconnect.rs:223:

let (sanitized, error_type) = if find_invalid_argument_in_chain(error).is_some() {
    (SanitizedError::InvalidRequest, ErrorType::Validation)
} else if request_was_rejected(error) {
    (SanitizedError::Overloaded, ErrorType::Overload)
} else if request_was_unavailable(error) {
    (SanitizedError::Unavailable, ErrorType::Unavailable)
} else if request_was_cancelled(error) {
    (SanitizedError::Cancelled, ErrorType::Cancelled)
} else {
    (SanitizedError::Internal, ErrorType::Internal)
};

ErrorMessage::from_anyhow in lib/llm/src/http/service/openai.rs:548 tests, in order:
find_queue_rejection_in_chain (openai.rs:549), request_was_rejected
(openai.rs:564), request_was_unavailable (openai.rs:572),
find_invalid_argument_in_chain (openai.rs:580), request_was_cancelled
(openai.rs:594). Two other ladders in the same crate agree with from_anyhow and not
with the new one: lib/llm/src/http/service/anthropic.rs:563–589 and
lib/llm/src/http/service/sglang_generate.rs:107–118 both order rejection, then
unavailability, then invalid argument, then cancellation. The streaming ladder is the only
one of the four that leads with the invalid-argument probe, and it additionally omits the
find_queue_rejection_in_chain arm entirely.

Both predicates walk the whole chain — find_invalid_argument_in_chain (openai.rs:277)
matches ErrorType::InvalidArgument | ErrorType::Backend(BackendError::InvalidArgument)
anywhere in it, and request_was_rejected (lib/llm/src/http/service/metrics.rs:41)
matches ResourceExhausted | WorkerOverloaded anywhere in it — so for a chain carrying
both, order alone decides. The observable split for such a chain: pre-commit it is
SanitizedError::Overloaded, whose status() is overload_status_code() (529 by
default) with slug service_unavailable and metric label overload; mid-stream it is
SanitizedError::InvalidRequest, "code": 400, "type": "invalid_request_error", metric
label validation. A client that retries an overload but not a 400 changes behaviour
purely on whether the failure landed before or after the first token.

The PR's own tests do not catch this because none of them constructs a chain matching two
arms. This is not a hypothetical shape: map_scheduler_error
(lib/llm/src/kv_router.rs:451) builds ResourceExhausted and WorkerOverloaded wrappers
around a .cause(), and Backend(InvalidArgument) covers any engine-side argument fault
that cause may carry.

Resolve it in one of two ways: reorder the streaming ladder to match, or keep the
streaming-specific order and correct the comment to say what it is and why. The suggestion
carried in the machine-readable findings takes the first option, because three of the four
in-tree ladders already agree and matching them is the lower-surprise result; it is safe
against the PR's own tests, since test_mid_stream_invalid_argument_reports_category_without_message
drives a bare Backend(InvalidArgument) for which request_was_rejected is false.

2. lib/llm/src/http/service/disconnect.rs:229 — nit

Claim. ErrorType::Cancelled is now a value the formatter can return, and it is also
the pre-classification sentinel meaning "this stream was dropped without a verdict". After
this PR the two are indistinguishable, so a formatter verdict of Cancelled can be
silently overwritten on drop.

Evidence. monitor_for_disconnects_with_timeout seeds every monitored stream with
inflight_guard.mark_error(ErrorType::Cancelled) at disconnect.rs:491, with the comment
"Default to Cancelled: if the stream is dropped unexpectedly … the guard will report
'cancelled' instead of 'internal'". On a stream error it calls
inflight_guard.mark_error(error_type) with the formatter's verdict (disconnect.rs:520).
SignaledInflightGuard::drop (disconnect.rs:142) then reads the guard back:

impl Drop for SignaledInflightGuard {
    fn drop(&mut self) {
        if self.guard.error_type() == &ErrorType::Cancelled
            && let Some(error_type) = self.signaled_error_type()
        {
            self.guard.mark_error(error_type);
        }
    }
}

Before this PR the formatter returned ErrorType::Internal unconditionally, so an
ErrorType::Cancelled on the guard could only have come from the seed and the sentinel was
unambiguous. The new request_was_cancelled arm makes it ambiguous.

I could not construct a live request path that hits this today, and say so rather than
overstate it. The only site that supplies a StreamErrorSignal is the Responses streaming
handler (openai.rs:3608 and openai.rs:3673), and on that path backend errors are
consumed by extract_backend_error_if_present and turned into terminal Responses events
before they can reach error_formatter. So this is a latent trap rather than a live defect:
the next caller that passes an error signal into StreamMonitorOptions on a path where the
formatter does run will get its cancellations relabelled. The cheap fix is to stop
overloading Cancelled — give the guard a distinct "unset" state, or have
SignaledInflightGuard record whether mark_error was called deliberately — rather than to
special-case the new arm.

3. lib/llm/src/http/service/disconnect.rs:927–956 — blocking

Claim. test_mid_stream_unknown_backend_error_stays_sanitized, whose async fn is at
disconnect.rs:933, passes unchanged if the production change is reverted, so it is
evidence about pre-existing behaviour rather than about this PR, and by the repository's
test rule it should be deleted rather than kept or strengthened. The range starts at
disconnect.rs:927 — the preceding test's closing brace — only so that the deletion has
exact replacement text and applies in one click.

Evidence. The test asserts frame["message"] == "Internal server error",
frame["type"] == "internal_server_error", frame["code"] == 500, and that the secret
substring does not appear. Those are exactly the values the pre-change
openai_stream_error produced for every error, since it built its body from an
unconditional SanitizedError::Internal. The post-change ladder sends
Backend(BackendError::Unknown) to its final else arm, which is the same
SanitizedError::Internal, so all four assertions are byte-identical before and after. Any
reader can confirm this by restoring the body of openai_stream_error to
(ErrorType::Internal, …) and running cargo test -p dynamo-llm --lib http::service — this
test stays green.

Its doc comment is separately inaccurate and is worth noting even if the test is kept:
"Forwarding messages is gated on the rejection classification, not on the error merely being
typed" describes a gate that does not exist. No arm of openai_stream_error forwards an
error message; every arm passes a static sanitized.to_string() into stream_error_frame.
The asymmetry the function's own doc comment at disconnect.rs:207 describes is with the
pre-commit path, not between arms of this function.

4. lib/llm/src/http/service/metrics.rs:2272–2289 — blocking

Claim. test_annotated_error_event_falls_back_to_comment, whose fn is at
metrics.rs:2276, passes unchanged if the production change is reverted, for the same
reason, and should be deleted. As above, the range starts at metrics.rs:2272 — the
preceding test's closing brace — so the deletion has exact replacement text.

Evidence. The test constructs an Annotated with error: None and
comment: Some(vec!["worker exploded".to_string()]). The branch this PR adds to
annotated_to_sse_event is guarded on annotated.error being Some and non-empty, so with
error: None the test never enters it and exercises only the untouched comment fallback.
Its two assertions — that the message contains worker exploded, and that
find_invalid_argument_in_chain finds nothing — both held before the change.

Its sibling test_annotated_error_event_preserves_typed_error (metrics.rs:2252) is the
one that carries the weight here: with the change reverted, the typed error is flattened to
a String, find_invalid_argument_in_chain returns None, and its .expect(...) panics.
That test should stay.

Taken together, exactly two of the four tests this PR adds are sensitive to it:
test_mid_stream_invalid_argument_reports_category_without_message
(disconnect.rs:896) and test_annotated_error_event_preserves_typed_error
(metrics.rs:2252). The other two are the subject of findings 3 and 4.

Coverage gap worth naming

Nothing in this PR exercises the change through the streaming handler end to end. Both
sensitive tests call into the classification layer directly —
monitor_for_disconnects_with_timeout with a synthesized error stream, and
annotated_to_sse_event with a hand-built Annotated. That covers the logic, and it is the
right level for most of it, but the claim the PR is actually making is about what a live
POST /v1/chat/completions with "stream": true emits and what
dynamo_frontend_requests_total records afterwards, and no test asserts that end to end. A
handler-level test that drives a mock engine which fails after the first chunk, then reads
both the SSE frame and the counter, would close it. This is a gap in the PR's evidence, not
a defect in its logic.

Validation state

Every static, compile, lint, and unit check run against the merged result passed:
git diff --check and cargo fmt --all --check both exit 0;
cargo check --workspace --all-targets exits 0;
cargo check --manifest-path lib/bindings/python/Cargo.toml --all-targets exits 0;
cargo clippy --workspace --all-targets --no-deps -- -D warnings exits 0 with no lint
diagnostics; and cargo test -p dynamo-llm --lib reports
2633 passed; 0 failed; 5 ignored.

A live single-GPU streaming request was attempted and did not run: the worker aborted during
CUDA device initialization before reaching any Dynamo code. So the assessment rests on
static and unit evidence, and no claim here depends on that attempt. The coverage gap the
PR itself should close is stated above, as a finding about the change rather than about the
attempt.

The command record for this review has three entries and all three executed; none is
recorded as unrun. The PR's two new test helpers — error_frame and typed_error_stream,
at disconnect.rs:870 and disconnect.rs:882 — were exercised rather than merely added:
both are used by test_mid_stream_invalid_argument_reports_category_without_message, which
ran green under cargo test -p dynamo-llm --lib http::service::disconnect
(17 passed; 0 failed). Deleting the test named in finding 3 does not orphan either helper.

Merge sequencing, for whoever schedules this

Two open pull requests, #14382 and
#14395, are an identical pair — same title,
refactor(errors): unify semantic failure handling, twelve files each — and both modify
all three files this PR touches (lib/llm/src/http/service/disconnect.rs, error.rs,
metrics.rs) plus anthropic.rs and openai.rs. Whichever lands second will conflict in
the same region. Finding 1 is about precedence ordering across exactly the ladders that
refactor unifies, so sequencing these two deliberately is likely cheaper than resolving them
against each other afterwards. Stated as fact for scheduling, not as an objection to this
PR.

Requested changes

  1. disconnect.rs:222–233 — make the streaming ladder and its comment agree. Either
    reorder to request_was_rejected → request_was_unavailable →
    find_invalid_argument_in_chain → request_was_cancelled, matching
    ErrorMessage::from_anyhow, anthropic.rs, and sglang_generate.rs; or keep the
    streaming order and replace the comment with one that states the intended
    streaming-specific precedence and why it differs. If the omission of the
    find_queue_rejection_in_chain arm is deliberate, say so in the same comment.
  2. disconnect.rs:927–956 — delete
    test_mid_stream_unknown_backend_error_stays_sanitized. It asserts the pre-change
    output of the unconditional SanitizedError::Internal path and passes with the change
    reverted.
  3. metrics.rs:2272–2289 — delete test_annotated_error_event_falls_back_to_comment.
    With error: None it never reaches the branch this PR adds and passes with the change
    reverted. Keep test_annotated_error_event_preserves_typed_error.
  4. Optional, disconnect.rs:229 — decide whether ErrorType::Cancelled should remain
    both a formatter verdict and the "no verdict yet" sentinel that
    SignaledInflightGuard::drop reads at disconnect.rs:142. A distinct unset state would
    remove the ambiguity.
  5. Optional — add one handler-level test that drives a streaming chat-completions
    request against an engine that fails mid-stream and asserts both the SSE error frame and
    the resulting dynamo_frontend_requests_total label.

@glamr-agent glamr-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated AI review — advisory. The inline comments below are the findings. The full review is in a separate comment on this pull request.

Comment on lines +222 to +233
// Same precedence as `ErrorMessage::from_anyhow`.
let (sanitized, error_type) = if find_invalid_argument_in_chain(error).is_some() {
(SanitizedError::InvalidRequest, ErrorType::Validation)
} else if request_was_rejected(error) {
(SanitizedError::Overloaded, ErrorType::Overload)
} else if request_was_unavailable(error) {
(SanitizedError::Unavailable, ErrorType::Unavailable)
} else if request_was_cancelled(error) {
(SanitizedError::Cancelled, ErrorType::Cancelled)
} else {
(SanitizedError::Internal, ErrorType::Internal)
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — automated AI review (advisory). the comment // Same precedence as ErrorMessage::from_anyhow. is not accurate — this ladder tests find_invalid_argument_in_chain first while ErrorMessage::from_anyhow tests it fourth, so a chain matching both request_was_rejected and find_invalid_argument_in_chain is an overload before the response commits and a 400 after the first token

Evidence: openai.rs:548 orders find_queue_rejection_in_chain (openai.rs:549), request_was_rejected (openai.rs:564), request_was_unavailable (openai.rs:572), find_invalid_argument_in_chain (openai.rs:580), request_was_cancelled (openai.rs:594); anthropic.rs:563-589 and sglang_generate.rs:107-118 use that same order. Both predicates walk the whole chain — find_invalid_argument_in_chain (openai.rs:277) matches InvalidArgument or Backend(InvalidArgument) anywhere in it, request_was_rejected (metrics.rs:41) matches ResourceExhausted or WorkerOverloaded anywhere in it — so order alone decides. Pre-commit that chain is SanitizedError::Overloaded (overload_status_code(), 529 by default, slug service_unavailable, label overload); mid-stream it is now "code": 400, "type": "invalid_request_error", label validation. map_scheduler_error (lib/llm/src/kv_router.rs:451) builds exactly such wrappers. The suggested reorder is safe against test_mid_stream_invalid_argument_reports_category_without_message, which drives a bare Backend(InvalidArgument) for which request_was_rejected is false. The alternative resolution is to keep this order and rewrite the comment to state the intended streaming-specific precedence, including whether dropping the find_queue_rejection_in_chain arm was deliberate.

Suggested change
// Same precedence as `ErrorMessage::from_anyhow`.
let (sanitized, error_type) = if find_invalid_argument_in_chain(error).is_some() {
(SanitizedError::InvalidRequest, ErrorType::Validation)
} else if request_was_rejected(error) {
(SanitizedError::Overloaded, ErrorType::Overload)
} else if request_was_unavailable(error) {
(SanitizedError::Unavailable, ErrorType::Unavailable)
} else if request_was_cancelled(error) {
(SanitizedError::Cancelled, ErrorType::Cancelled)
} else {
(SanitizedError::Internal, ErrorType::Internal)
};
// Same precedence as `ErrorMessage::from_anyhow`: a rejection or an
// unavailable worker outranks an invalid argument found deeper in the chain.
let (sanitized, error_type) = if request_was_rejected(error) {
(SanitizedError::Overloaded, ErrorType::Overload)
} else if request_was_unavailable(error) {
(SanitizedError::Unavailable, ErrorType::Unavailable)
} else if find_invalid_argument_in_chain(error).is_some() {
(SanitizedError::InvalidRequest, ErrorType::Validation)
} else if request_was_cancelled(error) {
(SanitizedError::Cancelled, ErrorType::Cancelled)
} else {
(SanitizedError::Internal, ErrorType::Internal)
};

Comment on lines +927 to +956
}

/// The complement: an untyped backend fault stays sanitized. Forwarding
/// messages is gated on the rejection classification, not on the error
/// merely being typed.
#[tokio::test]
async fn test_mid_stream_unknown_backend_error_stays_sanitized() {
use dynamo_runtime::error::{BackendError, DynamoError, ErrorType as RuntimeErrorType};

let (_metrics, guard, ctx, handle) = setup_test("unknown-model", "req-unknown");
let secret = "assertion failed at engine/worker.py:512";
let stream = typed_error_stream(
DynamoError::builder()
.error_type(RuntimeErrorType::Backend(BackendError::Unknown))
.message(secret)
.build(),
);

let monitored = monitor_for_disconnects_with_timeout(stream, ctx, guard, handle, None);
let body = collect_sse_body(monitored).await;
let frame = error_frame(&body);

assert_eq!(frame["message"], "Internal server error");
assert_eq!(frame["type"], "internal_server_error");
assert_eq!(frame["code"], 500);
assert!(
!body.contains(secret),
"leaked internal detail. Body:\n{body}"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — automated AI review (advisory). test_mid_stream_unknown_backend_error_stays_sanitized passes unchanged with the production change reverted, so it documents pre-existing behaviour rather than evidencing this change, and should be deleted rather than strengthened

Evidence: It asserts frame["message"] == "Internal server error", frame["type"] == "internal_server_error" and frame["code"] == 500 — exactly what the previous openai_stream_error produced for every error, since it built its body from an unconditional SanitizedError::Internal. Post-change, Backend(BackendError::Unknown) falls to the final else, which is the same SanitizedError::Internal, so all four assertions are byte-identical either way. Confirm it by restoring the body of openai_stream_error to (ErrorType::Internal, …) and running cargo test -p dynamo-llm --lib http::service: this test stays green. Its doc comment is separately inaccurate — "Forwarding messages is gated on the rejection classification" describes a gate that does not exist, since every arm passes a static sanitized.to_string() into stream_error_frame. The suggestion deletes the test; the range starts one line early, at the preceding test's closing brace, so the replacement text is exact.

Suggested change
}
/// The complement: an untyped backend fault stays sanitized. Forwarding
/// messages is gated on the rejection classification, not on the error
/// merely being typed.
#[tokio::test]
async fn test_mid_stream_unknown_backend_error_stays_sanitized() {
use dynamo_runtime::error::{BackendError, DynamoError, ErrorType as RuntimeErrorType};
let (_metrics, guard, ctx, handle) = setup_test("unknown-model", "req-unknown");
let secret = "assertion failed at engine/worker.py:512";
let stream = typed_error_stream(
DynamoError::builder()
.error_type(RuntimeErrorType::Backend(BackendError::Unknown))
.message(secret)
.build(),
);
let monitored = monitor_for_disconnects_with_timeout(stream, ctx, guard, handle, None);
let body = collect_sse_body(monitored).await;
let frame = error_frame(&body);
assert_eq!(frame["message"], "Internal server error");
assert_eq!(frame["type"], "internal_server_error");
assert_eq!(frame["code"], 500);
assert!(
!body.contains(secret),
"leaked internal detail. Body:\n{body}"
);
}
}

Comment on lines +2272 to +2289
}

/// Without a typed error the comment fallback still applies, untyped.
#[test]
fn test_annotated_error_event_falls_back_to_comment() {
let annotated = crate::types::Annotated::<serde_json::Value> {
data: None,
id: None,
event: Some("error".to_string()),
comment: Some(vec!["worker exploded".to_string()]),
error: None,
};

let err = annotated_to_sse_event(annotated).expect_err("error event must become Err");
assert!(err.to_string().contains("worker exploded"));
assert!(crate::http::service::openai::find_invalid_argument_in_chain(&err).is_none());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — automated AI review (advisory). test_annotated_error_event_falls_back_to_comment passes unchanged with the production change reverted, because error: None means it never enters the branch this change adds, and should be deleted

Evidence: The new branch in annotated_to_sse_event is guarded on annotated.error being Some and non-empty, so an Annotated built with error: None exercises only the untouched comment fallback; both assertions — that the message contains worker exploded, and that find_invalid_argument_in_chain finds nothing — held before the change. Its sibling test_annotated_error_event_preserves_typed_error (metrics.rs:2252) is the one that carries the weight and should stay: revert the change and its .expect(...) panics, because the typed error is flattened to a String and find_invalid_argument_in_chain returns None. The suggestion deletes only the comment-fallback test; the range starts one line early, at the preceding test's closing brace, so the replacement text is exact.

Suggested change
}
/// Without a typed error the comment fallback still applies, untyped.
#[test]
fn test_annotated_error_event_falls_back_to_comment() {
let annotated = crate::types::Annotated::<serde_json::Value> {
data: None,
id: None,
event: Some("error".to_string()),
comment: Some(vec!["worker exploded".to_string()]),
error: None,
};
let err = annotated_to_sse_event(annotated).expect_err("error event must become Err");
assert!(err.to_string().contains("worker exploded"));
assert!(crate::http::service::openai::find_invalid_argument_in_chain(&err).is_none());
}
}

Comment on lines +229 to +230
} else if request_was_cancelled(error) {
(SanitizedError::Cancelled, ErrorType::Cancelled)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — automated AI review (advisory). ErrorType::Cancelled is now both a formatter verdict and the "dropped without a verdict" sentinel, so a genuine mid-stream cancellation can be silently relabelled on drop

Evidence: disconnect.rs:491 seeds every monitored stream with inflight_guard.mark_error(ErrorType::Cancelled) to mean "no verdict yet", disconnect.rs:520 overwrites it with the formatter's verdict, and SignaledInflightGuard::drop (disconnect.rs:142) re-marks the guard from the stream-error signal precisely when it still reads ErrorType::Cancelled. Before this change the formatter returned ErrorType::Internal unconditionally, so that sentinel was unambiguous. This is latent rather than live: the only site supplying a StreamErrorSignal is the Responses streaming handler (openai.rs:3608, openai.rs:3673), where backend errors are consumed by extract_backend_error_if_present before they can reach error_formatter. A distinct unset state on the guard would remove the ambiguity more cheaply than special-casing this arm.

@glamr-agent

Copy link
Copy Markdown
Contributor

Assessment: needs_changes

The premise here is right, and so is the shape of the fix. annotated_to_sse_event was
flattening Annotated::error into a String before openai_stream_error ever saw it, so
the typed chain was gone by the time anything could classify it and every mid-stream failure
came out as internal_server_error in the frame and error_type="internal" in
dynamo_frontend_requests_total. Preserving the DynamoError and then classifying it is
the correct fix, and the sanitization contract holds — every arm passes a static message
into stream_error_frame, so nothing backend-side reaches a client.

Four things I would want resolved before this merges. Line numbers are in this PR's diff.

1. disconnect.rs:222 — the parity comment is not accurate, and the divergence is
observable.
// Same precedence as ErrorMessage::from_anyhow. sits above a ladder that
tests find_invalid_argument_in_chain first. ErrorMessage::from_anyhow
(lib/llm/src/http/service/openai.rs:548) tests it fourth, behind
find_queue_rejection_in_chain (openai.rs:549), request_was_rejected (openai.rs:564)
and request_was_unavailable (openai.rs:572). Two other ladders in the same crate agree
with from_anyhow and not with the new one:
lib/llm/src/http/service/anthropic.rs:563–589 and
lib/llm/src/http/service/sglang_generate.rs:107–118. The new ladder also drops the
find_queue_rejection_in_chain arm entirely.

Both predicates walk the whole chain — find_invalid_argument_in_chain (openai.rs:277)
matches InvalidArgument or Backend(InvalidArgument) anywhere in it,
request_was_rejected (lib/llm/src/http/service/metrics.rs:41) matches
ResourceExhausted or WorkerOverloaded anywhere in it — so for a chain carrying both,
order alone decides the answer. Such a chain is not exotic: map_scheduler_error
(lib/llm/src/kv_router.rs:451) wraps a cause in ResourceExhausted or
WorkerOverloaded, and Backend(InvalidArgument) covers an engine-side argument fault that
cause may carry. Before the response commits that chain is SanitizedError::Overloaded —
overload_status_code(), 529 by default, slug service_unavailable, metric label
overload. After the first token it is now "code": 400,
"type": "invalid_request_error", metric label validation. Same failure, different answer
depending only on stream timing, and a client that retries 529 but not 400 will behave
differently across that boundary.

Either reorder to match — request_was_rejected, request_was_unavailable,
find_invalid_argument_in_chain, request_was_cancelled — or keep the streaming order and
rewrite the comment to state the intended precedence and why it differs, including whether
dropping the queue-rejection arm was deliberate. Reordering is safe against your own tests:
test_mid_stream_invalid_argument_reports_category_without_message drives a bare
Backend(InvalidArgument), for which request_was_rejected is false, so it still lands
on the Invalid request arm.

2. disconnect.rs:929–956 —
test_mid_stream_unknown_backend_error_stays_sanitized passes with the production change
reverted.
It asserts frame["message"] == "Internal server error",
frame["type"] == "internal_server_error" and frame["code"] == 500, which are exactly the
values the old openai_stream_error produced for everything, since it built its body from
an unconditional SanitizedError::Internal. Post-change, Backend(BackendError::Unknown)
falls to the final else, which is the same SanitizedError::Internal, so all four
assertions are byte-identical either way. You can confirm it in your checkout: restore the
body of openai_stream_error to (ErrorType::Internal, …) and run
cargo test -p dynamo-llm --lib http::service — this test stays green. I would delete it
rather than add assertions to it.

Its doc comment is also inaccurate independently of that: "Forwarding messages is gated on
the rejection classification, not on the error merely being typed" describes a gate that
does not exist in this function. No arm forwards a message; the asymmetry your own doc
comment at disconnect.rs:207 describes is with the pre-commit path, not between arms here.

3. metrics.rs:2274–2288 — test_annotated_error_event_falls_back_to_comment passes
with the change reverted too.
It builds an Annotated with error: None, and the branch
this PR adds is guarded on annotated.error being Some and non-empty, so the test never
enters it — it exercises only the untouched comment fallback. Its sibling
test_annotated_error_event_preserves_typed_error (metrics.rs:2252) is the one doing the
work: revert the change and its .expect(...) panics, because the typed error is flattened
and find_invalid_argument_in_chain returns None. Keep that one, drop this one.

That leaves exactly two of the four added tests sensitive to the change —
test_mid_stream_invalid_argument_reports_category_without_message (disconnect.rs:896)
and test_annotated_error_event_preserves_typed_error.

4. disconnect.rs:229 — smaller, and a design question rather than a defect. The new
request_was_cancelled arm makes ErrorType::Cancelled a value the formatter can return.
It is also the sentinel: disconnect.rs:491 seeds every monitored stream with
inflight_guard.mark_error(ErrorType::Cancelled) to mean "dropped without a verdict", and
SignaledInflightGuard::drop (disconnect.rs:142) re-marks the guard from the stream-error
signal precisely when it still reads Cancelled. After this PR a genuine cancellation
verdict is indistinguishable from the seed and can be overwritten on drop.

I could not build a live request path that hits this today — the only site supplying a
StreamErrorSignal is the Responses streaming handler (openai.rs:3608, openai.rs:3673),
and there backend errors are consumed by extract_backend_error_if_present before they can
reach error_formatter — so this is latent, not live. Worth a decision anyway: the next
caller that passes an error signal on a path where the formatter does run will get its
cancellations relabelled. A distinct "unset" state on the guard is cheaper than
special-casing the arm.

One coverage gap. Nothing in the PR drives the change through the streaming handler.
Both sensitive tests call the classification layer directly —
monitor_for_disconnects_with_timeout with a synthesized error stream, and
annotated_to_sse_event with a hand-built Annotated. The claim the PR makes, though, is
about what a live POST /v1/chat/completions with "stream": true emits and what
dynamo_frontend_requests_total records afterwards. One handler-level test with a mock
engine that fails after the first chunk, asserting both the SSE frame and the counter, would
close it.

Scheduling note, not an objection. #14382 and
#14395 are open and identical — same title,
refactor(errors): unify semantic failure handling, twelve files each — and both modify all
three files this PR touches, plus anthropic.rs and openai.rs. Whichever lands second
conflicts in the same region, and that refactor unifies exactly the ladders finding 1 is
about, so it is worth sequencing the two deliberately.

Requested changes

  1. disconnect.rs:222–233 — reorder the streaming ladder to match
    ErrorMessage::from_anyhow, or correct the comment to describe the intended
    streaming-specific precedence and the dropped queue-rejection arm.
  2. disconnect.rs:929–956 — delete test_mid_stream_unknown_backend_error_stays_sanitized.
    This does not orphan the two helpers the PR adds: error_frame (disconnect.rs:870) and
    typed_error_stream (disconnect.rs:882) are both still used by
    test_mid_stream_invalid_argument_reports_category_without_message.
  3. metrics.rs:2274–2288 — delete test_annotated_error_event_falls_back_to_comment;
    keep test_annotated_error_event_preserves_typed_error.
  4. Optional — resolve the ErrorType::Cancelled verdict-versus-sentinel overlap at
    disconnect.rs:229 and disconnect.rs:142.
  5. Optional — add a handler-level streaming test asserting the SSE frame and the
    dynamo_frontend_requests_total label together.

Everything else checks out. cargo check --workspace --all-targets,
cargo check --manifest-path lib/bindings/python/Cargo.toml --all-targets and
cargo clippy --workspace --all-targets --no-deps -- -D warnings all exit 0 on the merged
result, and cargo test -p dynamo-llm --lib reports 2633 passed; 0 failed; 5 ignored, so
nothing else in the crate regressed.

@nnshah1

nnshah1 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Cross-linking: I independently filed #14403 for this exact issue before finding this PR (my search terms didn't surface "classify backend errors on the streaming path"). Closing #14403 as a duplicate of this PR, which is the correct and more precisely-located fix.

For the record, #14403 carries production evidence for the impact, in case it's useful motivation here: cross-checking dynamo_frontend_requests_total{error_type="internal"} against the NVCF gateway's authoritative HTTP status (http_server_request_duration_seconds_count, true-5xx = 500/501 excl 502/503/504/529) on the prod fleet showed dynamo internal running 3.6×–22× the real gateway 5xx — e.g. nemotron-3-nano-omni: 801,623 internal vs 35,867 gateway true-5xx. Bidirectional (some models under-report), consistent with mid-stream failures being blanket-tagged internal.

Related umbrella: #12899 (couple HTTP errors with metric classification — Anthropic pre-stream handler).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants