Skip to content
Open
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
81 changes: 72 additions & 9 deletions crates/clickhouse-cloud-api/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,32 @@ fn derive_query_host(base_url: &str) -> Option<String> {
Some(format!("{}://queries.{}{}", parsed.scheme(), rest, port))
}

fn query_api_error_message(status: reqwest::StatusCode, body: &str) -> String {
let sql_error = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|value| {
let error = value.get("error")?;
let code = match error.get("code")? {
serde_json::Value::String(code) if !code.is_empty() => code.clone(),
serde_json::Value::Number(code) => code.to_string(),
_ => return None,
};
let details = error.get("details")?.as_str()?;
if details.is_empty() {
return None;
}
Some(format!("SQL error {code}: {details}"))
});

sql_error.unwrap_or_else(|| {
if body.is_empty() {
format!("Query API returned HTTP {status} with an empty response body")
} else {
format!("Query API returned HTTP {status}: {body}")
}
})
}

impl Client {
/// Create a new client with the default base URL (`https://api.clickhouse.cloud`).
pub fn new(key_id: impl Into<String>, key_secret: impl Into<String>) -> Self {
Expand Down Expand Up @@ -319,7 +345,12 @@ impl Client {
// wake confirmation to wake it and run the query), `Service is
// stopped` for one that must be started explicitly.
if status.as_u16() == 206 {
let body_text = response.text().await.unwrap_or_default();
let body_text = response.text().await.map_err(|error| Error::Api {
status: status.as_u16(),
message: format!(
"Query API returned HTTP {status}, but its response body could not be read: {error}"
),
})?;
#[derive(serde::Deserialize)]
struct StateBody {
data: Option<String>,
Expand All @@ -332,19 +363,20 @@ impl Client {
Some("Service is stopped") => Error::ServiceStopped,
_ => Error::Api {
status: 206,
message: body_text,
message: query_api_error_message(status, &body_text),
},
});
}
if !status.is_success() {
let body_text = response.text().await.unwrap_or_default();
let body_text = response.text().await.map_err(|error| Error::Api {
status: status.as_u16(),
message: format!(
"Query API returned HTTP {status}, but its response body could not be read: {error}"
),
})?;
return Err(Error::Api {
status: status.as_u16(),
message: if body_text.is_empty() {
format!("Query API returned {status}")
} else {
body_text
},
message: query_api_error_message(status, &body_text),
});
}

Expand Down Expand Up @@ -3999,7 +4031,7 @@ impl Client {

#[cfg(test)]
mod tests {
use super::derive_query_host;
use super::{derive_query_host, query_api_error_message};

#[test]
fn derive_query_host_prod() {
Expand Down Expand Up @@ -4057,4 +4089,35 @@ mod tests {
Some("https://queries.clickhouse.cloud")
);
}

#[test]
fn query_api_error_extracts_documented_sql_error() {
let body = r#"{"error":{"code":"62","details":"Syntax error","extra":"ignored"}}"#;
assert_eq!(
query_api_error_message(reqwest::StatusCode::BAD_REQUEST, body),
"SQL error 62: Syntax error"
);
}

#[test]
fn query_api_error_accepts_numeric_codes() {
let body = r#"{"error":{"code":241,"details":"Memory limit exceeded"}}"#;
assert_eq!(
query_api_error_message(reqwest::StatusCode::INTERNAL_SERVER_ERROR, body),
"SQL error 241: Memory limit exceeded"
);
}

#[test]
fn query_api_error_preserves_status_and_unrecognized_body() {
let malformed = r#"{"error":{"code":"62","details":"truncated"#;
assert_eq!(
query_api_error_message(reqwest::StatusCode::BAD_REQUEST, malformed),
format!("Query API returned HTTP 400 Bad Request: {malformed}")
);
assert_eq!(
query_api_error_message(reqwest::StatusCode::BAD_GATEWAY, "upstream failed"),
"Query API returned HTTP 502 Bad Gateway: upstream failed"
);
}
}
54 changes: 52 additions & 2 deletions crates/clickhouse-cloud-api/tests/run_query_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,10 @@ async fn run_query_206_unrecognized_body_maps_to_api_error() {
match err {
Error::Api { status, message } => {
assert_eq!(status, 206);
assert_eq!(message, r#"{"data":"Something new"}"#);
assert_eq!(
message,
r#"Query API returned HTTP 206 Partial Content: {"data":"Something new"}"#
);
}
other => panic!("expected Error::Api, got: {other:?}"),
}
Expand All @@ -212,7 +215,54 @@ async fn run_query_non_success_status_maps_to_api_error() {
match err {
Error::Api { status, message } => {
assert_eq!(status, 404);
assert_eq!(message, "query endpoint not found");
assert_eq!(
message,
"Query API returned HTTP 404 Not Found: query endpoint not found"
);
}
other => panic!("expected Error::Api, got: {other:?}"),
}
}

#[tokio::test]
async fn run_query_formats_documented_sql_error_envelope() {
let mock = start_mock_query_host(
400,
r#"{"error":{"code":"62","details":"Syntax error near FROM"}}"#,
)
.await;
let client = Client::with_bearer_token(mock.uri(), "oauth-token").with_query_host(mock.uri());

let err = client
.run_query_bearer("svc-1", "SELECT broken FROM", None, "CSV", false)
.await
.expect_err("expected Api error");
match err {
Error::Api { status, message } => {
assert_eq!(status, 400);
assert_eq!(message, "SQL error 62: Syntax error near FROM");
}
other => panic!("expected Error::Api, got: {other:?}"),
}
}

#[tokio::test]
async fn run_query_preserves_malformed_json_error_with_status() {
let body = r#"{"error":{"code":"62","details":"truncated"#;
let mock = start_mock_query_host(400, body).await;
let client = Client::with_bearer_token(mock.uri(), "oauth-token").with_query_host(mock.uri());

let err = client
.run_query_bearer("svc-1", "SELECT broken FROM", None, "CSV", false)
.await
.expect_err("expected Api error");
match err {
Error::Api { status, message } => {
assert_eq!(status, 400);
assert_eq!(
message,
format!("Query API returned HTTP 400 Bad Request: {body}")
);
}
other => panic!("expected Error::Api, got: {other:?}"),
}
Expand Down
Loading