Skip to content

fix(runtime): carry typed pre-stream errors on QUIC response plane (#14750) - #2

Open
Prudctual wants to merge 191 commits into
mainfrom
cursor/quic-typed-prestream-errors-12f2
Open

Prudctual wants to merge 191 commits into
mainfrom
cursor/quic-typed-prestream-errors-12f2

Conversation

@Prudctual

@Prudctual Prudctual commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Overview:

QUIC (DYN_RESPONSE_PLANE=quic) could not carry a worker's ErrorType on a pre-stream refusal. The frontend therefore answered HTTP 500 and fault detection quarantined the worker that had correctly refused the request. TCP already maps the same refusal to 400 once the typed prologue exists; this PR extends that path to QUIC with a mixed-version Error-frame encoding.

Details:

QuicResponseSender sent FrameKind::Error as raw UTF-8, so ResponsePublisher::send_prologue_typed had nowhere to put a DynamoError. The requesting side re-synthesized ErrorType::CannotConnect with no cause, which the frontend maps to a sanitized 500 and is_inhibited treats as a worker fault.

This PR:

  • Adds StreamPrologueError and an optional typed_error on ResponseStreamPrologue (skipped when absent so an old sender still handshakes).
  • Encodes a typed QUIC error as JSON ResponseStreamPrologue. Untyped errors stay raw UTF-8. Receivers accept both: JSON with a present error field recovers the type; anything else, including a Python {"message","code"} envelope, is the legacy message. Protocol version stays 3 — a bump would force a lockstep upgrade for a payload the old decoder already accepts as UTF-8.
  • Implements send_prologue_typed on both QUIC and TCP so the worker's type survives whichever response plane is configured.
  • Attaches a non-migration-sensitive typed error as the cause of the existing outer CannotConnect, so HTTP classification sees InvalidArgument while retry classification of the outer type is unchanged.
  • Excludes InvalidArgument / Backend(InvalidArgument) from is_inhibited, so a clean client refusal does not quarantine the worker. An untyped pre-stream failure still does.

TCP untyped behaviour is unchanged: an old worker that sends only display text still yields 500 and is still treated as a worker fault.

Public API: StreamProvider<T> widens its error from String to StreamPrologueError. StreamPrologueError derefs to str, so err.contains(..), err.len(), and &err[..] keep compiling. This overlaps the runtime plumbing in ai-dynamo#14467; this PR is self-contained against current main so QUIC can land without waiting on that review. If ai-dynamo#14467 merges first, this rebases onto the shared types.

Where should the reviewer start?

  • lib/runtime/src/pipeline/network/quic_response.rs — encode_error_payload / decode_error_payload and send_prologue_typed
  • lib/runtime/src/pipeline/network.rs — ResponseStreamPrologue.typed_error and StreamPrologueError
  • lib/runtime/src/pipeline/network/egress/addressed_router.rs — pre_stream_failure_error
  • lib/runtime/src/pipeline/network/egress/push_router.rs — is_inhibited client-refusal exclude

Validation

  • cargo fmt --all -- --check
  • cargo clippy -p dynamo-runtime --lib --all-targets -- -D warnings
  • cargo test -p dynamo-runtime --lib pipeline::network::quic_response::tests — 26 passed (includes typed Error-frame round trip, live send_prologue_typed → InvalidArgument, and legacy UTF-8)
  • cargo test -p dynamo-runtime --lib pipeline::network::tcp::server::tests — 40 passed (TCP typed prologue survives; untyped/invalid-prologue paths unchanged)
  • cargo test -p dynamo-llm --lib test_pre_stream_refusal_surfaces_as_400 test_pre_stream_connect_failure_still_surfaces_as_500 — typed pre-stream refusal → HTTP 400 with the worker's reason; untyped / engine-shutdown still 500
  • typed_client_refusal_does_not_quarantine_the_worker — CannotConnect + InvalidArgument cause is not inhibited; untyped CannotConnect still is

Related Issues

🔗 This PR is linked to an issue:

AI disclosure

This change was drafted with assistance from Cursor Grok 4.6. I reviewed the wire-format compatibility, the quarantine/is_inhibited interaction, and the test results before opening the PR.

Open in Web Open in Cursor 

Summary by cubic

Carries the worker's typed error on the QUIC response plane so a clean client refusal gets HTTP 400 instead of HTTP 500 and is no longer treated as a worker fault. Untyped pre-stream failures still surface as HTTP 500 and quarantine the worker.

Note: this branch is far behind main and has not been rebased — the diff carries ~150 unrelated commits. Rebase before merging; only the QUIC typed-error work below belongs to this PR.

  • Typed QUIC errors travel as JSON tagged with dynamo.quic.typed_error; anything untagged (legacy UTF-8, Python {"message","code"} envelopes, decoy {"error","code"} objects) stays raw UTF-8 so it can't be misparsed as a prologue. Protocol version stays 3.
  • Client refusals (InvalidArgument / Backend(InvalidArgument)) are excluded from is_inhibited, and NON_MIGRATABLE is pinned to the same set so an attached cause never changes migration behavior.

Migration

  • StreamProvider<T>'s error type widens from String to StreamPrologueError; it derefs to str, so .contains(), .len(), and indexing keep compiling.

Written for commit cfbd80c. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/runtime/src/pipeline/network/egress/push_router.rs">

<violation number="1" location="lib/runtime/src/pipeline/network/egress/push_router.rs:64">
P2: The `CLIENT_REFUSAL` exclude set makes `is_inhibited` return `false` if *any* link in the chain is `InvalidArgument`, not just the outer pre-stream refusal. `is_inhibited` is also applied to mid-stream errors at lines 1948 and 1978, so any genuine worker fault (e.g. `CannotConnect`, `StreamIncomplete`, `ResponseTimeout`) whose cause chain happens to contain an `InvalidArgument` anywhere will now escape quarantine and keep being reselected. The pre-stream typed-refusal case is intended, but this suppresses inhibition for the whole chain regardless of the true outermost fault.</violation>
</file>

<file name="lib/runtime/src/pipeline/network/ingress/push_handler.rs">

<violation number="1" location="lib/runtime/src/pipeline/network/ingress/push_handler.rs:766">
P2: When the failure has no worker `DynamoError`, this always supplies `Some(ErrorType::Unknown)`, causing QUIC to emit typed JSON instead of the legacy raw UTF-8 error. Return an optional typed error and use `StreamPrologueError::from_message` when no worker error was recovered.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

let _ = publisher.send_prologue(Some(error_string)).await;
let prologue_error = StreamPrologueError::new(
error_string,
typed_error_from_pipeline_error(&error),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When the failure has no worker DynamoError, this always supplies Some(ErrorType::Unknown), causing QUIC to emit typed JSON instead of the legacy raw UTF-8 error. Return an optional typed error and use StreamPrologueError::from_message when no worker error was recovered.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/runtime/src/pipeline/network/ingress/push_handler.rs, line 766:

<comment>When the failure has no worker `DynamoError`, this always supplies `Some(ErrorType::Unknown)`, causing QUIC to emit typed JSON instead of the legacy raw UTF-8 error. Return an optional typed error and use `StreamPrologueError::from_message` when no worker error was recovered.</comment>

<file context>
@@ -729,7 +761,11 @@ where
-                    let _ = publisher.send_prologue(Some(error_string)).await;
+                    let prologue_error = StreamPrologueError::new(
+                        error_string,
+                        typed_error_from_pipeline_error(&error),
+                    );
+                    let _ = publisher.send_prologue_typed(Some(prologue_error)).await;
</file context>

ErrorType::InvalidArgument,
ErrorType::Backend(BackendError::InvalidArgument),
];
match_error_chain(err, INHIBITED, CLIENT_REFUSAL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The CLIENT_REFUSAL exclude set makes is_inhibited return false if any link in the chain is InvalidArgument, not just the outer pre-stream refusal. is_inhibited is also applied to mid-stream errors at lines 1948 and 1978, so any genuine worker fault (e.g. CannotConnect, StreamIncomplete, ResponseTimeout) whose cause chain happens to contain an InvalidArgument anywhere will now escape quarantine and keep being reselected. The pre-stream typed-refusal case is intended, but this suppresses inhibition for the whole chain regardless of the true outermost fault.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/runtime/src/pipeline/network/egress/push_router.rs, line 64:

<comment>The `CLIENT_REFUSAL` exclude set makes `is_inhibited` return `false` if *any* link in the chain is `InvalidArgument`, not just the outer pre-stream refusal. `is_inhibited` is also applied to mid-stream errors at lines 1948 and 1978, so any genuine worker fault (e.g. `CannotConnect`, `StreamIncomplete`, `ResponseTimeout`) whose cause chain happens to contain an `InvalidArgument` anywhere will now escape quarantine and keep being reselected. The pre-stream typed-refusal case is intended, but this suppresses inhibition for the whole chain regardless of the true outermost fault.</comment>

<file context>
@@ -54,7 +54,14 @@ fn is_inhibited(err: &(dyn std::error::Error + 'static)) -> bool {
+        ErrorType::InvalidArgument,
+        ErrorType::Backend(BackendError::InvalidArgument),
+    ];
+    match_error_chain(err, INHIBITED, CLIENT_REFUSAL)
 }
 
</file context>

Signed-off-by: Biswa Panda <biswa.panda@gmail.com>
@cursor
cursor Bot deployed to external_collaborator September 15, 2026 12:14 Active

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/runtime/src/pipeline/network/quic_response.rs">

<violation number="1" location="lib/runtime/src/pipeline/network/quic_response.rs:300">
P2: An untyped JSON message containing the reserved `kind` and `error` fields is misclassified as the tagged envelope, even without a typed error. Require `typed_error` to be present before taking this branch so raw untyped messages remain byte-for-byte unchanged.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

fn decode_error_payload(payload: &[u8]) -> Result<StreamPrologueError> {
if payload.first() == Some(&b'{')
&& let Ok(wire) = serde_json::from_slice::<QuicTypedErrorWire>(payload)
&& wire.kind == QUIC_TYPED_ERROR_KIND

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: An untyped JSON message containing the reserved kind and error fields is misclassified as the tagged envelope, even without a typed error. Require typed_error to be present before taking this branch so raw untyped messages remain byte-for-byte unchanged.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/runtime/src/pipeline/network/quic_response.rs, line 300:

<comment>An untyped JSON message containing the reserved `kind` and `error` fields is misclassified as the tagged envelope, even without a typed error. Require `typed_error` to be present before taking this branch so raw untyped messages remain byte-for-byte unchanged.</comment>

<file context>
@@ -244,46 +244,65 @@ impl Frame {
-        && let Ok(prologue) = serde_json::from_slice::<ResponseStreamPrologue>(payload)
-        && let Some(message) = prologue.error
+        && let Ok(wire) = serde_json::from_slice::<QuicTypedErrorWire>(payload)
+        && wire.kind == QUIC_TYPED_ERROR_KIND
+        && let Some(message) = wire.prologue.error
     {
</file context>
Suggested change
&& wire.kind == QUIC_TYPED_ERROR_KIND
&& wire.kind == QUIC_TYPED_ERROR_KIND
&& wire.prologue.typed_error.is_some()

dynamo-ops and others added 25 commits September 15, 2026 12:19
Automated refresh from the public Dynamo Google Calendar.

Generated by .github/workflows/community-events-refresh.yml.

Signed-off-by: dynamo-ops <170655669+dynamo-ops@users.noreply.github.com>
…lease 1.5.0 (ai-dynamo#14840)

Signed-off-by: Biswa Panda <biswa.panda@gmail.com>
…i-dynamo#14635)

Signed-off-by: Codex <codex@openai.com>
Signed-off-by: vanshil shah <49670736+vanshilshah97@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
…14855)

Signed-off-by: Julien Mancuso <jmancuso@nvidia.com>
…amo#14449)

Signed-off-by: svc-glamr@nvidia.com <svc-glamr@nvidia.com>
Signed-off-by: William Arnold <7565007+Aphoh@users.noreply.github.com>
… supports them (ai-dynamo#14835)

Signed-off-by: Vinya567 <vinyak@nvidia.com>
…ai-dynamo#14563)

Signed-off-by: nnshah1 <neelays@nvidia.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Dmitry Tokarev <dtokarev@nvidia.com>
…dynamo#14364)

Signed-off-by: svc-glamr@nvidia.com <svc-glamr@nvidia.com>
Signed-off-by: GLAMR <svc-glamr@nvidia.com>
Co-authored-by: Dan Gil <dagil@nvidia.com>
Co-authored-by: yunzhoul-nv <232973175+yunzhoul-nv@users.noreply.github.com>
…mo#14797)

Signed-off-by: svc-glamr@nvidia.com <svc-glamr@nvidia.com>
Signed-off-by: Schwinn Saereesitthipitak <schwinns@nvidia.com>
Signed-off-by: GLAMR <svc-glamr@nvidia.com>
Co-authored-by: Schwinn Saereesitthipitak <schwinns@nvidia.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
…ENV (ai-dynamo#14865)

Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
…es (ai-dynamo#14455)

Signed-off-by: svc-glamr@nvidia.com <svc-glamr@nvidia.com>
Signed-off-by: Biswa Panda <biswa.panda@gmail.com>
Co-authored-by: Biswa Panda <biswa.panda@gmail.com>
Co-authored-by: yunzhoul-nv <232973175+yunzhoul-nv@users.noreply.github.com>
Add live-reloaded file sources for KV DC Relay namespace selection and expose readiness and source revisions through /engine/state.

Preserve applied membership on invalid updates, coalesce discovery refreshes, and isolate native integration tests in forked processes.

Signed-off-by: Nikita Sukharev <kaonael@gmail.com>
Signed-off-by: William Arnold <7565007+Aphoh@users.noreply.github.com>
Co-authored-by: Zero Rains <57100978+zeroRains@users.noreply.github.com>
…d recipes for B200 (ai-dynamo#14376)

Signed-off-by: Sandhya Rani Narravula <snarravula@nvidia.com>
…LM (ai-dynamo#14428)

Signed-off-by: Karen Chung <karenc@nvidia.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
ishandhanani and others added 14 commits September 23, 2026 10:58
Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Automated refresh from the public Dynamo Google Calendar.

Generated by .github/workflows/community-events-refresh.yml.

Signed-off-by: dynamo-ops <170655669+dynamo-ops@users.noreply.github.com>
Signed-off-by: J Wyman <jwyman@nvidia.com>
Signed-off-by: furionw <qiwa@nvidia.com>
…-dynamo#14676)

Signed-off-by: Dao007forever <daole@inferact.ai>
Signed-off-by: krishung5 <krish@nvidia.com>
Signed-off-by: furionw <qiwa@nvidia.com>
Co-authored-by: Dao007forever <daole@inferact.ai>
Co-authored-by: krishung5 <krish@nvidia.com>
Co-authored-by: furionw <qiwa@nvidia.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ai-dynamo#15090)

Signed-off-by: svc-glamr@nvidia.com <svc-glamr@nvidia.com>
Signed-off-by: GLAMR <svc-glamr@nvidia.com>
Signed-off-by: Ryan McCormick <rmccormick@nvidia.com>
…i-dynamo#15237)

Signed-off-by: Kan Wu <wukanustc@gmail.com>
Co-authored-by: Ryan McCormick <rmccormick@nvidia.com>
Signed-off-by: dynamo-ops <170655669+dynamo-ops@users.noreply.github.com>
Signed-off-by: Dmitry Tokarev <dtokarev@nvidia.com>
Signed-off-by: tanmayv25 <tanmay2592@gmail.com>
Co-authored-by: Dmitry Tokarev <dtokarev@nvidia.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: tanmayv25 <tanmay2592@gmail.com>
Signed-off-by: Ace Eldeib <aeldeib@coreweave.com>
Signed-off-by: thuanpham582002 <tienthuan05082002@gmail.com>
Co-authored-by: thuanpham582002 <tienthuan05082002@gmail.com>
…i-dynamo#14181)

Signed-off-by: Jorge Antonio <matroid@outlook.com>
Co-authored-by: Ryan McCormick <rmccormick@nvidia.com>
QUIC Error frames were still raw UTF-8, so a worker ErrorType was
dropped. Tag typed payloads with dynamo.quic.typed_error and leave
untagged JSON as the legacy message.

Co-authored-by: Prudctual <Prudctual@users.noreply.github.com>
Signed-off-by: Jasim Kareem <219012065+Prudctual@users.noreply.github.com>
@cursor
cursor Bot force-pushed the cursor/quic-typed-prestream-errors-12f2 branch from edbee96 to cfbd80c Compare September 24, 2026 05:48
@cursor
cursor Bot deployed to external_collaborator September 24, 2026 05:48 Active

This branch was successfully deployed

1 active deployment
external_collaborator — cfbd80c3 Deployed Sep 24, 2026 by cursor[bot] via ok-to-test #12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

QUIC response plane cannot carry typed pre-stream errors, so refusals regress to 500