From 0e3f55c8a4e369535083bc47ab76ecdb0ad183e8 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Mon, 13 Apr 2026 10:07:28 -0700 Subject: [PATCH 1/2] add health check to db connection --- Cargo.lock | 14 ++--- src/connections.rs | 130 ++++++++++++++++++++++++++++++++++++----- src/connections_new.rs | 44 +++++++++++++- src/query.rs | 8 +-- src/util.rs | 13 +++++ 5 files changed, 181 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 450b996..d64ce0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -83,7 +83,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -248,7 +248,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -420,7 +420,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -483,7 +483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1697,7 +1697,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2080,7 +2080,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/src/connections.rs b/src/connections.rs index ba8e43b..3e3817c 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -1,6 +1,37 @@ use crate::api::ApiClient; use serde::{Deserialize, Serialize}; +#[derive(Deserialize, Serialize)] +struct HealthResponse { + #[allow(dead_code)] + connection_id: String, + healthy: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + latency_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, +} + +fn fetch_health(api: &ApiClient, connection_id: &str, show_spinner: bool) -> HealthResponse { + let spinner = show_spinner.then(|| crate::util::spinner("Checking connection health...")); + let health: HealthResponse = api.get(&format!("/connections/{connection_id}/health")); + if let Some(s) = spinner { s.finish_and_clear(); } + health +} + +fn format_health(health: &HealthResponse) -> String { + use crossterm::style::Stylize; + if health.healthy { + match health.latency_ms { + Some(ms) => format!("{} {}", "healthy".green(), format!("({ms}ms)").dark_grey()), + None => "healthy".green().to_string(), + } + } else { + let err = health.error.as_deref().unwrap_or("unknown error"); + format!("{} — {}", "unhealthy".red(), err) + } +} + #[derive(Deserialize, Serialize)] struct ConnectionType { name: String, @@ -88,11 +119,37 @@ struct ListResponse { pub fn get(workspace_id: &str, connection_id: &str, format: &str) { let api = ApiClient::new(Some(workspace_id)); + let is_table = format == "table"; + + let spinner = is_table.then(|| crate::util::spinner("Fetching connection...")); let detail: ConnectionDetail = api.get(&format!("/connections/{connection_id}")); + if let Some(s) = spinner { s.finish_and_clear(); } + + let health = fetch_health(&api, connection_id, is_table); match format { - "json" => println!("{}", serde_json::to_string_pretty(&detail).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(&detail).unwrap()), + "json" => { + let combined = serde_json::json!({ + "id": detail.id, + "name": detail.name, + "source_type": detail.source_type, + "table_count": detail.table_count, + "synced_table_count": detail.synced_table_count, + "health": &health, + }); + println!("{}", serde_json::to_string_pretty(&combined).unwrap()); + } + "yaml" => { + let combined = serde_json::json!({ + "id": detail.id, + "name": detail.name, + "source_type": detail.source_type, + "table_count": detail.table_count, + "synced_table_count": detail.synced_table_count, + "health": &health, + }); + print!("{}", serde_yaml::to_string(&combined).unwrap()); + } "table" => { use crossterm::style::Stylize; let label = |l: &str| format!("{:<16}", l).dark_grey().to_string(); @@ -100,11 +157,22 @@ pub fn get(workspace_id: &str, connection_id: &str, format: &str) { println!("{}{}", label("name:"), detail.name.white()); println!("{}{}", label("source_type:"), detail.source_type.green()); println!("{}{}", label("tables:"), format!("{} synced / {} total", detail.synced_table_count.to_string().cyan(), detail.table_count.to_string().cyan())); + println!("{}{}", label("health:"), format_health(&health)); } _ => unreachable!(), } } +#[derive(Deserialize, Serialize)] +struct CreateResponse { + id: String, + name: String, + source_type: String, + tables_discovered: u64, + discovery_status: String, + discovery_error: Option, +} + pub fn create( workspace_id: &str, name: &str, @@ -127,22 +195,53 @@ pub fn create( }); let api = ApiClient::new(Some(workspace_id)); + let is_table = format == "table"; - #[derive(Deserialize, Serialize)] - struct CreateResponse { - id: String, - name: String, - source_type: String, - tables_discovered: u64, - discovery_status: String, - discovery_error: Option, + let spinner = is_table.then(|| crate::util::spinner("Creating connection...")); + let (status, resp_body) = api.post_raw("/connections", &body); + if let Some(s) = &spinner { s.finish_and_clear(); } + + if !status.is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp_body).red()); + std::process::exit(1); } - let result: CreateResponse = api.post("/connections", &body); + let result: CreateResponse = match serde_json::from_str(&resp_body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + let health = fetch_health(&api, &result.id, is_table); match format { - "json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()), + "json" => { + let combined = serde_json::json!({ + "id": result.id, + "name": result.name, + "source_type": result.source_type, + "tables_discovered": result.tables_discovered, + "discovery_status": result.discovery_status, + "discovery_error": result.discovery_error, + "health": &health, + }); + println!("{}", serde_json::to_string_pretty(&combined).unwrap()); + } + "yaml" => { + let combined = serde_json::json!({ + "id": result.id, + "name": result.name, + "source_type": result.source_type, + "tables_discovered": result.tables_discovered, + "discovery_status": result.discovery_status, + "discovery_error": result.discovery_error, + "health": &health, + }); + print!("{}", serde_yaml::to_string(&combined).unwrap()); + } "table" => { use crossterm::style::Stylize; println!("{}", "Connection created".green()); @@ -156,9 +255,14 @@ pub fn create( _ => result.discovery_status.yellow().to_string(), }; println!("discovery_status: {status_colored}"); + println!("health: {}", format_health(&health)); } _ => unreachable!(), } + + if !health.healthy { + std::process::exit(1); + } } pub fn list(workspace_id: &str, format: &str) { diff --git a/src/connections_new.rs b/src/connections_new.rs index c0b9b34..fd5d75a 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -268,9 +268,37 @@ pub fn run(workspace_id: &str) { discovery_error: Option, } - let result: CreateResponse = api.post("/connections", &body); + #[derive(serde::Deserialize)] + struct HealthResponse { + healthy: bool, + #[serde(default)] + latency_ms: Option, + #[serde(default)] + error: Option, + } + + let create_spinner = crate::util::spinner("Creating connection..."); + let (status_code, resp_body) = api.post_raw("/connections", &body); + create_spinner.finish_and_clear(); use crossterm::style::Stylize; + if !status_code.is_success() { + eprintln!("{}", crate::util::api_error(resp_body).red()); + std::process::exit(1); + } + + let result: CreateResponse = match serde_json::from_str(&resp_body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + let health_spinner = crate::util::spinner("Checking connection health..."); + let health: HealthResponse = api.get(&format!("/connections/{}/health", result.id)); + health_spinner.finish_and_clear(); + println!("{}", "Connection created".green()); println!("id: {}", result.id); println!("name: {}", result.name); @@ -282,4 +310,18 @@ pub fn run(workspace_id: &str) { _ => result.discovery_status.yellow().to_string(), }; println!("discovery_status: {status}"); + let health_str = if health.healthy { + match health.latency_ms { + Some(ms) => format!("{} {}", "healthy".green(), format!("({ms}ms)").dark_grey()), + None => "healthy".green().to_string(), + } + } else { + let err = health.error.as_deref().unwrap_or("unknown error"); + format!("{} — {}", "unhealthy".red(), err) + }; + println!("health: {health_str}"); + + if !health.healthy { + std::process::exit(1); + } } diff --git a/src/query.rs b/src/query.rs index 9c4f609..ccadea4 100644 --- a/src/query.rs +++ b/src/query.rs @@ -57,13 +57,7 @@ pub fn execute(sql: &str, workspace_id: &str, connection: Option<&str>, format: body["connection_id"] = Value::String(conn.to_string()); } - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.set_style( - indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") - .unwrap(), - ); - spinner.set_message("running query..."); - spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + let spinner = crate::util::spinner("running query..."); let (status, resp_body) = api.post_raw("/query", &body); spinner.finish_and_clear(); diff --git a/src/util.rs b/src/util.rs index cd84ef9..8204f1f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,17 @@ use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +/// Create a steady-ticking spinner with a cyan glyph and the given message. +/// Writes to stderr so stdout (json/yaml output) stays clean. +pub fn spinner(msg: &str) -> indicatif::ProgressBar { + let pb = indicatif::ProgressBar::new_spinner(); + pb.set_style( + indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap(), + ); + pb.set_message(msg.to_string()); + pb.enable_steady_tick(Duration::from_millis(80)); + pb +} static DEBUG: AtomicBool = AtomicBool::new(false); From e7563677f740ed56b4c242027960239ef5cf9922 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Mon, 13 Apr 2026 12:34:25 -0700 Subject: [PATCH 2/2] fix connections get errors --- src/api.rs | 18 ++++++++++++++ src/connections.rs | 53 ++++++++++++++++++++++++++++++++++-------- src/connections_new.rs | 32 +++++++++++++++++++------ 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/api.rs b/src/api.rs index e0424f3..dc46266 100644 --- a/src/api.rs +++ b/src/api.rs @@ -171,6 +171,24 @@ impl ApiClient { } } + /// GET request, exits only on connection error, returns raw (status, body). + /// Use for best-effort endpoints (e.g. health checks) where the caller wants + /// to handle non-2xx responses gracefully instead of aborting. + pub fn get_raw(&self, path: &str) -> (reqwest::StatusCode, String) { + let url = format!("{}{path}", self.api_url); + self.log_request("GET", &url, None); + + let resp = match self.build_request(reqwest::Method::GET, &url).send() { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + util::debug_response(resp) + } + /// POST request with JSON body, exits on error, returns raw (status, body). pub fn post_raw(&self, path: &str, body: &serde_json::Value) -> (reqwest::StatusCode, String) { let url = format!("{}{path}", self.api_url); diff --git a/src/connections.rs b/src/connections.rs index 3e3817c..69fcc2d 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -12,23 +12,56 @@ struct HealthResponse { error: Option, } -fn fetch_health(api: &ApiClient, connection_id: &str, show_spinner: bool) -> HealthResponse { +/// Result of a best-effort health check. Either the endpoint responded with a +/// parseable body, or it did not — in which case we record why and keep going. +enum HealthStatus { + Available(HealthResponse), + Unavailable(String), +} + +impl HealthStatus { + fn is_confirmed_unhealthy(&self) -> bool { + matches!(self, HealthStatus::Available(h) if !h.healthy) + } +} + +impl Serialize for HealthStatus { + fn serialize(&self, ser: S) -> Result { + match self { + HealthStatus::Available(h) => h.serialize(ser), + HealthStatus::Unavailable(_) => ser.serialize_none(), + } + } +} + +fn fetch_health(api: &ApiClient, connection_id: &str, show_spinner: bool) -> HealthStatus { let spinner = show_spinner.then(|| crate::util::spinner("Checking connection health...")); - let health: HealthResponse = api.get(&format!("/connections/{connection_id}/health")); + let (status, body) = api.get_raw(&format!("/connections/{connection_id}/health")); if let Some(s) = spinner { s.finish_and_clear(); } - health + + if !status.is_success() { + return HealthStatus::Unavailable(crate::util::api_error(body)); + } + match serde_json::from_str::(&body) { + Ok(h) => HealthStatus::Available(h), + Err(e) => HealthStatus::Unavailable(format!("parse error: {e}")), + } } -fn format_health(health: &HealthResponse) -> String { +fn format_health(health: &HealthStatus) -> String { use crossterm::style::Stylize; - if health.healthy { - match health.latency_ms { + match health { + HealthStatus::Available(h) if h.healthy => match h.latency_ms { Some(ms) => format!("{} {}", "healthy".green(), format!("({ms}ms)").dark_grey()), None => "healthy".green().to_string(), + }, + HealthStatus::Available(h) => { + let err = h.error.as_deref().unwrap_or("unknown error"); + format!("{} — {}", "unhealthy".red(), err) + } + HealthStatus::Unavailable(err) => { + format!("{} — {}", "unavailable".yellow(), err) } - } else { - let err = health.error.as_deref().unwrap_or("unknown error"); - format!("{} — {}", "unhealthy".red(), err) } } @@ -260,7 +293,7 @@ pub fn create( _ => unreachable!(), } - if !health.healthy { + if health.is_confirmed_unhealthy() { std::process::exit(1); } } diff --git a/src/connections_new.rs b/src/connections_new.rs index fd5d75a..042d188 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -277,6 +277,11 @@ pub fn run(workspace_id: &str) { error: Option, } + enum HealthStatus { + Available(HealthResponse), + Unavailable(String), + } + let create_spinner = crate::util::spinner("Creating connection..."); let (status_code, resp_body) = api.post_raw("/connections", &body); create_spinner.finish_and_clear(); @@ -296,9 +301,18 @@ pub fn run(workspace_id: &str) { }; let health_spinner = crate::util::spinner("Checking connection health..."); - let health: HealthResponse = api.get(&format!("/connections/{}/health", result.id)); + let (hstatus, hbody) = api.get_raw(&format!("/connections/{}/health", result.id)); health_spinner.finish_and_clear(); + let health = if !hstatus.is_success() { + HealthStatus::Unavailable(crate::util::api_error(hbody)) + } else { + match serde_json::from_str::(&hbody) { + Ok(h) => HealthStatus::Available(h), + Err(e) => HealthStatus::Unavailable(format!("parse error: {e}")), + } + }; + println!("{}", "Connection created".green()); println!("id: {}", result.id); println!("name: {}", result.name); @@ -310,18 +324,22 @@ pub fn run(workspace_id: &str) { _ => result.discovery_status.yellow().to_string(), }; println!("discovery_status: {status}"); - let health_str = if health.healthy { - match health.latency_ms { + let health_str = match &health { + HealthStatus::Available(h) if h.healthy => match h.latency_ms { Some(ms) => format!("{} {}", "healthy".green(), format!("({ms}ms)").dark_grey()), None => "healthy".green().to_string(), + }, + HealthStatus::Available(h) => { + let err = h.error.as_deref().unwrap_or("unknown error"); + format!("{} — {}", "unhealthy".red(), err) + } + HealthStatus::Unavailable(err) => { + format!("{} — {}", "unavailable".yellow(), err) } - } else { - let err = health.error.as_deref().unwrap_or("unknown error"); - format!("{} — {}", "unhealthy".red(), err) }; println!("health: {health_str}"); - if !health.healthy { + if matches!(&health, HealthStatus::Available(h) if !h.healthy) { std::process::exit(1); } }