Surface stream failures instead of truncating the answer - #1007
Conversation
The stream-error log discarded the error text it already held, and neither path recorded a request id or a duration, so none of the roughly 500 records a day could be joined to another log line or say what actually failed. Both paths now carry request_id and error_detail, and the interrupted-stream path also carries total_duration_ms and ms_since_last_token.
The other outcome logs in record_usage_and_metrics reported without a request id, leaving half the stream outcomes unjoinable. They now carry it, and the three that report a completed stream also carry total_duration_ms.
A provider copies its upstream message verbatim into HttpError, so an in-stream failure could put a client URL into an error log, and no line carried both the chat id and the request id, so a failed signature lookup could not be traced to its request. Error text is now redacted at every stream-failure site, the text-completion site gains the fields it lacked, and both chat mapping sites log the forwarded request id.
A silent upstream closed the stream with no application error, so a truncated answer looked identical to a complete one. InterceptStream now fails a stream that produces nothing for its idle budget, using a longer bound before the first token because a large context can prefill for minutes. The watchdog is off unless STREAM_WATCHDOG_ENABLED is set.
Review · Status🟩 CompletedIronLoop completed the review and posted it to GitHub. ResultRun detailsAutomatic trigger · attempt 1 of 3 · completed in 5m 41s |
There was a problem hiding this comment.
Review · Summary
Found four issues in the watchdog and stream-failure logging paths.
Findings: 🔴 High 1 · 🟠 Medium 3
Code-specific findings are attached to the diff.
Validation
- ✅ Patch hygiene — No whitespace errors were found in the proposed change.
Review details
- Run:
9c2a7450-fd01-41f2-b063-c11bfaa54e44 - Attempts: 1
| } | ||
|
|
||
| fn sanitized_stream_error(e: &inference_providers::CompletionError) -> String { | ||
| services::inference_provider_pool::InferenceProviderPool::sanitize_error_message(&e.to_string()) |
There was a problem hiding this comment.
🔴 High · Redact arbitrary upstream error text before logging
This sanitizer only removes HTTP(S) URLs and IPv4 addresses. An upstream SSE error frame can carry arbitrary provider-supplied text, including echoed customer input, and the parser preserves that text in CompletionError::HttpError; it will therefore reach the newly added error logs unchanged. Log an allowlisted error category or fully redact provider-supplied message bodies.
| ttft_ms: None, | ||
| token_count: 0, | ||
| last_token_time: None, | ||
| idle_timeouts: self.stream_idle_timeouts, |
There was a problem hiding this comment.
🟠 Medium · Arm the first-token timeout before provider stream peeking
The interceptor is constructed only after chat_completion_stream_with_attribution returns, but that path awaits the first stream item while peeking for a chat ID (and the NearAI provider also peeks). If the upstream has sent HTTP headers but never sends its first SSE event, this call waits indefinitely and the configured first-token watchdog is never armed. Apply the deadline around the pre-peek path or wrap the stream before any peeking.
| }; | ||
| self.idle_armed = false; | ||
| self.last_error = Some(timeout.clone()); | ||
| return Poll::Ready(Some(Err(timeout))); |
There was a problem hiding this comment.
🟠 Medium · Terminate the stream after synthesizing a timeout
Returning the timeout error leaves the interceptor in Streaming. The routes convert an error to an SSE frame and continue polling, so a permanently pending upstream is re-armed and emits another timeout every budget instead of reaching EOF and [DONE]. The underlying request and its concurrent-request slot remain live until the client disconnects. Transition to a terminal state when emitting this synthetic timeout.
| %organization_id, | ||
| model = %model_for_err, | ||
| error_type = %completion_stream_error_category(&e), | ||
| %error_detail, |
There was a problem hiding this comment.
🟠 Medium · Include duration for failures after usage has arrived
This added route log has the request ID and error detail but no duration. NearAI requests continuous usage stats, so after its first chunk both usage and chat ID are set; a later provider error follows the billing branch in record_usage_and_metrics, which does not emit either of the new interruption logs. Those common partial-stream failures therefore cannot be correlated with total stream duration. Emit the failure fields whenever last_error is present, before branching on usage, or add duration here.
lloydmak99
left a comment
There was a problem hiding this comment.
Surfaces stream failures instead of silently truncating: adds sanitized error logging (always-on) plus an idle-timeout watchdog gated behind STREAM_WATCHDOG_ENABLED, which defaults off. The only always-on behavior change is the extra logging; the watchdog is inert at runtime until enabled, so this is safe to merge. A few things worth addressing before the watchdog is switched on:
crates/services/src/completions/mod.rs:627— after a synthesized timeout the stream stays inStreamingand re-arms, so it re-emits a timeout every budget rather than terminating (route atcrates/api/src/routes/completions.rs:1851maps theErrto an SSE frame and keeps polling). Transition to a terminal state after the one timeout. Non-blocking (watchdog off by default).crates/services/src/completions/mod.rs:528-530— non-token passthrough frames (keepalives/pings) return without resetting the idle timer, so a keepalive-only stream could be falsely timed out once the watchdog is enabled. Non-blocking.crates/api/src/routes/completions.rs:323—sanitize_error_messageonly strips URLs/IPv4, so other echoed provider text still reaches logs; consider logging an allowlisted category/safe fields rather than raw error text. Non-blocking; consistent with the existing privacy approach.
Checks: cargo fmt --all -- --check passed. Could not run cargo check/tests here (no C linker in the sandbox); verified statically that config field wiring, CompletionError::Timeout fields, visibility changes, and all ApiConfig construction sites are correct. Author reports clippy clean and 1411 lib + 703 e2e tests passing.
lloydmak99
left a comment
There was a problem hiding this comment.
The watchdog logic is sound and, critically, gated off by default (STREAM_WATCHDOG_ENABLED=false), so merging changes no production streaming behavior until an operator opts in. Timer arming/re-arming is correct (a delivered token disarms and the next Pending re-arms with a fresh deadline), the emitted CompletionError::Timeout is an existing variant already handled by the route's error categorizer, and the new logging fields are IDs/numerics with provider error text routed through the existing sanitize_error_message.
Optional, non-blocking follow-ups:
crates/services/src/completions/mod.rs:528— SSE control/keepalive events (event.chunk.is_none()) return early without clearingidle_armed, so a prefill that emits only keepalives can still hit the first-token timeout despite being alive. Appears intentional (wall-clock idle measures token progress, not connection liveness) and is behind the disabled flag — just confirm this matches intent before enabling.crates/services/src/completions/mod.rs:197—error_detaillogs sanitized provider error text, which only strips URLs/IPs; non-URL customer content in an upstream error string could reach a warn log. Consistent with existing SSE-frame handling, not a regression; tighten the allowlist only if desired.
Note the PR currently has merge conflicts and needs a rebase before merging.
Checks run locally: cargo fmt --check and git diff --check clean; cargo check (offline) passes for config, services, and api; cargo test -p config -- stream_watchdog (4 passed, covering zero/>3600/bound-ordering validation); cargo test -p services -- completions passes including the stalled-stream, slow-prefill, unwatched-stream, and interrupted-stream/redaction tests. Clippy passed; full integration/e2e suites not run (require DB/vLLM).
HTTP failures now report only their status, since the SSE parser copies an upstream message verbatim and it can echo customer input. The watchdog also ends the stream after one timeout instead of re-arming, deadlines the provider call, reports duration on failures that reach the billing path, and lets keepalives clear the idle timer.
|
Pushed fixes for all of these, plus the keepalive one from the review.
Two I left alone. The provider deadline has no unit test, it needs the pool mocked. And CompletionError(String) is capped rather than fully redacted, most sites wrap our own text but the anthropic adapter passes the provider's through and you can't tell them apart at runtime. Also merged main in, the only conflict was the chat_id peek that #952 restructured. |
Summary
request_id, duration and a safe error detail on every stream failurerequest_idon the remaining stream outcomes and joinchat_idto iterror.messageverbatim and it can echo customer inputCovers the P0 logging and P1 watchdog in #982.
Rollout
STREAM_WATCHDOG_ENABLEDdefaults to false, so merging changes no streamingbehaviour until an operator sets it; the logging and redaction are always on.
The 300s and 90s bounds come from partner data measuring total stream duration
rather than inter-token gaps, so they want a look before it is enabled.
Rollback is unsetting the variable.
CompletionError(String)is bounded rather than fully redacted, because mostsites wrap our own text but the Anthropic adapter passes a provider message
through and the two cannot be told apart at runtime.
Verification
cargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warningscargo test --lib --bins(1463 passed)cargo test --test e2e_all(714 passed)The three
database_encryptione2e failures arrived with #968, use fixedfixture ids on the shared test database, and pass when run serially.