Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 73 additions & 4 deletions crates/ov_cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))
}
}

Expand All @@ -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<u16>) -> &'static str {
match status {
Some(400 | 422) => "INVALID_ARGUMENT",
Expand All @@ -131,3 +147,56 @@ fn code_from_http_status(status: Option<u16>) -> &'static str {
}

pub type Result<T> = std::result::Result<T, Error>;

#[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);
}
}
33 changes: 28 additions & 5 deletions crates/ov_cli/src/error_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,14 +269,17 @@ pub(crate) fn report_for_runtime_error(command: impl Into<String>, 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", "快速检查服务器健康状态")),
Expand Down Expand Up @@ -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::<Vec<_>>()
.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());
Expand Down