Skip to content

Commit 1c8f860

Browse files
authored
fix(connections): Add health check to connection flow
1 parent f51f5ba commit 1c8f860

6 files changed

Lines changed: 250 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ impl ApiClient {
171171
}
172172
}
173173

174+
/// GET request, exits only on connection error, returns raw (status, body).
175+
/// Use for best-effort endpoints (e.g. health checks) where the caller wants
176+
/// to handle non-2xx responses gracefully instead of aborting.
177+
pub fn get_raw(&self, path: &str) -> (reqwest::StatusCode, String) {
178+
let url = format!("{}{path}", self.api_url);
179+
self.log_request("GET", &url, None);
180+
181+
let resp = match self.build_request(reqwest::Method::GET, &url).send() {
182+
Ok(r) => r,
183+
Err(e) => {
184+
eprintln!("error connecting to API: {e}");
185+
std::process::exit(1);
186+
}
187+
};
188+
189+
util::debug_response(resp)
190+
}
191+
174192
/// POST request with JSON body, exits on error, returns raw (status, body).
175193
pub fn post_raw(&self, path: &str, body: &serde_json::Value) -> (reqwest::StatusCode, String) {
176194
let url = format!("{}{path}", self.api_url);

src/connections.rs

Lines changed: 150 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,70 @@
11
use crate::api::ApiClient;
22
use serde::{Deserialize, Serialize};
33

4+
#[derive(Deserialize, Serialize)]
5+
struct HealthResponse {
6+
#[allow(dead_code)]
7+
connection_id: String,
8+
healthy: bool,
9+
#[serde(default, skip_serializing_if = "Option::is_none")]
10+
latency_ms: Option<u64>,
11+
#[serde(default, skip_serializing_if = "Option::is_none")]
12+
error: Option<String>,
13+
}
14+
15+
/// Result of a best-effort health check. Either the endpoint responded with a
16+
/// parseable body, or it did not — in which case we record why and keep going.
17+
enum HealthStatus {
18+
Available(HealthResponse),
19+
Unavailable(String),
20+
}
21+
22+
impl HealthStatus {
23+
fn is_confirmed_unhealthy(&self) -> bool {
24+
matches!(self, HealthStatus::Available(h) if !h.healthy)
25+
}
26+
}
27+
28+
impl Serialize for HealthStatus {
29+
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
30+
match self {
31+
HealthStatus::Available(h) => h.serialize(ser),
32+
HealthStatus::Unavailable(_) => ser.serialize_none(),
33+
}
34+
}
35+
}
36+
37+
fn fetch_health(api: &ApiClient, connection_id: &str, show_spinner: bool) -> HealthStatus {
38+
let spinner = show_spinner.then(|| crate::util::spinner("Checking connection health..."));
39+
let (status, body) = api.get_raw(&format!("/connections/{connection_id}/health"));
40+
if let Some(s) = spinner { s.finish_and_clear(); }
41+
42+
if !status.is_success() {
43+
return HealthStatus::Unavailable(crate::util::api_error(body));
44+
}
45+
match serde_json::from_str::<HealthResponse>(&body) {
46+
Ok(h) => HealthStatus::Available(h),
47+
Err(e) => HealthStatus::Unavailable(format!("parse error: {e}")),
48+
}
49+
}
50+
51+
fn format_health(health: &HealthStatus) -> String {
52+
use crossterm::style::Stylize;
53+
match health {
54+
HealthStatus::Available(h) if h.healthy => match h.latency_ms {
55+
Some(ms) => format!("{} {}", "healthy".green(), format!("({ms}ms)").dark_grey()),
56+
None => "healthy".green().to_string(),
57+
},
58+
HealthStatus::Available(h) => {
59+
let err = h.error.as_deref().unwrap_or("unknown error");
60+
format!("{} — {}", "unhealthy".red(), err)
61+
}
62+
HealthStatus::Unavailable(err) => {
63+
format!("{} — {}", "unavailable".yellow(), err)
64+
}
65+
}
66+
}
67+
468
#[derive(Deserialize, Serialize)]
569
struct ConnectionType {
670
name: String,
@@ -88,23 +152,60 @@ struct ListResponse {
88152

89153
pub fn get(workspace_id: &str, connection_id: &str, format: &str) {
90154
let api = ApiClient::new(Some(workspace_id));
155+
let is_table = format == "table";
156+
157+
let spinner = is_table.then(|| crate::util::spinner("Fetching connection..."));
91158
let detail: ConnectionDetail = api.get(&format!("/connections/{connection_id}"));
159+
if let Some(s) = spinner { s.finish_and_clear(); }
160+
161+
let health = fetch_health(&api, connection_id, is_table);
92162

93163
match format {
94-
"json" => println!("{}", serde_json::to_string_pretty(&detail).unwrap()),
95-
"yaml" => print!("{}", serde_yaml::to_string(&detail).unwrap()),
164+
"json" => {
165+
let combined = serde_json::json!({
166+
"id": detail.id,
167+
"name": detail.name,
168+
"source_type": detail.source_type,
169+
"table_count": detail.table_count,
170+
"synced_table_count": detail.synced_table_count,
171+
"health": &health,
172+
});
173+
println!("{}", serde_json::to_string_pretty(&combined).unwrap());
174+
}
175+
"yaml" => {
176+
let combined = serde_json::json!({
177+
"id": detail.id,
178+
"name": detail.name,
179+
"source_type": detail.source_type,
180+
"table_count": detail.table_count,
181+
"synced_table_count": detail.synced_table_count,
182+
"health": &health,
183+
});
184+
print!("{}", serde_yaml::to_string(&combined).unwrap());
185+
}
96186
"table" => {
97187
use crossterm::style::Stylize;
98188
let label = |l: &str| format!("{:<16}", l).dark_grey().to_string();
99189
println!("{}{}", label("id:"), detail.id.dark_cyan());
100190
println!("{}{}", label("name:"), detail.name.white());
101191
println!("{}{}", label("source_type:"), detail.source_type.green());
102192
println!("{}{}", label("tables:"), format!("{} synced / {} total", detail.synced_table_count.to_string().cyan(), detail.table_count.to_string().cyan()));
193+
println!("{}{}", label("health:"), format_health(&health));
103194
}
104195
_ => unreachable!(),
105196
}
106197
}
107198

199+
#[derive(Deserialize, Serialize)]
200+
struct CreateResponse {
201+
id: String,
202+
name: String,
203+
source_type: String,
204+
tables_discovered: u64,
205+
discovery_status: String,
206+
discovery_error: Option<String>,
207+
}
208+
108209
pub fn create(
109210
workspace_id: &str,
110211
name: &str,
@@ -127,22 +228,53 @@ pub fn create(
127228
});
128229

129230
let api = ApiClient::new(Some(workspace_id));
231+
let is_table = format == "table";
232+
233+
let spinner = is_table.then(|| crate::util::spinner("Creating connection..."));
234+
let (status, resp_body) = api.post_raw("/connections", &body);
235+
if let Some(s) = &spinner { s.finish_and_clear(); }
130236

131-
#[derive(Deserialize, Serialize)]
132-
struct CreateResponse {
133-
id: String,
134-
name: String,
135-
source_type: String,
136-
tables_discovered: u64,
137-
discovery_status: String,
138-
discovery_error: Option<String>,
237+
if !status.is_success() {
238+
use crossterm::style::Stylize;
239+
eprintln!("{}", crate::util::api_error(resp_body).red());
240+
std::process::exit(1);
139241
}
140242

141-
let result: CreateResponse = api.post("/connections", &body);
243+
let result: CreateResponse = match serde_json::from_str(&resp_body) {
244+
Ok(v) => v,
245+
Err(e) => {
246+
eprintln!("error parsing response: {e}");
247+
std::process::exit(1);
248+
}
249+
};
250+
251+
let health = fetch_health(&api, &result.id, is_table);
142252

143253
match format {
144-
"json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
145-
"yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()),
254+
"json" => {
255+
let combined = serde_json::json!({
256+
"id": result.id,
257+
"name": result.name,
258+
"source_type": result.source_type,
259+
"tables_discovered": result.tables_discovered,
260+
"discovery_status": result.discovery_status,
261+
"discovery_error": result.discovery_error,
262+
"health": &health,
263+
});
264+
println!("{}", serde_json::to_string_pretty(&combined).unwrap());
265+
}
266+
"yaml" => {
267+
let combined = serde_json::json!({
268+
"id": result.id,
269+
"name": result.name,
270+
"source_type": result.source_type,
271+
"tables_discovered": result.tables_discovered,
272+
"discovery_status": result.discovery_status,
273+
"discovery_error": result.discovery_error,
274+
"health": &health,
275+
});
276+
print!("{}", serde_yaml::to_string(&combined).unwrap());
277+
}
146278
"table" => {
147279
use crossterm::style::Stylize;
148280
println!("{}", "Connection created".green());
@@ -156,9 +288,14 @@ pub fn create(
156288
_ => result.discovery_status.yellow().to_string(),
157289
};
158290
println!("discovery_status: {status_colored}");
291+
println!("health: {}", format_health(&health));
159292
}
160293
_ => unreachable!(),
161294
}
295+
296+
if health.is_confirmed_unhealthy() {
297+
std::process::exit(1);
298+
}
162299
}
163300

164301
pub fn list(workspace_id: &str, format: &str) {

src/connections_new.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,51 @@ pub fn run(workspace_id: &str) {
268268
discovery_error: Option<String>,
269269
}
270270

271-
let result: CreateResponse = api.post("/connections", &body);
271+
#[derive(serde::Deserialize)]
272+
struct HealthResponse {
273+
healthy: bool,
274+
#[serde(default)]
275+
latency_ms: Option<u64>,
276+
#[serde(default)]
277+
error: Option<String>,
278+
}
279+
280+
enum HealthStatus {
281+
Available(HealthResponse),
282+
Unavailable(String),
283+
}
284+
285+
let create_spinner = crate::util::spinner("Creating connection...");
286+
let (status_code, resp_body) = api.post_raw("/connections", &body);
287+
create_spinner.finish_and_clear();
272288

273289
use crossterm::style::Stylize;
290+
if !status_code.is_success() {
291+
eprintln!("{}", crate::util::api_error(resp_body).red());
292+
std::process::exit(1);
293+
}
294+
295+
let result: CreateResponse = match serde_json::from_str(&resp_body) {
296+
Ok(v) => v,
297+
Err(e) => {
298+
eprintln!("error parsing response: {e}");
299+
std::process::exit(1);
300+
}
301+
};
302+
303+
let health_spinner = crate::util::spinner("Checking connection health...");
304+
let (hstatus, hbody) = api.get_raw(&format!("/connections/{}/health", result.id));
305+
health_spinner.finish_and_clear();
306+
307+
let health = if !hstatus.is_success() {
308+
HealthStatus::Unavailable(crate::util::api_error(hbody))
309+
} else {
310+
match serde_json::from_str::<HealthResponse>(&hbody) {
311+
Ok(h) => HealthStatus::Available(h),
312+
Err(e) => HealthStatus::Unavailable(format!("parse error: {e}")),
313+
}
314+
};
315+
274316
println!("{}", "Connection created".green());
275317
println!("id: {}", result.id);
276318
println!("name: {}", result.name);
@@ -282,4 +324,22 @@ pub fn run(workspace_id: &str) {
282324
_ => result.discovery_status.yellow().to_string(),
283325
};
284326
println!("discovery_status: {status}");
327+
let health_str = match &health {
328+
HealthStatus::Available(h) if h.healthy => match h.latency_ms {
329+
Some(ms) => format!("{} {}", "healthy".green(), format!("({ms}ms)").dark_grey()),
330+
None => "healthy".green().to_string(),
331+
},
332+
HealthStatus::Available(h) => {
333+
let err = h.error.as_deref().unwrap_or("unknown error");
334+
format!("{} — {}", "unhealthy".red(), err)
335+
}
336+
HealthStatus::Unavailable(err) => {
337+
format!("{} — {}", "unavailable".yellow(), err)
338+
}
339+
};
340+
println!("health: {health_str}");
341+
342+
if matches!(&health, HealthStatus::Available(h) if !h.healthy) {
343+
std::process::exit(1);
344+
}
285345
}

src/query.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,7 @@ pub fn execute(sql: &str, workspace_id: &str, connection: Option<&str>, format:
5757
body["connection_id"] = Value::String(conn.to_string());
5858
}
5959

60-
let spinner = indicatif::ProgressBar::new_spinner();
61-
spinner.set_style(
62-
indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}")
63-
.unwrap(),
64-
);
65-
spinner.set_message("running query...");
66-
spinner.enable_steady_tick(std::time::Duration::from_millis(80));
60+
let spinner = crate::util::spinner("running query...");
6761

6862
let (status, resp_body) = api.post_raw("/query", &body);
6963
spinner.finish_and_clear();

0 commit comments

Comments
 (0)