From 47eea187e7d8031611fc3a8a24e8918d4cd81a4d Mon Sep 17 00:00:00 2001 From: r266-tech Date: Tue, 20 Oct 2026 03:33:45 +0800 Subject: [PATCH] fix(cli): preserve underlying connection error causes --- crates/ov_cli/src/error.rs | 9 +- crates/ov_cli/src/error_ui.rs | 152 +++++++++++++++-------- crates/ov_cli/tests/connection_errors.rs | 53 ++++++++ 3 files changed, 164 insertions(+), 50 deletions(-) create mode 100644 crates/ov_cli/tests/connection_errors.rs diff --git a/crates/ov_cli/src/error.rs b/crates/ov_cli/src/error.rs index d449d60a33..6b7ae9b5ac 100644 --- a/crates/ov_cli/src/error.rs +++ b/crates/ov_cli/src/error.rs @@ -92,7 +92,14 @@ impl Error { } else if error.is_builder() { Self::Client(format!("{context}: {error}")) } else { - Self::Network(format!("{context}: {error}")) + let mut message = format!("{context}: {error}"); + let mut source = std::error::Error::source(&error); + while let Some(cause) = source { + message.push_str(": "); + message.push_str(&cause.to_string()); + source = cause.source(); + } + Self::Network(message) } } diff --git a/crates/ov_cli/src/error_ui.rs b/crates/ov_cli/src/error_ui.rs index 45db152909..4e213f3749 100644 --- a/crates/ov_cli/src/error_ui.rs +++ b/crates/ov_cli/src/error_ui.rs @@ -244,15 +244,22 @@ pub(crate) fn report_for_runtime_error(command: impl Into, error: &Error "ov config", copy(language, "Create a config", "创建配置"), )]), - Error::Config(message) => ErrorReport::new(copy(language, "Configuration Error", "配置错误"), message) - .with_command(command) - .with_actions(config_error_actions(message, language)), - Error::Language(message) => ErrorReport::new(copy(language, "Language Error", "语言错误"), message) - .with_command(command) - .with_actions(vec![ - ErrorAction::new("ov language en", copy(language, "Use English", "使用英文")), - ErrorAction::new("ov language zh-CN", copy(language, "Use Simplified Chinese", "使用简体中文")), - ]), + Error::Config(message) => { + ErrorReport::new(copy(language, "Configuration Error", "配置错误"), message) + .with_command(command) + .with_actions(config_error_actions(message, language)) + } + Error::Language(message) => { + ErrorReport::new(copy(language, "Language Error", "语言错误"), message) + .with_command(command) + .with_actions(vec![ + ErrorAction::new("ov language en", copy(language, "Use English", "使用英文")), + ErrorAction::new( + "ov language zh-CN", + copy(language, "Use Simplified Chinese", "使用简体中文"), + ), + ]) + } Error::Timeout(message) => ErrorReport::new( copy(language, "Request Timeout", "请求超时"), copy( @@ -264,24 +271,37 @@ pub(crate) fn report_for_runtime_error(command: impl Into, error: &Error .with_command(command) .with_detail(message) .with_actions(vec![ - ErrorAction::new("ov config", copy(language, "Increase the request timeout", "提高请求超时时间")), - ErrorAction::new("ov config show", copy(language, "Show the active config", "查看当前配置")), - ]), - 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。", + ErrorAction::new( + "ov config", + copy(language, "Increase the request timeout", "提高请求超时时间"), + ), + ErrorAction::new( + "ov config show", + copy(language, "Show the active config", "查看当前配置"), ), - ) - .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", "快速检查服务器健康状态")), - ErrorAction::new("ov config switch", copy(language, "Switch to another config", "切换到其他配置")), ]), + Error::Network(message) => { + ErrorReport::new(copy(language, "Connection Error", "连接错误"), message) + .with_command(command) + .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", + "快速检查服务器健康状态", + ), + ), + ErrorAction::new( + "ov config switch", + copy(language, "Switch to another config", "切换到其他配置"), + ), + ]) + } Error::Api { message, details, .. } if error.code() == "REFRESH_FAILED" => { @@ -320,10 +340,18 @@ pub(crate) fn report_for_runtime_error(command: impl Into, error: &Error ) .with_command(command) .with_actions(vec![ - ErrorAction::new("ov config", copy(language, "Edit this config", "编辑这个配置")), - ErrorAction::new("ov config switch", copy(language, "Use another config", "使用其他配置")), + ErrorAction::new( + "ov config", + copy(language, "Edit this config", "编辑这个配置"), + ), + ErrorAction::new( + "ov config switch", + copy(language, "Use another config", "使用其他配置"), + ), ]), - Error::Api { message, details, .. } => { + Error::Api { + message, details, .. + } => { let mut report = ErrorReport::new( copy(language, "OpenViking API Error", "OpenViking API 错误"), api_error_message(error.code(), message), @@ -338,34 +366,59 @@ pub(crate) fn report_for_runtime_error(command: impl Into, error: &Error copy(language, "Command Error", "命令错误"), sentence_case_error(message), ) - .with_command(command.clone()) - .with_actions(contextual_help_actions(&command, language)), + .with_command(command.clone()) + .with_actions(contextual_help_actions(&command, language)), Error::Parse(message) => ErrorReport::new( copy(language, "Parse Error", "解析错误"), sentence_case_error(message), ) - .with_command(command.clone()) - .with_actions(contextual_help_actions(&command, language)), - Error::Output(message) => ErrorReport::new(copy(language, "Output Error", "输出错误"), message).with_command(command), + .with_command(command.clone()) + .with_actions(contextual_help_actions(&command, language)), + Error::Output(message) => { + ErrorReport::new(copy(language, "Output Error", "输出错误"), message) + .with_command(command) + } Error::InvalidPath(message) => ErrorReport::new( copy(language, "Invalid Path", "路径无效"), sentence_case_error(message), ) - .with_command(command.clone()) - .with_actions(contextual_help_actions(&command, language)), - Error::Io(error) => ErrorReport::new(copy(language, "IO Error", "IO 错误"), copy(language, "OpenViking could not read or write a file.", "OpenViking 无法读取或写入文件。")) - .with_command(command) - .with_detail(error.to_string()), - Error::Serialization(error) => { - ErrorReport::new(copy(language, "Serialization Error", "序列化错误"), copy(language, "OpenViking could not parse structured data.", "OpenViking 无法解析结构化数据。")) - .with_command(command) - .with_detail(error.to_string()) - } - Error::Zip(error) => ErrorReport::new(copy(language, "Archive Error", "压缩包错误"), copy(language, "OpenViking could not process the archive.", "OpenViking 无法处理压缩包。")) - .with_command(command) - .with_detail(error.to_string()), - Error::AlreadyReported => ErrorReport::new(copy(language, "Command Error", "命令错误"), copy(language, "The command failed.", "命令执行失败。")) - .with_command(command), + .with_command(command.clone()) + .with_actions(contextual_help_actions(&command, language)), + Error::Io(error) => ErrorReport::new( + copy(language, "IO Error", "IO 错误"), + copy( + language, + "OpenViking could not read or write a file.", + "OpenViking 无法读取或写入文件。", + ), + ) + .with_command(command) + .with_detail(error.to_string()), + Error::Serialization(error) => ErrorReport::new( + copy(language, "Serialization Error", "序列化错误"), + copy( + language, + "OpenViking could not parse structured data.", + "OpenViking 无法解析结构化数据。", + ), + ) + .with_command(command) + .with_detail(error.to_string()), + Error::Zip(error) => ErrorReport::new( + copy(language, "Archive Error", "压缩包错误"), + copy( + language, + "OpenViking could not process the archive.", + "OpenViking 无法处理压缩包。", + ), + ) + .with_command(command) + .with_detail(error.to_string()), + Error::AlreadyReported => ErrorReport::new( + copy(language, "Command Error", "命令错误"), + copy(language, "The command failed.", "命令执行失败。"), + ) + .with_command(command), } } @@ -1179,7 +1232,8 @@ Usage: ov config [OPTIONS] [COMMAND] let rendered = strip_ansi(&render_report(&report, false)); assert!(rendered.contains("Connection Error")); - assert!(rendered.contains("Could not reach OpenViking")); + assert!(rendered.contains("connection refused")); + assert!(!rendered.contains("server may be offline")); assert!(rendered.contains("ov config validate")); assert!(rendered.contains("ov health")); assert!(rendered.contains("ov config switch")); diff --git a/crates/ov_cli/tests/connection_errors.rs b/crates/ov_cli/tests/connection_errors.rs new file mode 100644 index 0000000000..a8a29c42ac --- /dev/null +++ b/crates/ov_cli/tests/connection_errors.rs @@ -0,0 +1,53 @@ +use std::{ + io::{Read, Write}, + process::{Command, Stdio}, +}; + +#[test] +fn connection_errors_include_the_underlying_cause() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = [0; 4096]; + assert!(stream.read(&mut request).unwrap() > 0); + stream.write_all(b"not HTTP\r\n\r\n").unwrap(); + } + }); + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("ovcli.conf"); + std::fs::write(&config, serde_json::json!({"url": url}).to_string()).unwrap(); + for format in ["json", "table"] { + let result = Command::new(env!("CARGO_BIN_EXE_ov")) + .current_dir(dir.path()) + .env("OPENVIKING_CLI_CONFIG_FILE", &config) + .env("OPENVIKING_LANG", "en") + .env("NO_PROXY", "127.0.0.1") + .env("no_proxy", "127.0.0.1") + .env("NO_COLOR", "1") + .stdin(Stdio::null()) + .args(["-o", format, "health"]) + .output() + .unwrap(); + assert!(!result.status.success(), "{result:?}"); + let text = String::from_utf8_lossy(&result.stderr); + let message = text + .replace('│', " ") + .split_whitespace() + .collect::>() + .join(" "); + assert!(message.contains("invalid HTTP version"), "{text}"); + if format == "json" { + let data: serde_json::Value = serde_json::from_slice(&result.stderr).unwrap(); + assert_eq!(data["error"]["code"], "UNAVAILABLE"); + } else { + assert!(text.contains("Connection Error"), "{text}"); + assert!(!text.contains("server may be offline"), "{text}"); + } + } + server.join().unwrap(); +}