1use 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 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 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 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 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 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
127async 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 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}