From 6b403288ab026caff38140b4c9195a3318124c5b Mon Sep 17 00:00:00 2001 From: rjdotdev <191971597+rjdotdev@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:30:20 +0530 Subject: [PATCH] fix(ov_cli): show the underlying cause of connection errors reqwest's Display drops the source() chain. from_reqwest now appends it so JSON and table output name causes like invalid peer certificate: UnknownIssuer. The Connection Error card shows the cause by default instead of only guessing offline or wrong URL. Fixes #6288 --- crates/ov_cli/src/error.rs | 77 +++++++++++++++++++++++++++++++++-- crates/ov_cli/src/error_ui.rs | 33 ++++++++++++--- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/crates/ov_cli/src/error.rs b/crates/ov_cli/src/error.rs index d449d60a33..4fe8918c0c 100644 --- a/crates/ov_cli/src/error.rs +++ b/crates/ov_cli/src/error.rs @@ -85,14 +85,15 @@ impl Error { } pub(crate) fn from_reqwest(context: &str, error: reqwest::Error) -> Self { + let detail = with_sources(&error); if error.is_timeout() { - Self::Timeout(format!("{context}: request timed out: {error}")) + Self::Timeout(format!("{context}: request timed out: {detail}")) } else if error.is_decode() { - Self::Parse(format!("{context}: {error}")) + Self::Parse(format!("{context}: {detail}")) } else if error.is_builder() { - Self::Client(format!("{context}: {error}")) + Self::Client(format!("{context}: {detail}")) } else { - Self::Network(format!("{context}: {error}")) + Self::Network(format!("{context}: {detail}")) } } @@ -115,6 +116,21 @@ impl Error { } } +/// reqwest 0.12 / hyper 1.x: Display omits the cause, which lives in source(). +fn with_sources(error: &dyn std::error::Error) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let text = cause.to_string(); + if !message.ends_with(&text) { + message.push_str(": "); + message.push_str(&text); + } + source = cause.source(); + } + message +} + fn code_from_http_status(status: Option) -> &'static str { match status { Some(400 | 422) => "INVALID_ARGUMENT", @@ -131,3 +147,56 @@ fn code_from_http_status(status: Option) -> &'static str { } pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error as _; + + #[test] + fn source_chain_format_avoids_duplicate_suffixes() { + #[derive(Debug, thiserror::Error)] + #[error("{message}")] + struct ChainedError { + message: &'static str, + #[source] + cause: std::io::Error, + } + for message in ["outer", "outer: inner"] { + let error = ChainedError { + message, + cause: std::io::Error::other("inner"), + }; + assert_eq!(with_sources(&error), "outer: inner"); + } + } + + #[tokio::test] + async fn from_reqwest_reports_the_underlying_cause() { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let error = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get(format!("http://127.0.0.1:{port}/health")) + .send() + .await + .unwrap_err(); + let mut root_cause = String::new(); + let mut source = error.source(); + while let Some(cause) = source { + root_cause = cause.to_string(); + source = cause.source(); + } + assert!(!root_cause.is_empty()); + + let message = Error::from_reqwest("HTTP request failed", error).to_string(); + + assert!(message.contains(&root_cause), "{message}"); + assert_eq!(message.matches(root_cause.as_str()).count(), 1); + } +} diff --git a/crates/ov_cli/src/error_ui.rs b/crates/ov_cli/src/error_ui.rs index 45db152909..ab95f3d617 100644 --- a/crates/ov_cli/src/error_ui.rs +++ b/crates/ov_cli/src/error_ui.rs @@ -269,14 +269,17 @@ pub(crate) fn report_for_runtime_error(command: impl Into, error: &Error ]), Error::Network(message) => ErrorReport::new( copy(language, "Connection Error", "连接错误"), - copy( - language, - "Could not reach OpenViking. The server may be offline, or this config points to the wrong URL.", - "无法连接 OpenViking。服务器可能未启动,或当前配置指向了错误的 URL。", + format!( + "{} {}", + copy( + language, + "Could not reach OpenViking. Check the detail first, then whether the server is running and the URL is correct.", + "无法连接 OpenViking。请先查看详情,再确认服务器是否运行、URL 是否正确。" + ), + detail_line(language, message), ), ) .with_command(command) - .with_detail(message) .with_actions(vec![ ErrorAction::new("ov config validate", copy(language, "Check the active config", "检查当前配置")), ErrorAction::new("ov health", copy(language, "Run a quick server health check", "快速检查服务器健康状态")), @@ -1180,11 +1183,31 @@ Usage: ov config [OPTIONS] [COMMAND] assert!(rendered.contains("Connection Error")); assert!(rendered.contains("Could not reach OpenViking")); + assert!(rendered.contains("connection refused")); assert!(rendered.contains("ov config validate")); assert!(rendered.contains("ov health")); assert!(rendered.contains("ov config switch")); } + #[test] + fn network_error_card_shows_tls_cause_without_guessing_offline() { + let error = Error::Network("invalid peer certificate: UnknownIssuer".to_string()); + let report = report_for_runtime_error("ov health", &error); + let rendered = strip_ansi(&render_report(&report, false)); + let text = rendered + .replace('│', " ") + .split_whitespace() + .collect::>() + .join(" "); + + assert!( + text.contains("invalid peer certificate: UnknownIssuer"), + "{rendered}" + ); + assert!(!text.contains("offline")); + assert!(!text.contains("wrong URL")); + } + #[test] fn language_error_points_to_language_commands() { let error = Error::Language("Unsupported language 'fr'. Use 'en' or 'zh-CN'.".to_string());