Skip to main content

stackless_cloud/
health.rs

1//! Shared cloud health gate (§4): poll a service URL until it returns the
2//! expected status (and, if set, a body containing a needle) or a budget
3//! expires. The loop is identical across cloud substrates; only the fault they
4//! raise differs, so this returns a neutral [`HealthFailure`] the substrate maps
5//! to its own `HealthFailed` (preserving per-provider codes/remediation, §2).
6
7use std::time::Duration;
8
9/// Per-request timeout and inter-attempt interval — the same across substrates.
10const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
11const POLL_INTERVAL: Duration = Duration::from_secs(5);
12
13/// A health gate that never went green within its budget, as neutral data the
14/// provider maps to its own fault.
15#[derive(Debug, Clone)]
16pub struct HealthFailure {
17    pub url: String,
18    pub detail: String,
19    pub budget_secs: u64,
20}
21
22/// Poll `url` until it returns `expected_status` and (when set) a body
23/// containing `contains`, or `budget` expires. Each failed attempt records why,
24/// so the returned detail explains the last thing seen.
25pub async fn poll(
26    url: &str,
27    expected_status: u16,
28    contains: Option<&str>,
29    budget: Duration,
30) -> Result<(), HealthFailure> {
31    let client = reqwest::Client::new();
32    let deadline = tokio::time::Instant::now() + budget;
33    let mut last_detail;
34    loop {
35        match client.get(url).timeout(REQUEST_TIMEOUT).send().await {
36            Ok(response) => {
37                let status = response.status().as_u16();
38                let body = response.text().await.unwrap_or_default();
39                let status_ok = status == expected_status;
40                let contains_ok = contains.is_none_or(|needle| body.contains(needle));
41                if status_ok && contains_ok {
42                    return Ok(());
43                }
44                last_detail = format!(
45                    "got {status}, expected {expected_status}{}",
46                    contains
47                        .map(|n| format!(" containing {n:?}"))
48                        .unwrap_or_default()
49                );
50            }
51            Err(err) => last_detail = err.to_string(),
52        }
53        if tokio::time::Instant::now() >= deadline {
54            return Err(HealthFailure {
55                url: url.to_owned(),
56                detail: last_detail,
57                budget_secs: budget.as_secs(),
58            });
59        }
60        tokio::time::sleep(POLL_INTERVAL).await;
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use wiremock::matchers::{method, path};
68    use wiremock::{Mock, MockServer, ResponseTemplate};
69
70    #[tokio::test]
71    async fn green_on_status_and_body_match() {
72        let server = MockServer::start().await;
73        Mock::given(method("GET"))
74            .and(path("/health"))
75            .respond_with(ResponseTemplate::new(200).set_body_string("stackless-smoke-ok"))
76            .mount(&server)
77            .await;
78        let url = format!("{}/health", server.uri());
79        poll(
80            &url,
81            200,
82            Some("stackless-smoke-ok"),
83            Duration::from_secs(5),
84        )
85        .await
86        .unwrap();
87    }
88
89    #[tokio::test]
90    async fn times_out_when_status_never_matches() {
91        let server = MockServer::start().await;
92        Mock::given(method("GET"))
93            .and(path("/health"))
94            .respond_with(ResponseTemplate::new(503))
95            .mount(&server)
96            .await;
97        let url = format!("{}/health", server.uri());
98        // Zero budget: one attempt, then the deadline check fails immediately.
99        let err = poll(&url, 200, None, Duration::from_secs(0))
100            .await
101            .unwrap_err();
102        assert_eq!(err.url, url);
103        assert_eq!(err.budget_secs, 0);
104        assert!(err.detail.contains("got 503"), "detail: {}", err.detail);
105    }
106}