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
75 changes: 66 additions & 9 deletions src/client/sdk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,19 @@ impl ApiError {

/// A printable, single-line description of the failure.
///
/// Used where the error is surfaced inline (e.g. folded into a query
/// `warning`) rather than printed-and-exited via [`exit`](Self::exit).
/// Used where the error is surfaced inline (folded into a query `warning`,
/// the `create --attach` warning) rather than printed-and-exited via
/// [`exit`](Self::exit). Carries the status so a non-JSON body still says
/// what happened, and runs the body through [`server_explanation`] so an
/// error envelope renders as the sentence inside it, never as raw JSON.
/// The exit path ([`format_fail_message`]) shares that base and adds the
/// credential-probe hints on top, which need a live `Api` this does not
/// have.
pub fn message(&self) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the PR description describes a different change (not blocking).

Update the description before merge.

The description says the PR adds ApiError::user_message and leaves message() alone. This commit changes message() itself, which is a wider change: every caller now sees the unwrapped text, including fail_query and attach_connection. The description also lists a test named failed_attach_reports_the_servers_sentence_not_its_envelope, which the diff does not contain. The description becomes the squash-merge commit body, so the permanent record names a method and a test that do not exist.

match self {
ApiError::Status { status, body } => format!("{status}: {body}"),
ApiError::Status { status, body } => {
format!("{status}: {}", server_explanation(*status, body))
}
ApiError::Transport(msg) => msg.clone(),
}
}
Expand Down Expand Up @@ -1040,6 +1048,21 @@ impl Api {
}
}

/// What the server said, as one line a person can read.
///
/// An error envelope (`{"error":{"message":…}}`, and the other shapes
/// `util::api_error` knows) renders as the sentence inside it; a bodyless
/// response says so instead of printing a blank. This is the one place that
/// unwrapping lives — both `ApiError::message` (inline) and
/// `format_fail_message` (fatal) build on it.
fn server_explanation(status: reqwest::StatusCode, body: &str) -> String {
if body.trim().is_empty() {
format!("HTTP {status} (empty response body)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a bodyless response prints the status twice in inline warnings (not blocking).

Fix: return only empty response body here, and let format_fail_message build error: HTTP {status} (empty response body) from the status it already has.

ApiError::message prefixes {status}: and this branch prefixes HTTP {status} again. A bodyless 502 reaches the user through src/commands/databases.rs:1981 as warning: could not attach 'src': 502 Bad Gateway: HTTP 502 Bad Gateway (empty response body). The assertion at src/client/sdk.rs:1141 pins that duplication.

} else {
util::api_error(body.to_string())
}
}

/// Decide what error text to print for a failed response. Pure function so the
/// re-auth-hint heuristic is unit-testable without HTTP or `exit`.
///
Expand All @@ -1055,12 +1078,13 @@ pub fn format_fail_message(
body: &str,
auth_status: Option<&credentials::AuthStatus>,
) -> String {
// Base: the server's own explanation, or the status line when there is none.
let mut msg = if body.trim().is_empty() {
format!("error: HTTP {status} (empty response body)")
} else {
util::api_error(body.to_string())
};
// Base: the server's own explanation, or the status line when there is
// none. Shared with `ApiError::message` so the inline and the fatal
// rendering of one response never drift apart.
let mut msg = server_explanation(status, body);
if body.trim().is_empty() {
msg = format!("error: {msg}");
}
// A 403 ACCESS_DENIED is the allow-list guard rejecting an operation the
// credential can't perform — typically a database API token (which is
// limited to create/query/upload) hitting a workspace-level endpoint. Keep
Expand Down Expand Up @@ -1089,6 +1113,39 @@ mod tests {
use super::*;
use credentials::AuthStatus;

/// An inline warning is read by a person, so the envelope must be gone.
/// `create --attach` used to print `409 Conflict: {"error":{"message":…}}`.
#[test]
fn api_error_message_unwraps_the_error_envelope() {
let err = ApiError::Status {
status: reqwest::StatusCode::CONFLICT,
body: r#"{"error":{"message":"this catalog is scoped to another database and cannot be attached here","code":"CONFLICT"}}"#.to_string(),
};
assert_eq!(
err.message(),
"409 Conflict: this catalog is scoped to another database and cannot be attached here"
);
}

/// A bodyless response names the status instead of printing a blank, and
/// says the same thing the fatal path says (minus its `error:` prefix).
#[test]
fn api_error_message_and_fail_message_agree_on_an_empty_body() {
let status = reqwest::StatusCode::BAD_GATEWAY;
let err = ApiError::Status {
status,
body: String::new(),
};
assert_eq!(
err.message(),
"502 Bad Gateway: HTTP 502 Bad Gateway (empty response body)"
);
assert_eq!(
format_fail_message(status, "", None),
"error: HTTP 502 Bad Gateway (empty response body)"
);
}

#[test]
fn api_error_message_formats_status_and_transport() {
let status = ApiError::Status {
Expand Down
8 changes: 7 additions & 1 deletion src/commands/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1708,7 +1708,9 @@ mod tests {
"arrow".into(),
))
.with_status(500)
.with_body("boom")
// An error envelope, the shape the server actually sends: the
// warning must carry the sentence inside it, not the JSON.
.with_body(r#"{"error":{"message":"result res_1 has expired","code":"NOT_FOUND"}}"#)
.create();

let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), Some("db-1"));
Expand Down Expand Up @@ -1738,6 +1740,10 @@ mod tests {
assert_eq!(resolved.total_row_count, None);
let warning = resolved.warning.as_deref().unwrap_or("");
assert!(warning.contains("truncated"), "warning: {warning:?}");
assert!(
warning.contains("result res_1 has expired") && !warning.contains("{\"error\""),
"the warning should quote the server's sentence, not its envelope: {warning}"
);
assert!(
warning.contains("could not fetch full result"),
"warning: {warning:?}"
Expand Down
Loading