feat: emit stable json errors

This commit is contained in:
Eric Wendland 2026-07-05 18:30:44 +02:00
commit 675e3fb45d
4 changed files with 108 additions and 4 deletions

View file

@ -1227,6 +1227,18 @@ pub struct EmptyArgs {}
pub async fn run() -> Result<()> {
let cli = Cli::parse();
let json = cli.json || cli.jsonl;
match run_inner(cli).await {
Ok(()) => Ok(()),
Err(error) if json => {
print_json_error(&error)?;
std::process::exit(1);
}
Err(error) => Err(error),
}
}
async fn run_inner(cli: Cli) -> Result<()> {
if let Command::Completions { shell } = &cli.command {
print_completions(*shell);
return Ok(());
@ -1328,6 +1340,47 @@ pub async fn run() -> Result<()> {
Ok(())
}
fn print_json_error(error: &anyhow::Error) -> Result<()> {
let message = error.to_string();
let detail = format!("{error:#}");
let hint = json_error_hint(&detail);
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "error",
"code": json_error_code(&detail),
"message": message,
"detail": detail,
"hint": hint,
}))?
);
Ok(())
}
fn json_error_code(detail: &str) -> &'static str {
let lower = detail.to_ascii_lowercase();
if lower.contains("connect to daemon") || lower.contains("connection refused") {
"daemon_unavailable"
} else if lower.contains("unauthorized") || lower.contains("missing grant") {
"unauthorized"
} else if lower.contains("peer candidate not found") {
"peer_not_found"
} else if lower.contains("resource not found") {
"resource_not_found"
} else if lower.contains("invalid") {
"invalid_input"
} else {
"command_failed"
}
}
fn json_error_hint(detail: &str) -> Option<String> {
detail
.lines()
.find_map(|line| line.strip_prefix("next: "))
.map(ToOwned::to_owned)
}
fn print_guide(topic: Option<GuideTopic>, json: bool) -> Result<()> {
let (name, body) = match topic {
None => ("index", GUIDE_INDEX),
@ -3931,3 +3984,30 @@ fn shell_quote_command(command: &[String]) -> String {
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_error_classification_is_stable_for_common_failures() {
assert_eq!(
json_error_code("connect to daemon at /tmp/geth.sock: No such file or directory"),
"daemon_unavailable"
);
assert_eq!(
json_error_code("unauthorized: missing grant"),
"unauthorized"
);
assert_eq!(
json_error_code("peer candidate not found: node:missing"),
"peer_not_found"
);
assert_eq!(
json_error_hint(
"peer candidate not found: node:missing\nnext: import a peer card with `geth peer import <path>`",
),
Some("import a peer card with `geth peer import <path>`".to_owned())
);
}
}

View file

@ -344,6 +344,24 @@ fn stable_json_output_matches_fixtures() {
);
}
#[test]
fn json_errors_include_stable_code_for_common_failures() {
let home = tempfile::tempdir().expect("tempdir");
let output = run_geth(home.path(), &["--json", "status"]);
assert!(!output.status.success());
let error = serde_json::from_slice::<serde_json::Value>(&output.stdout)
.expect("decode json error output");
assert_eq!(error["type"], "error");
assert_eq!(error["code"], "daemon_unavailable");
assert!(
error["message"]
.as_str()
.expect("error message")
.contains("connect to daemon")
);
}
#[test]
fn peer_ping_uses_daemon_owned_iroh_endpoint() {
if skip_iroh_integration_tests() {