fix(server): preserve LLM error source chain in doctor output

`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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-04 11:23:58 -04:00
parent 558e585985
commit 8dcb8ebe7e
No known key found for this signature in database

View file

@ -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";