Skip to content

fix(http): give POST /api/chat/stream a PrivacyProxy fallback and err… - #819

Open
dale053 wants to merge 4 commits into
GeniePod:mainfrom
dale053:fix/818-chat-stream-silent-failure
Open

fix(http): give POST /api/chat/stream a PrivacyProxy fallback and err…#819
dale053 wants to merge 4 commits into
GeniePod:mainfrom
dale053:fix/818-chat-stream-silent-failure

Conversation

@dale053

@dale053 dale053 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Gives POST /api/chat/stream a PrivacyProxy fallback and a real error event on backend failure.
Closes #818 — on any LLM backend failure, the client used to get HTTP 200 + a "start" event, then the connection silently closed with no signal at all.

Changes

  • crates/genie-core/src/server.rs: handle_chat_stream now takes privacy_proxy: Option<&PrivacyProxyConfig>, matching handle_chat's existing signature.
    Mirrors process_chat_turn's escalation logic for the streaming path:
    • Context overflow escalates to PrivacyProxy before ever attempting the local model, same as the blocking path.
    • A producer failure escalates to PrivacyProxy for a LocalDecline trigger, but only once nothing has been shown to the client yet (!state.emitted_text).
      Once partial text has streamed, mixing in an unrelated proxy response would be more confusing than a clean error.
    • A successful PrivacyProxy response is stashed into the existing StreamState.pending field.
      The unchanged tool-detection/finalization code downstream treats it exactly like buffered-but-unflushed stream output, so it correctly becomes a "replace" or "token" event with zero further special-casing.
    • If escalation isn't configured, isn't allowed for the trigger, or itself fails, the client now gets a {"type":"error","message":...} event before the connection closes.
  • Updated the one call site (the RequestRoute::ChatStream dispatch arm) to pass the already-in-scope privacy_proxy variable through.
  • 2 new tests, both driving a real ChatServer over a loopback TCP connection rather than asserting against internal state directly.

Why the disconnect path is unaffected

A client-initiated disconnect and a backend failure surface through the same tokio::select! block, but they're already distinguishable before my change: a disconnect only ever manifests as the consumer's write_stream_event hitting a broken pipe, and state_r? propagates that immediately, before any of the new escalation logic runs.
My changes only ever see llm_result when the consumer completed cleanly (no disconnect) and the producer either succeeded or hit a genuine backend error.
I traced this carefully rather than assuming it, since it's easy to get backwards.

Real Behavior Proof

  • I have built and run the affected code locally (or noted why I could not).
  • I have verified the change end-to-end on Jetson hardware.
  • I have NOT verified on Jetson hardware, and I explain the equivalent verification path or validation gap below.

Tested profile / hardware (check all that apply):

  • jetson
  • raspberry_pi
  • portable_sbc
  • laptop
  • mac
  • CI-only / docs-only
  • Not run locally

What I ran

x86_64 Linux dev machine:

cargo test -p genie-core --lib stream_backend_failure
cargo build --workspace --all-targets
cargo test --workspace --locked --all-targets
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo clippy -p genie-core -p genie-ctl --no-default-features --all-targets --locked -- -D warnings
cargo test -p genie-core -p genie-ctl --no-default-features --all-targets --locked
cargo fmt --all -- --check

