From 8dcb8ebe7e544324e8d545e266103460d663d6b2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 4 May 2026 11:23:58 -0400 Subject: [PATCH] fix(server): preserve LLM error source chain in doctor output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fabro_llm::Error`'s Display only renders the top-level message field for `Network`/`Stream`/`Configuration`/`RequestTimeout` variants — the `#[source]` chain is dropped. Walk the chain at the rendering boundary so connectivity failures (DNS, connection refused, TLS) surface their underlying cause in `fabro doctor` output. Per docs/internal/error-handling-strategy.md, CLI surfaces should render the full cause chain. Adds a regression test that walks `err.source()` on a typed Network error with an inner io::Error. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-server/src/diagnostics.rs | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index 3770021b4..5d2dc8b77 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -121,11 +121,12 @@ async fn check_llm_providers(state: &AppState) -> CheckResult { match probe_result { Ok(Ok(())) => details.push(CheckDetail::new(format!("{provider}: OK"))), Ok(Err(err)) => { + let rendered = render_error_chain(&err); failures.push(ProviderFailure { name: provider.to_string(), - short: short_error_line(&err.to_string()), + short: short_error_line(&rendered), }); - details.push(CheckDetail::new(format!("{provider}: {err}"))); + details.push(CheckDetail::new(format!("{provider}: {rendered}"))); } Err(_) => { failures.push(ProviderFailure { @@ -188,6 +189,17 @@ fn short_error_line(rendered: &str) -> String { } } +fn render_error_chain(err: &dyn std::error::Error) -> String { + let mut out = err.to_string(); + let mut source = err.source(); + while let Some(cause) = source { + out.push_str(": "); + out.push_str(&cause.to_string()); + source = cause.source(); + } + out +} + fn probe_model(provider: Provider) -> String { Catalog::builtin() .probe_for_provider(provider) @@ -675,6 +687,23 @@ mod tests { assert_eq!(short_error_line(&input), expected); } + #[test] + fn render_error_chain_includes_underlying_cause_for_typed_llm_error() { + let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "tcp connect"); + let err = fabro_llm::Error::network("openai request failed", inner); + + let rendered = render_error_chain(&err); + + assert!( + rendered.contains("openai request failed"), + "top-level message missing: {rendered}" + ); + assert!( + rendered.contains("tcp connect"), + "underlying cause missing — chain not walked: {rendered}" + ); + } + #[test] fn short_error_line_returns_first_non_empty_trimmed_line() { let input = " first line \nsecond line";