Skip to main content

rustlavel_http/
health.rs

1//! Health endpoints, for whatever is deciding whether to send traffic here.
2//!
3//! Two questions, two routes, because they have different answers. `GET /up`
4//! asks *is the process alive* — it returns 200 as long as the server can
5//! answer at all, and a load balancer or Kubernetes liveness probe uses it to
6//! decide whether to restart the process. `GET /up/ready` asks *can it do
7//! useful work* — it runs every registered check and returns 503 if any fails,
8//! and a readiness probe uses it to decide whether to route requests here.
9//! Conflating the two is how a database outage turns into a restart loop.
10//!
11//! ```ignore
12//! App::new()?.plugin(
13//!     Health::new()
14//!         .check("database", |req| {
15//!             let db = req.state::<Database>().cloned();
16//!             async move {
17//!                 let db = db.ok_or("no database configured")?;
18//!                 db.scalar::<i64>("select 1", &[]).await.map(|_| ()).map_err(|e| e.to_string())
19//!             }
20//!         })
21//!         .check("cache", |req| { … }),
22//! )
23//! ```
24//!
25//! The path is `/up`, as in Laravel 11, so anything already probing a Laravel
26//! application needs no change.
27
28use crate::handler::BoxFuture;
29use crate::plugin::{Plugin, Setup};
30use crate::request::Request;
31use crate::response::Response;
32use crate::status::Status;
33use rustlavel_core::Json;
34use std::future::Future;
35use std::sync::Arc;
36use std::time::{Duration, Instant};
37
38type CheckFn = Arc<dyn Fn(&Request) -> BoxFuture<Result<(), String>> + Send + Sync>;
39
40#[derive(Clone)]
41pub struct Health {
42    path: String,
43    checks: Vec<(String, CheckFn)>,
44    /// How long a single check may take before it counts as failed.
45    timeout: Duration,
46}
47
48impl Default for Health {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl Health {
55    pub fn new() -> Self {
56        Health { path: "/up".to_string(), checks: Vec::new(), timeout: Duration::from_secs(5) }
57    }
58
59    /// Serve from a different path: `/healthz`, `/_health`.
60    pub fn at(mut self, path: &str) -> Self {
61        self.path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
62        self
63    }
64
65    /// Register a readiness check. It receives the request so it can reach
66    /// application state; `Err(reason)` marks the check — and the whole
67    /// readiness response — as failed.
68    pub fn check<F, Fut>(mut self, name: &str, check: F) -> Self
69    where
70        F: Fn(&Request) -> Fut + Send + Sync + 'static,
71        Fut: Future<Output = Result<(), String>> + Send + 'static,
72    {
73        self.checks.push((name.to_string(), Arc::new(move |req| Box::pin(check(req)))));
74        self
75    }
76
77    /// The most a check may take. A dependency that hangs must be reported
78    /// as down, not left to hang the probe with it.
79    pub fn timeout(mut self, timeout: Duration) -> Self {
80        self.timeout = timeout;
81        self
82    }
83
84    async fn readiness(checks: Vec<(String, CheckFn)>, timeout: Duration, request: Request) -> Response {
85        // All checks run at once: a probe that waits for the database and
86        // *then* for the cache takes the sum of their latencies for no reason.
87        let futures: Vec<_> = checks
88            .iter()
89            .map(|(name, check)| {
90                let name = name.clone();
91                let future = check(&request);
92                async move {
93                    let started = Instant::now();
94                    let outcome = match tokio::time::timeout(timeout, future).await {
95                        Ok(Ok(())) => Ok(()),
96                        Ok(Err(reason)) => Err(reason),
97                        Err(_) => Err(format!("no answer within {} ms", timeout.as_millis())),
98                    };
99                    (name, outcome, started.elapsed())
100                }
101            })
102            .collect();
103
104        let results = join_all(futures).await;
105        let healthy = results.iter().all(|(_, outcome, _)| outcome.is_ok());
106
107        let checks = Json::object(results.into_iter().map(|(name, outcome, took)| {
108            let mut fields = vec![
109                ("status", Json::from(if outcome.is_ok() { "ok" } else { "failed" })),
110                ("duration_ms", Json::Number(took.as_secs_f64() * 1000.0)),
111            ];
112            if let Err(reason) = outcome {
113                fields.push(("error", Json::from(reason)));
114            }
115            (name, Json::object(fields))
116        }));
117
118        let body = Json::object([
119            ("status", Json::from(if healthy { "ok" } else { "failed" })),
120            ("checks", checks),
121        ]);
122        let status = if healthy { Status::OK } else { Status::SERVICE_UNAVAILABLE };
123        Response::new(status).with_json(body).with_header("cache-control", "no-store")
124    }
125}
126
127/// Wait for every future, keeping the order.
128///
129/// A small join rather than a dependency: the checks are few and the result
130/// is needed in the order they were registered, which is the order a person
131/// reading the JSON expects.
132async fn join_all<F: Future>(futures: Vec<F>) -> Vec<F::Output> {
133    let mut handles = Vec::with_capacity(futures.len());
134    for future in futures {
135        handles.push(Box::pin(future));
136    }
137    let mut results: Vec<Option<F::Output>> = (0..handles.len()).map(|_| None).collect();
138    std::future::poll_fn(|cx| {
139        let mut pending = false;
140        for (slot, handle) in results.iter_mut().zip(handles.iter_mut()) {
141            if slot.is_none() {
142                match handle.as_mut().poll(cx) {
143                    std::task::Poll::Ready(value) => *slot = Some(value),
144                    std::task::Poll::Pending => pending = true,
145                }
146            }
147        }
148        if pending { std::task::Poll::Pending } else { std::task::Poll::Ready(()) }
149    })
150    .await;
151    results.into_iter().map(|slot| slot.expect("every future completed")).collect()
152}
153
154impl Plugin for Health {
155    fn name(&self) -> &'static str {
156        "health"
157    }
158
159    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
160        let ready_path = format!("{}/ready", self.path);
161        let checks = self.checks;
162        let timeout = self.timeout;
163
164        setup
165            .router
166            .get(&self.path, |_req: Request| async {
167                Response::json(Json::object([("status", Json::from("ok"))]))
168                    .with_header("cache-control", "no-store")
169            })
170            .name("health.up")
171            .describe("Liveness: the process is running and answering")
172            .tag("Health");
173
174        setup
175            .router
176            .get(&ready_path, move |req: Request| Health::readiness(checks.clone(), timeout, req))
177            .name("health.ready")
178            .describe("Readiness: every dependency check passes")
179            .responds(503, "At least one check failed")
180            .tag("Health");
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::router::Router;
188    use crate::testing::TestClient;
189
190    fn client(health: Health) -> TestClient {
191        let mut router = Router::new();
192        let config = rustlavel_core::Config::new();
193        let mut context = Some(rustlavel_core::Context::builder());
194        let mut setup = Setup { router: &mut router, config: &config, context: &mut context };
195        Box::new(health).register(&mut setup);
196        TestClient::new(router)
197    }
198
199    #[tokio::test]
200    async fn liveness_is_always_ok() {
201        let response = client(Health::new()).get("/up").await;
202        let response = response.assert_ok();
203        assert_eq!(response.json().get("status").and_then(Json::as_str), Some("ok"));
204        assert_eq!(response.header("cache-control"), Some("no-store"));
205    }
206
207    #[tokio::test]
208    async fn readiness_with_no_checks_is_ok() {
209        let response = client(Health::new()).get("/up/ready").await;
210        response.assert_ok().assert_json("status", "ok");
211    }
212
213    #[tokio::test]
214    async fn readiness_reports_each_check_and_fails_if_any_does() {
215        let health = Health::new()
216            .check("database", |_req| async { Ok(()) })
217            .check("cache", |_req| async { Err("connection refused".to_string()) });
218
219        let response = client(health).get("/up/ready").await;
220        let response = response.assert_status(503);
221        let body = response.json();
222        assert_eq!(body.get("status").and_then(Json::as_str), Some("failed"));
223        assert_eq!(body.get("checks.database.status").and_then(Json::as_str), Some("ok"));
224        assert_eq!(body.get("checks.cache.status").and_then(Json::as_str), Some("failed"));
225        assert_eq!(body.get("checks.cache.error").and_then(Json::as_str), Some("connection refused"));
226        assert!(body.get("checks.database.duration_ms").is_some());
227    }
228
229    #[tokio::test]
230    async fn a_hanging_check_is_reported_as_failed_not_waited_for() {
231        let health = Health::new()
232            .timeout(Duration::from_millis(50))
233            .check("slow", |_req| async {
234                tokio::time::sleep(Duration::from_secs(30)).await;
235                Ok(())
236            });
237
238        let started = Instant::now();
239        let response = client(health).get("/up/ready").await;
240        assert!(started.elapsed() < Duration::from_secs(5), "the probe must not hang with the check");
241        let response = response.assert_status(503);
242        let error = response.json().get("checks.slow.error").and_then(Json::as_str).unwrap().to_string();
243        assert!(error.contains("no answer within 50 ms"), "{error}");
244    }
245
246    #[tokio::test]
247    async fn checks_run_concurrently() {
248        let mut health = Health::new();
249        for i in 0..5 {
250            health = health.check(&format!("dep{i}"), |_req| async {
251                tokio::time::sleep(Duration::from_millis(100)).await;
252                Ok(())
253            });
254        }
255        let started = Instant::now();
256        client(health).get("/up/ready").await.assert_ok();
257        // Five 100 ms checks in series would be half a second.
258        assert!(started.elapsed() < Duration::from_millis(400), "took {:?}", started.elapsed());
259    }
260
261    #[tokio::test]
262    async fn a_check_can_reach_application_state() {
263        struct Flag(bool);
264        let health = Health::new().check("flag", |req| {
265            let ok = req.state::<Flag>().map(|f| f.0);
266            async move { if ok == Some(true) { Ok(()) } else { Err("flag not set".to_string()) } }
267        });
268
269        let mut router = Router::new();
270        let config = rustlavel_core::Config::new();
271        let mut context = Some(rustlavel_core::Context::builder().state(Flag(true)));
272        let mut setup = Setup { router: &mut router, config: &config, context: &mut context };
273        Box::new(health).register(&mut setup);
274        let client = TestClient::new(router).with_context(context.unwrap().build());
275
276        client.get("/up/ready").await.assert_ok();
277    }
278
279    #[tokio::test]
280    async fn the_path_is_configurable() {
281        let client = client(Health::new().at("healthz"));
282        client.get("/healthz").await.assert_ok();
283        client.get("/healthz/ready").await.assert_ok();
284        client.get("/up").await.assert_not_found();
285    }
286}