What I observed

  • stream_backend_failure_without_privacy_proxy_writes_error_event: passes.
    Drives a real ChatServer wired to a MockLlmBackend with an empty reply queue (so the first call errors), opens a raw TCP connection, sends POST /api/chat/stream, and asserts the raw response bytes contain a {"type":"error",...} event.
    This is the same test I used to first empirically reproduce the bug before writing any fix — before my change it failed (no error event present); after, it passes.
  • stream_backend_failure_escalates_to_privacy_proxy: passes.
    Same setup, but with a [privacy_proxy] config pointing at a real mock HTTP server on a second loopback port.
    Asserts the client receives the proxy's response text and no error event.
    While writing this test I hit a real bug in my own mock server's response body (missing the role field the OpenAI-compatible parser requires) — caught by actually running the test and reading the failure, not by inspection.
  • Full workspace build: clean.
  • cargo test --workspace --locked --all-targets: every crate green.
    One unrelated test (genie-common's tegrastats::tests::mem_available_mb_async_matches_sync_version, which reads live system memory) failed once and passed on rerun in isolation — confirmed via git diff that genie-common has zero changes from this PR, so this is a pre-existing flake, not a regression.
  • cargo clippy --workspace -- -D warnings: clean across all crates.
  • --no-default-features clippy + test (genie-core, genie-ctl): clean and green, including both new tests.
  • cargo fmt --all -- --check: no diff.
  • This is pure HTTP request-handling logic with no hardware dependency.
    Both new tests exercise the real code path over a real TCP socket, not a hand-constructed unit-level shortcut.

Test plan

  • cargo test -p genie-core --lib stream_backend_failure — both new tests.
  • Manual: stop the local LLM backend (or point [services.llm].url at a dead port), send POST /api/chat/stream, confirm the response now includes a {"type":"error",...} event instead of silently closing.
  • Manual: additionally configure [privacy_proxy] pointing at a real PrivacyProxy instance, repeat the same request, confirm the client receives an escalated response instead of an error.
  • I could not run either of these manually against real infrastructure here — covered instead by the two new tests, which exercise the identical code path against real (mock) TCP servers.

Notes for reviewers

  • Scope call: escalation only fires if !state.emitted_text.
    If the model has already streamed some real text and then the connection to it drops mid-stream, this PR does not attempt to recover — it's still a silent-ish failure in that narrow case (no error event, since the code path that would write one only exists for the llm_result producer-error branch, not a mid-stream drop after some tokens already flowed).
    I judged that a client that's already seen partial text has enough context to not be totally confused by a stalled stream, and layering escalation on top of partial output risked worse UX than the status quo.
    Flagging in case a maintainer wants that case covered too.
  • Not attempted here: the same silent-failure shape could in principle affect other partial-write failure modes I didn't specifically construct a test for (e.g. a mid-generation timeout after several tokens).
    The fix's structure (check local_err, decide escalate-vs-error before returning) covers those the same way it covers immediate backend failure, but I only empirically verified the immediate-failure case since that's what [bug] POST /api/chat/stream sends "start" then silently closes on any LLM backend failure — no error event, no PrivacyProxy fallback #818 reported.

Summary by CodeRabbit

  • New Features

    • Added PrivacyProxy escalation support for streamed chats when context limits are exceeded.
    • Local backend failures can now automatically fall back to PrivacyProxy when configured.
  • Bug Fixes

    • Streaming chat errors are now clearly reported to clients instead of silently closing the connection.
    • When fallback succeeds, clients receive the proxy response without an unnecessary local error message.

…or event (GeniePod#818)

On any LLM backend failure, handle_chat_stream propagated the error
straight up through its Result<()> return: the caller just logged it
and returned Ok(()), so the client got HTTP 200 + a "start" event and
then the connection silently closed. No {"type":"error",...} event,
no {"type":"done",...} event, and (unlike POST /api/chat's
process_chat_turn) no PrivacyProxy escalation attempt at all - the
function didn't even take a privacy_proxy parameter.

Threads privacy_proxy: Option<&PrivacyProxyConfig> into
handle_chat_stream and mirrors process_chat_turn's escalation logic:

- Context overflow escalates to PrivacyProxy before ever attempting
  the local model, same as the blocking path.
- A producer failure (once nothing has been shown to the client yet)
  escalates to PrivacyProxy for a LocalDecline trigger; a
  PrivacyProxy response is stashed into the existing StreamState's
  `pending` field so the unchanged tool-detection/finalization code
  downstream treats it exactly like buffered-but-unflushed stream
  output, correctly turning it into a "replace" or "token" event.
- If escalation isn't configured, isn't allowed, or itself fails, the
  client now gets a {"type":"error","message":...} event before the
  connection closes, instead of nothing.

A disconnect-triggered cancellation (the client closing the
connection) is unaffected: `state_r?` already propagates that case
immediately, before any of this new logic runs, exactly as before.

Two new tests reproduce the original silent-failure bug and prove the
escalation path actually delivers a PrivacyProxy response to the
client (via a real mock proxy server, not just a passing assertion).
@github-actions github-actions Bot added the bug Something isn't working label Jul 20, 2026

@matedev01 matedev01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM — POST /api/chat/stream now gets a PrivacyProxy fallback and a real error event on backend failure, instead of a silent HTTP 200 + dead connection. Escalation logic carefully mirrors handle_chat's existing blocking-path behavior, and the PR description traces through why the client-disconnect path is unaffected. Verified: clippy -D warnings clean, server tests (46) incl. 2 new loopback-TCP tests pass, fmt clean.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The streaming chat endpoint now supports PrivacyProxy escalation for context overflow and local backend failures. It emits NDJSON error events when failures cannot be escalated, with integration tests covering both behaviors.

Changes

Streaming chat error handling

Layer / File(s) Summary
Streaming escalation wiring and pre-flight checks
crates/genie-core/src/server.rs
privacy_proxy is passed into handle_chat_stream, which computes request hints, estimates token usage, detects context overflow, and loads escalated responses into stream state.
Backend error events and fallback validation
crates/genie-core/src/server.rs
Streaming failures now emit NDJSON error events or use LocalDecline PrivacyProxy escalation when no text has been emitted; integration tests cover both outcomes.

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

Suggested reviewers: kiannidev, andriypolanski, ai-hpc

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is specific and matches the main change: adding PrivacyProxy fallback and error handling to /api/chat/stream.
Linked Issues check ✅ Passed The PR adds PrivacyProxy fallback, emits error events on backend failure, and covers the #818 streaming failure case with tests.
Out of Scope Changes check ✅ Passed Changes stay focused on the streaming chat failure path, fallback logic, and related tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/genie-core/src/server.rs (3)

1296-1311: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor duplication in the error-event write + return.

Both branches write the same {"type":"error","message": local_err.to_string()} event and return Err(local_err). Consider factoring this into a small local closure/helper to avoid the repeated pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/genie-core/src/server.rs` around lines 1296 - 1311, Factor the
duplicated error-event write and Err return in the surrounding server
request-handling logic into a small local helper or closure. Reuse it from both
branches while preserving the existing local_err message, awaited
write_stream_event call, and error propagation behavior.

1132-1193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the overflow-estimation heuristic into a shared helper.

The comment at Line 1134 states this logic "mirrors process_chat_turn (POST /api/chat)". Duplicating the token-estimate heuristic and the RESPONSE_RESERVE_TOKENS/JETSON_BASELINE_CONTEXT_TOKENS comparison inline here risks the two code paths silently diverging if one is tuned without the other.

♻️ Suggested extraction
fn estimate_context_overflow(messages: &[Message]) -> bool {
    let estimated_tokens: usize = messages.iter().map(|m| m.content.len() / 4).sum();
    estimated_tokens + crate::agent_harness::RESPONSE_RESERVE_TOKENS
        > crate::runtime_boundary::JETSON_BASELINE_CONTEXT_TOKENS as usize
}

Then call estimate_context_overflow(&messages) from both handle_chat and handle_chat_stream.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/genie-core/src/server.rs` around lines 1132 - 1193, Extract the
duplicated overflow heuristic into a shared estimate_context_overflow helper
accepting the messages slice and returning the existing boolean comparison using
RESPONSE_RESERVE_TOKENS and JETSON_BASELINE_CONTEXT_TOKENS. Replace the inline
calculation in the shown streaming handler and the corresponding logic in
process_chat_turn/handle_chat with calls to this helper, preserving the current
escalation behavior.

3903-4111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Solid coverage for the LocalDecline paths; consider adding a ContextOverflow test.

Both new tests exercise backend-failure escalation (LocalDecline), matching issue #818's two required scenarios well. The new pre-flight overflow-escalation branch (Lines 1132-1193) has no dedicated integration test in this file yet.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/genie-core/src/server.rs` around lines 3903 - 4111, Add a dedicated
integration test for the pre-flight context-overflow escalation branch near the
existing stream backend-failure tests. Exercise an oversized
conversation/request that triggers the ContextOverflow path, configure
PrivacyProxy, and assert the proxy response reaches the client; use the existing
ChatServer setup and stream response helper while keeping backend-failure
coverage unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/genie-core/src/server.rs`:
- Around line 1287-1290: In the LocalDecline escalation success branch, update
state.mode together with state.pending after receiving the proxy response,
resetting it to StreamMode::Undecided before returning r. Match the state reset
behavior used by the overflow-escalation branch so finalization can emit
non-tool escalated text incrementally.

---

Nitpick comments:
In `@crates/genie-core/src/server.rs`:
- Around line 1296-1311: Factor the duplicated error-event write and Err return
in the surrounding server request-handling logic into a small local helper or
closure. Reuse it from both branches while preserving the existing local_err
message, awaited write_stream_event call, and error propagation behavior.
- Around line 1132-1193: Extract the duplicated overflow heuristic into a shared
estimate_context_overflow helper accepting the messages slice and returning the
existing boolean comparison using RESPONSE_RESERVE_TOKENS and
JETSON_BASELINE_CONTEXT_TOKENS. Replace the inline calculation in the shown
streaming handler and the corresponding logic in process_chat_turn/handle_chat
with calls to this helper, preserving the current escalation behavior.
- Around line 3903-4111: Add a dedicated integration test for the pre-flight
context-overflow escalation branch near the existing stream backend-failure
tests. Exercise an oversized conversation/request that triggers the
ContextOverflow path, configure PrivacyProxy, and assert the proxy response
reaches the client; use the existing ChatServer setup and stream response helper
while keeping backend-failure coverage unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 94a9ca2a-1842-41ae-a50c-bf739614751f

📥 Commits

Reviewing files that changed from the base of the PR and between e8d3b93 and 6aeb430.

📒 Files selected for processing (1)
  • crates/genie-core/src/server.rs

Comment on lines +1287 to +1290
Ok(r) => {
state.pending = r.clone();
r
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset state.mode alongside state.pending on LocalDecline escalation success.

Unlike the overflow-escalation branch (Line 1182), this path overwrites state.pending with the proxy response but leaves state.mode untouched. If the interrupted local stream had already buffered JSON-looking content (state.mode == StreamMode::Tool) before failing, and the escalated response doesn't itself parse as a tool call, the finalization code's state.mode == StreamMode::Undecided check (Line 1345) will skip flushing the escalated text as a "token" event — it only reaches the client via the final "done" event, not incrementally.

🐛 Proposed fix
                     Ok(r) => {
                         state.pending = r.clone();
+                        state.mode = StreamMode::Undecided;
                         r
                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ok(r) => {
state.pending = r.clone();
r
}
Ok(r) => {
state.pending = r.clone();
state.mode = StreamMode::Undecided;
r
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/genie-core/src/server.rs` around lines 1287 - 1290, In the
LocalDecline escalation success branch, update state.mode together with
state.pending after receiving the proxy response, resetting it to
StreamMode::Undecided before returning r. Match the state reset behavior used by
the overflow-escalation branch so finalization can emit non-tool escalated text
incrementally.

@matedev01 matedev01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One more from CodeRabbit worth fixing before merge, even though it's Minor: the LocalDecline escalation branch sets state.pending = r.clone() but not state.mode, unlike the overflow-escalation branch just above it which resets both. If the interrupted local stream had already classified as StreamMode::Tool before the LocalDecline trigger fired, and the escalated response isn't itself a tool call, finalization's mode == Undecided check skips the incremental "token"" flush — the text still reaches the client, just only in the final "done"event instead of incrementally. One-line fix mirroring the sibling branch: addstate.mode = StreamMode::Undecided;alongsidestate.pending = r.clone();`.

Everything else here is careful and well-traced — nice work on the disconnect-path analysis.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] POST /api/chat/stream sends "start" then silently closes on any LLM backend failure — no error event, no PrivacyProxy fallback

2 participants