Files
Nicolò Boschi 737e5bf420 fix(cli): print the server's response body on API errors (#4049) (#4113)
`hindsight document get <bank> <missing-id>` reported "API endpoint not
found (404)" with endpoint-path/version guidance, while the server had
answered 404 `{"detail":"Document not found"}` — pointing the operator at
an API breakage instead of an absent document.

Two things were dropping the body:

- `api.rs` only ran three of ~70 generated-client calls through
  `humanize_client_error`; the rest used a bare `.await?`, so the error
  rendered via progenitor's `Display` as a body-less "Unexpected
  Response: Response { .. }" and the body was never read at all. Every
  call now goes through a `Humanized` extension trait on the call future
  (`call(..).humanized().await?`), which attaches status and body.
- `errors.rs` discarded the body in the 404/401/403/5xx branches.
  `server_detail` now pulls the JSON `detail` (or the raw body) out of
  the error and every branch surfaces it: a 404 that explains itself
  prints "Not found (404): Document not found", and only a bodyless 404
  — a genuine unknown route — keeps the path/version guidance.

Nothing in the type system stops the next call site from writing the
bare form, and no unit test exercises a real error response, so
`tests/api_error_surface.rs` checks the whole family: every statement in
`api.rs` that awaits a generated-client call must end in `.humanized()`.
2026-09-04 11:17:27 +02:00

75 lines
3.0 KiB
Rust

//! Structural guard: every generated-client call must surface the HTTP
//! response body.
//!
//! A bare `self.client.foo(..).await?` converts progenitor's `Error` into
//! `anyhow::Error` through its `Display`, which for the common
//! `UnexpectedResponse` case is "Unexpected Response: Response { .. }" — the
//! body is never read, so the CLI cannot print what the server actually said
//! and falls back to generic guidance ("API endpoint not found (404)" for a
//! plain "Document not found"; see issue #4049). `.humanized()` reads the body
//! into the error instead.
//!
//! Nothing in the type system stops the next call site from writing the bare
//! form — it compiles and only misbehaves against a real error response, which
//! no unit test exercises. So the family is checked here, over the whole file,
//! rather than one call at a time.
/// Statement boundary for the crude scan below: `api.rs` writes one client call
/// per statement, so everything since the last `;`/`{`/`}` is the call
/// expression.
fn statement_before(source: &str, offset: usize) -> &str {
let start = [';', '{', '}']
.iter()
.filter_map(|delimiter| source[..offset].rfind(*delimiter))
.max()
.unwrap_or(0);
&source[start..offset]
}
#[test]
fn every_generated_client_call_surfaces_the_response_body() {
let source = include_str!("../src/api.rs");
let mut bare_calls = Vec::new();
for (offset, _) in source.match_indices(".await?") {
let statement = statement_before(source, offset);
let compact: String = statement.chars().filter(|c| !c.is_whitespace()).collect();
// `http_client` is the hand-rolled reqwest path (multipart upload,
// template import); it reads the body itself and formats its own error.
if !compact.contains("self.client") || compact.contains("http_client") {
continue;
}
if !compact.contains(".humanized()") {
let line = source[..offset].lines().count();
let call = statement
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim_start_matches(['{', '}', ';'])
.trim()
.to_string();
bare_calls.push(format!(" src/api.rs:{}: {}", line, call));
}
}
assert!(
bare_calls.is_empty(),
"generated-client calls that swallow the server's response body — \
end them with `.humanized().await?` instead of `.await?`:\n{}",
bare_calls.join("\n")
);
}
#[test]
fn the_guard_notices_a_bare_call() {
// Guards the guard: a scan that silently matches nothing would pass the
// test above forever.
let bare = "let response = self\n.client\n.get_document(bank_id, document_id, None)\n.await?;";
let statement = statement_before(bare, bare.find(".await?").unwrap());
let compact: String = statement.chars().filter(|c| !c.is_whitespace()).collect();
assert!(compact.contains("self.client"));
assert!(!compact.contains(".humanized()"));
}