Skip to main content

max / makenotwork

18.2 KB · 535 lines History Blame Raw
1 //! HTTP health probing: issues the request, applies the configured JSON
2 //! expectations, and classifies the response into a `HealthStatus`.
3
4 use std::time::Instant;
5
6 use tracing::instrument;
7
8 use crate::config::{HealthConfig, HealthExpectation};
9 use crate::types::{HealthDetails, HealthSnapshot, HealthStatus};
10
11 /// Maximum response body size we'll read into memory (10 MB).
12 const MAX_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
13
14 #[instrument(skip_all)]
15 pub async fn check_health(
16 target_name: &str,
17 config: &HealthConfig,
18 expect: Option<&HealthExpectation>,
19 ) -> HealthSnapshot {
20 let checked_at = chrono::Utc::now().to_rfc3339();
21 // reqwest 0.13's builder can genuinely fail (platform trust-store load), and
22 // `Client::new()` panics on that same failure, so return an Unreachable result
23 // instead of a fallback client that discards the timeout or panics.
24 let client = match crate::tls::https_client_builder()
25 .timeout(std::time::Duration::from_secs(config.timeout_secs))
26 .build()
27 {
28 Ok(c) => c,
29 Err(e) => {
30 return HealthSnapshot {
31 id: None,
32 target: target_name.to_string(),
33 status: HealthStatus::Unreachable,
34 checked_at,
35 response_time_ms: 0,
36 details: None,
37 error: Some(format!("client build: {e}")),
38 };
39 }
40 };
41
42 let start = Instant::now();
43
44 match client.get(&config.url).send().await {
45 Ok(response) => {
46 let response_time_ms = start.elapsed().as_millis() as i64;
47 let status_code = response.status().as_u16();
48
49 // Reject responses that declare a content-length exceeding our limit.
50 if let Some(len) = response.content_length()
51 && len > MAX_RESPONSE_BYTES
52 {
53 return HealthSnapshot {
54 id: None,
55 target: target_name.to_string(),
56 status: HealthStatus::Degraded,
57 checked_at,
58 response_time_ms,
59 details: None,
60 error: Some(format!(
61 "Response body too large: {len} bytes (limit: {MAX_RESPONSE_BYTES} bytes)"
62 )),
63 };
64 }
65
66 // Read body with size cap (handles chunked/streaming responses without content-length).
67 let body_result = match response.bytes().await {
68 Ok(bytes) => {
69 if bytes.len() as u64 > MAX_RESPONSE_BYTES {
70 Err(format!(
71 "Response body too large: {} bytes (limit: {MAX_RESPONSE_BYTES} bytes)",
72 bytes.len()
73 ))
74 } else {
75 String::from_utf8(bytes.to_vec())
76 .map_err(|e| format!("Response body not valid UTF-8: {e}"))
77 }
78 }
79 Err(e) => Err(format!("Failed to read response body: {e}")),
80 };
81
82 match body_result {
83 Ok(body) => {
84 let json: Option<serde_json::Value> = serde_json::from_str(&body).ok();
85
86 let (mut status, details, mut error) = if let Some(ref json) = json {
87 let (s, d) = classify_json_response(status_code, json);
88 (s, Some(d), None)
89 } else {
90 (
91 classify_non_json(status_code),
92 None,
93 Some("Failed to parse response as JSON".to_string()),
94 )
95 };
96
97 // Apply expectation validation
98 if let Some(exp) = expect {
99 let failures =
100 validate_expectations(exp, status_code, &body, json.as_ref());
101 if !failures.is_empty() {
102 status = HealthStatus::Degraded;
103 error = Some(failures.join("; "));
104 } else if json.is_none() {
105 // Non-JSON response but all expectations passed, treat as operational
106 status = HealthStatus::Operational;
107 error = None;
108 }
109 }
110
111 HealthSnapshot {
112 id: None,
113 target: target_name.to_string(),
114 status,
115 checked_at,
116 response_time_ms,
117 details,
118 error,
119 }
120 }
121 Err(e) => HealthSnapshot {
122 id: None,
123 target: target_name.to_string(),
124 status: HealthStatus::Degraded,
125 checked_at,
126 response_time_ms,
127 details: None,
128 error: Some(e),
129 },
130 }
131 }
132 Err(e) => {
133 let response_time_ms = start.elapsed().as_millis() as i64;
134 HealthSnapshot {
135 id: None,
136 target: target_name.to_string(),
137 status: HealthStatus::Unreachable,
138 checked_at,
139 response_time_ms,
140 details: None,
141 error: Some(format!("{e}")),
142 }
143 }
144 }
145 }
146
147 /// Walk a dot-separated path through nested JSON objects.
148 pub fn resolve_json_path<'a>(
149 value: &'a serde_json::Value,
150 path: &str,
151 ) -> Option<&'a serde_json::Value> {
152 let mut current = value;
153 for key in path.split('.') {
154 current = current.get(key)?;
155 }
156 Some(current)
157 }
158
159 /// Validate response against expectations. Returns a list of failure descriptions.
160 pub fn validate_expectations(
161 expect: &HealthExpectation,
162 status_code: u16,
163 body: &str,
164 json: Option<&serde_json::Value>,
165 ) -> Vec<String> {
166 let mut failures = Vec::new();
167
168 if let Some(expected_code) = expect.status_code
169 && status_code != expected_code
170 {
171 failures.push(format!(
172 "expected status {expected_code}, got {status_code}"
173 ));
174 }
175
176 if let Some(ref substring) = expect.body_contains
177 && !body.contains(substring.as_str())
178 {
179 failures.push(format!("body missing expected substring \"{substring}\""));
180 }
181
182 if !expect.json_fields.is_empty() {
183 if let Some(json) = json {
184 for (path, expected_value) in &expect.json_fields {
185 match resolve_json_path(json, path) {
186 Some(actual) => {
187 let actual_str: std::borrow::Cow<'_, str> = match actual {
188 serde_json::Value::String(s) => std::borrow::Cow::Borrowed(s),
189 other => std::borrow::Cow::Owned(other.to_string()),
190 };
191 if *actual_str != *expected_value {
192 failures.push(format!("json field \"{path}\": expected \"{expected_value}\", got \"{actual_str}\""));
193 }
194 }
195 None => {
196 failures.push(format!("json field \"{path}\" not found"));
197 }
198 }
199 }
200 } else {
201 failures.push("expected JSON response for field validation, got non-JSON".to_string());
202 }
203 }
204
205 failures
206 }
207
208 /// Classify a JSON health response into status + details.
209 pub fn classify_json_response(
210 status_code: u16,
211 json: &serde_json::Value,
212 ) -> (HealthStatus, HealthDetails) {
213 let api_status = json
214 .get("status")
215 .and_then(|s| s.as_str())
216 .unwrap_or("unknown");
217
218 let status = match api_status {
219 "operational" => HealthStatus::Operational,
220 "degraded" => HealthStatus::Degraded,
221 _ if (200..300).contains(&status_code) => HealthStatus::Degraded,
222 _ => HealthStatus::Error,
223 };
224
225 let details = HealthDetails {
226 version: json
227 .get("version")
228 .and_then(|v| v.as_str())
229 .map(String::from),
230 git_sha: json
231 .get("git_sha")
232 .and_then(|v| v.as_str())
233 .map(String::from),
234 uptime: json
235 .get("uptime")
236 .and_then(|v| v.as_str())
237 .map(String::from),
238 checks: json.get("checks").cloned(),
239 monitoring: json.get("monitoring").cloned(),
240 };
241
242 (status, details)
243 }
244
245 /// Classify a response that couldn't be parsed as JSON.
246 pub fn classify_non_json(status_code: u16) -> HealthStatus {
247 if (200..300).contains(&status_code) {
248 HealthStatus::Degraded
249 } else {
250 HealthStatus::Error
251 }
252 }
253
254 #[cfg(test)]
255 mod tests {
256 use super::*;
257 use std::collections::HashMap;
258
259 #[test]
260 fn classify_operational() {
261 let json = serde_json::json!({
262 "status": "operational",
263 "version": "2.1.0",
264 "uptime": "3d 12h",
265 });
266 let (status, details) = classify_json_response(200, &json);
267 assert_eq!(status, HealthStatus::Operational);
268 assert_eq!(details.version.as_deref(), Some("2.1.0"));
269 assert_eq!(details.uptime.as_deref(), Some("3d 12h"));
270 }
271
272 #[test]
273 fn classify_degraded_explicit() {
274 let json = serde_json::json!({ "status": "degraded" });
275 let (status, _) = classify_json_response(200, &json);
276 assert_eq!(status, HealthStatus::Degraded);
277 }
278
279 #[test]
280 fn classify_unknown_status_with_success_code() {
281 let json = serde_json::json!({ "status": "starting_up" });
282 let (status, _) = classify_json_response(200, &json);
283 assert_eq!(status, HealthStatus::Degraded);
284 }
285
286 #[test]
287 fn classify_unknown_status_with_error_code() {
288 let json = serde_json::json!({ "status": "starting_up" });
289 let (status, _) = classify_json_response(503, &json);
290 assert_eq!(status, HealthStatus::Error);
291 }
292
293 #[test]
294 fn classify_missing_status_field() {
295 let json = serde_json::json!({ "version": "1.0.0" });
296 let (status, details) = classify_json_response(200, &json);
297 assert_eq!(status, HealthStatus::Degraded); // "unknown" falls through
298 assert_eq!(details.version.as_deref(), Some("1.0.0"));
299 }
300
301 #[test]
302 fn classify_extracts_checks_and_monitoring() {
303 let json = serde_json::json!({
304 "status": "operational",
305 "checks": { "db": "ok", "redis": "ok" },
306 "monitoring": { "external": true },
307 });
308 let (_, details) = classify_json_response(200, &json);
309 assert!(details.checks.is_some());
310 assert!(details.monitoring.is_some());
311 }
312
313 #[test]
314 fn classify_non_json_success() {
315 assert_eq!(classify_non_json(200), HealthStatus::Degraded);
316 assert_eq!(classify_non_json(204), HealthStatus::Degraded);
317 }
318
319 #[test]
320 fn classify_non_json_error() {
321 assert_eq!(classify_non_json(500), HealthStatus::Error);
322 assert_eq!(classify_non_json(404), HealthStatus::Error);
323 }
324
325 // resolve_json_path
326
327 #[test]
328 fn resolve_json_path_top_level() {
329 let json = serde_json::json!({"status": "operational"});
330 let val = resolve_json_path(&json, "status").unwrap();
331 assert_eq!(val, "operational");
332 }
333
334 #[test]
335 fn resolve_json_path_nested() {
336 let json = serde_json::json!({"checks": {"db": "ok", "redis": "warn"}});
337 let val = resolve_json_path(&json, "checks.db").unwrap();
338 assert_eq!(val, "ok");
339 }
340
341 #[test]
342 fn resolve_json_path_deeply_nested() {
343 let json = serde_json::json!({"a": {"b": {"c": 42}}});
344 let val = resolve_json_path(&json, "a.b.c").unwrap();
345 assert_eq!(val, 42);
346 }
347
348 #[test]
349 fn resolve_json_path_missing() {
350 let json = serde_json::json!({"status": "operational"});
351 assert!(resolve_json_path(&json, "missing").is_none());
352 }
353
354 #[test]
355 fn resolve_json_path_partial_missing() {
356 let json = serde_json::json!({"checks": {"db": "ok"}});
357 assert!(resolve_json_path(&json, "checks.redis").is_none());
358 }
359
360 // validate_expectations
361
362 #[test]
363 fn validate_status_code_match() {
364 let expect = HealthExpectation {
365 status_code: Some(200),
366 ..Default::default()
367 };
368 let failures = validate_expectations(&expect, 200, "", None);
369 assert!(failures.is_empty());
370 }
371
372 #[test]
373 fn validate_status_code_mismatch() {
374 let expect = HealthExpectation {
375 status_code: Some(200),
376 ..Default::default()
377 };
378 let failures = validate_expectations(&expect, 503, "", None);
379 assert_eq!(failures.len(), 1);
380 assert!(failures[0].contains("expected status 200"));
381 assert!(failures[0].contains("got 503"));
382 }
383
384 #[test]
385 fn validate_body_contains_match() {
386 let expect = HealthExpectation {
387 body_contains: Some("operational".to_string()),
388 ..Default::default()
389 };
390 let failures = validate_expectations(&expect, 200, r#"{"status":"operational"}"#, None);
391 assert!(failures.is_empty());
392 }
393
394 #[test]
395 fn validate_body_contains_mismatch() {
396 let expect = HealthExpectation {
397 body_contains: Some("operational".to_string()),
398 ..Default::default()
399 };
400 let failures = validate_expectations(&expect, 200, r#"{"status":"error"}"#, None);
401 assert_eq!(failures.len(), 1);
402 assert!(failures[0].contains("body missing"));
403 }
404
405 #[test]
406 fn validate_json_fields_match() {
407 let mut fields = HashMap::new();
408 fields.insert("status".to_string(), "operational".to_string());
409 fields.insert("checks.db".to_string(), "ok".to_string());
410 let expect = HealthExpectation {
411 json_fields: fields,
412 ..Default::default()
413 };
414 let json = serde_json::json!({"status": "operational", "checks": {"db": "ok"}});
415 let failures = validate_expectations(&expect, 200, "", Some(&json));
416 assert!(failures.is_empty());
417 }
418
419 #[test]
420 fn validate_json_fields_mismatch() {
421 let mut fields = HashMap::new();
422 fields.insert("status".to_string(), "operational".to_string());
423 let expect = HealthExpectation {
424 json_fields: fields,
425 ..Default::default()
426 };
427 let json = serde_json::json!({"status": "degraded"});
428 let failures = validate_expectations(&expect, 200, "", Some(&json));
429 assert_eq!(failures.len(), 1);
430 assert!(failures[0].contains("expected \"operational\""));
431 assert!(failures[0].contains("got \"degraded\""));
432 }
433
434 #[test]
435 fn validate_json_field_missing() {
436 let mut fields = HashMap::new();
437 fields.insert("checks.redis".to_string(), "ok".to_string());
438 let expect = HealthExpectation {
439 json_fields: fields,
440 ..Default::default()
441 };
442 let json = serde_json::json!({"checks": {"db": "ok"}});
443 let failures = validate_expectations(&expect, 200, "", Some(&json));
444 assert_eq!(failures.len(), 1);
445 assert!(failures[0].contains("not found"));
446 }
447
448 #[test]
449 fn validate_json_fields_on_non_json() {
450 let mut fields = HashMap::new();
451 fields.insert("status".to_string(), "ok".to_string());
452 let expect = HealthExpectation {
453 json_fields: fields,
454 ..Default::default()
455 };
456 let failures = validate_expectations(&expect, 200, "not json", None);
457 assert_eq!(failures.len(), 1);
458 assert!(failures[0].contains("non-JSON"));
459 }
460
461 #[test]
462 fn validate_mixed_failures() {
463 let mut fields = HashMap::new();
464 fields.insert("status".to_string(), "operational".to_string());
465 let expect = HealthExpectation {
466 status_code: Some(200),
467 body_contains: Some("healthy".to_string()),
468 json_fields: fields,
469 };
470 let json = serde_json::json!({"status": "degraded"});
471 let failures = validate_expectations(&expect, 503, r#"{"status":"degraded"}"#, Some(&json));
472 assert_eq!(failures.len(), 3); // status code + body + json field
473 }
474
475 #[test]
476 fn validate_empty_expectations_always_pass() {
477 let expect = HealthExpectation::default();
478 let failures = validate_expectations(&expect, 500, "garbage", None);
479 assert!(failures.is_empty());
480 }
481
482 // classify_non_json: 200..300 range boundaries
483
484 #[test]
485 fn classify_non_json_status_boundaries() {
486 // Pins the `(200..300).contains(&status_code)` range.
487 assert_eq!(
488 classify_non_json(199),
489 HealthStatus::Error,
490 "199 is below 2xx"
491 );
492 assert_eq!(
493 classify_non_json(200),
494 HealthStatus::Degraded,
495 "200 is start of 2xx"
496 );
497 assert_eq!(
498 classify_non_json(299),
499 HealthStatus::Degraded,
500 "299 is end of 2xx"
501 );
502 assert_eq!(
503 classify_non_json(300),
504 HealthStatus::Error,
505 "300 is start of 3xx"
506 );
507 }
508
509 #[test]
510 fn classify_json_unknown_status_3xx_is_error() {
511 // Pins the `_ if (200..300).contains(&status_code)` guard in
512 // classify_json_response: status_code 300 with unknown api_status
513 // must fall through to Error, not Degraded.
514 let json = serde_json::json!({ "status": "starting_up" });
515 assert_eq!(classify_json_response(300, &json).0, HealthStatus::Error);
516 assert_eq!(classify_json_response(199, &json).0, HealthStatus::Error);
517 }
518
519 // resolve_json_path edge cases
520
521 #[test]
522 fn resolve_json_path_empty_path_segment_is_none() {
523 // path "a..b" splits to ["a", "", "b"]; `.get("")` returns None.
524 let json = serde_json::json!({"a": {"b": 1}});
525 assert!(resolve_json_path(&json, "a..b").is_none());
526 }
527
528 #[test]
529 fn resolve_json_path_through_non_object_is_none() {
530 // Trying to descend into a string value should return None.
531 let json = serde_json::json!({"name": "hello"});
532 assert!(resolve_json_path(&json, "name.length").is_none());
533 }
534 }
535