feat: emit stable json errors
This commit is contained in:
parent
1f22139070
commit
675e3fb45d
4 changed files with 108 additions and 4 deletions
|
|
@ -1227,6 +1227,18 @@ pub struct EmptyArgs {}
|
||||||
|
|
||||||
pub async fn run() -> Result<()> {
|
pub async fn run() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
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 {
|
if let Command::Completions { shell } = &cli.command {
|
||||||
print_completions(*shell);
|
print_completions(*shell);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -1328,6 +1340,47 @@ pub async fn run() -> Result<()> {
|
||||||
Ok(())
|
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<()> {
|
fn print_guide(topic: Option<GuideTopic>, json: bool) -> Result<()> {
|
||||||
let (name, body) = match topic {
|
let (name, body) = match topic {
|
||||||
None => ("index", GUIDE_INDEX),
|
None => ("index", GUIDE_INDEX),
|
||||||
|
|
@ -3931,3 +3984,30 @@ fn shell_quote_command(command: &[String]) -> String {
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ")
|
.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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
#[test]
|
||||||
fn peer_ping_uses_daemon_owned_iroh_endpoint() {
|
fn peer_ping_uses_daemon_owned_iroh_endpoint() {
|
||||||
if skip_iroh_integration_tests() {
|
if skip_iroh_integration_tests() {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ explicit migration expectation before scripts depend on them.
|
||||||
top-level response variant name and existing field names keep their meaning
|
top-level response variant name and existing field names keep their meaning
|
||||||
within a major release.
|
within a major release.
|
||||||
|
|
||||||
|
Execution failures in `--json` or `--jsonl` mode return a stable JSON error
|
||||||
|
object on stdout with `type: "error"`, a machine-readable `code`, a human
|
||||||
|
`message`, full `detail`, and an optional `hint` derived from operator
|
||||||
|
recovery output. Common codes include `daemon_unavailable`, `unauthorized`,
|
||||||
|
`peer_not_found`, `resource_not_found`, `invalid_input`, and `command_failed`.
|
||||||
|
|
||||||
Backward-compatible JSON changes include:
|
Backward-compatible JSON changes include:
|
||||||
|
|
||||||
- Adding nullable or optional fields.
|
- Adding nullable or optional fields.
|
||||||
|
|
|
||||||
|
|
@ -236,11 +236,11 @@ Goal: make convergence and failure behavior predictable enough for automation.
|
||||||
|
|
||||||
Goal: make `geth` ergonomic and stable as a base layer for custom automation.
|
Goal: make `geth` ergonomic and stable as a base layer for custom automation.
|
||||||
|
|
||||||
- `[ ]` Stabilize JSON errors.
|
- `[x]` Stabilize JSON errors.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[ ]` Common failures include stable machine-readable error codes.
|
- `[x]` Common failures include stable machine-readable error codes.
|
||||||
- `[ ]` Human errors still include next-step recovery hints.
|
- `[x]` Human errors still include next-step recovery hints.
|
||||||
- `[ ]` Tests assert both code and operator-facing hint for representative
|
- `[x]` Tests assert both code and operator-facing hint for representative
|
||||||
failures.
|
failures.
|
||||||
|
|
||||||
- `[ ]` Add wait commands for automation.
|
- `[ ]` Add wait commands for automation.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue