1use std::collections::BTreeMap;
53use std::sync::Arc;
54use std::time::{Duration, Instant};
55
56use rainier_container::{facade_application, Application};
57use rainier_http::{Response, StatusCode};
58use rainier_support::{BoxedFuture, Error, Result};
59use serde::Serialize;
60use serde_json::{json, Value};
61
62type Check = Arc<dyn Fn(Arc<Application>) -> BoxedFuture<Result<()>> + Send + Sync>;
64
65#[derive(Debug, Clone, Serialize)]
67pub struct Outcome {
68 pub status: &'static str,
70 pub duration_ms: u64,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub error: Option<String>,
75}
76
77impl Outcome {
78 pub fn is_ok(&self) -> bool {
80 self.status == "ok"
81 }
82}
83
84#[derive(Debug, Clone, Serialize)]
86pub struct Report {
87 pub status: &'static str,
89 pub checks: BTreeMap<String, Outcome>,
91}
92
93impl Report {
94 pub fn is_healthy(&self) -> bool {
96 self.status == "ok"
97 }
98
99 pub fn status_code(&self) -> StatusCode {
106 if self.is_healthy() {
107 StatusCode::OK
108 } else {
109 StatusCode::SERVICE_UNAVAILABLE
110 }
111 }
112
113 pub fn failing(&self) -> Vec<&str> {
115 self.checks
116 .iter()
117 .filter(|(_, outcome)| !outcome.is_ok())
118 .map(|(name, _)| name.as_str())
119 .collect()
120 }
121}
122
123#[derive(Clone, Default)]
125pub struct Health {
126 checks: Vec<(String, Check)>,
127 timeout: Option<Duration>,
128 build: Option<Value>,
129}
130
131impl Health {
132 pub fn new() -> Self {
134 Self { checks: Vec::new(), timeout: Some(Duration::from_secs(5)), build: None }
135 }
136
137 #[must_use = "this returns a registry with the check added"]
143 pub fn register<F, Fut>(mut self, name: impl Into<String>, check: F) -> Self
144 where
145 F: Fn(Arc<Application>) -> Fut + Send + Sync + 'static,
146 Fut: std::future::Future<Output = Result<()>> + Send + 'static,
147 {
148 self.checks.push((name.into(), Arc::new(move |app| Box::pin(check(app)))));
149 self
150 }
151
152 #[must_use = "this returns a configured registry rather than configuring in place"]
158 pub fn timeout(mut self, timeout: Option<Duration>) -> Self {
159 self.timeout = timeout;
160 self
161 }
162
163 #[must_use = "this returns a configured registry rather than configuring in place"]
169 pub fn describing_build(mut self, build: impl Serialize) -> Self {
170 self.build = serde_json::to_value(build).ok();
171 self
172 }
173
174 pub fn names(&self) -> Vec<&str> {
176 self.checks.iter().map(|(name, _)| name.as_str()).collect()
177 }
178
179 pub async fn run(&self, app: Arc<Application>) -> Report {
185 let running = self.checks.iter().map(|(name, check)| {
186 let app = Arc::clone(&app);
187 let name = name.clone();
188 let check = Arc::clone(check);
189 let timeout = self.timeout;
190
191 async move { (name, run_one(check, app, timeout).await) }
192 });
193
194 let checks: BTreeMap<String, Outcome> =
195 futures_util::future::join_all(running).await.into_iter().collect();
196
197 let status = if checks.values().all(Outcome::is_ok) { "ok" } else { "failing" };
198
199 Report { status, checks }
200 }
201
202 pub async fn render(&self, app: Arc<Application>) -> Response {
204 let report = self.run(app).await;
205 let status = report.status_code();
206
207 let mut document = json!({
208 "status": report.status,
209 "checks": report.checks,
210 });
211
212 if let Some(build) = &self.build {
213 document["build"] = build.clone();
214 }
215
216 Response::json(&document).with_status(status)
217 }
218}
219
220async fn run_one(check: Check, app: Arc<Application>, timeout: Option<Duration>) -> Outcome {
230 let started = Instant::now();
231
232 let running = rainier_container::spawn_with_facades(async move {
233 match timeout {
234 Some(timeout) => match tokio::time::timeout(timeout, check(app)).await {
235 Ok(result) => result,
236 Err(_) => Err(Error::service_unavailable(format!(
237 "did not answer within {}ms",
238 timeout.as_millis()
239 ))),
240 },
241 None => check(app).await,
242 }
243 });
244
245 let result = match running.await {
246 Ok(result) => result,
247 Err(e) if e.is_panic() => Err(Error::internal("the check panicked".to_string())),
248 Err(_) => Err(Error::internal("the check was cancelled")),
249 };
250
251 let duration_ms = started.elapsed().as_millis() as u64;
252
253 match result {
254 Ok(()) => Outcome { status: "ok", duration_ms, error: None },
255 Err(e) => Outcome { status: "failing", duration_ms, error: Some(e.message().to_string()) },
259 }
260}
261
262impl std::fmt::Debug for Health {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 f.debug_struct("Health")
265 .field("checks", &self.names())
266 .field("timeout", &self.timeout)
267 .finish()
268 }
269}
270
271pub async fn endpoint() -> Response {
280 let app = facade_application();
281
282 match app.resolve::<Health>() {
283 Ok(health) => health.render(app).await,
284 Err(_) => Response::json(&json!({
288 "status": "unknown",
289 "checks": {},
290 "error": "no health checks are registered — bind a `Health` in a provider",
291 }))
292 .with_status(StatusCode::SERVICE_UNAVAILABLE),
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 fn app() -> Arc<Application> {
301 Arc::new(Application::new("."))
302 }
303
304 fn healthy() -> Health {
305 Health::new()
306 .register("database", |_| async { Ok(()) })
307 .register("cache", |_| async { Ok(()) })
308 }
309
310 #[tokio::test]
311 async fn every_check_passing_is_ok() {
312 let report = healthy().run(app()).await;
313
314 assert!(report.is_healthy());
315 assert_eq!(report.status_code(), StatusCode::OK);
316 assert_eq!(report.checks.len(), 2);
317 assert!(report.failing().is_empty());
318 }
319
320 #[tokio::test]
321 async fn one_failing_check_fails_the_report() {
322 let health =
323 healthy().register("search", |_| async { Err(Error::internal("connection refused")) });
324
325 let report = health.run(app()).await;
326
327 assert!(!report.is_healthy());
328 assert_eq!(report.failing(), vec!["search"]);
329 assert!(report.checks["database"].is_ok());
332 }
333
334 #[tokio::test]
335 async fn a_failing_check_says_why() {
336 let health = Health::new()
337 .register("search", |_| async { Err(Error::internal("connection refused")) });
338
339 let report = health.run(app()).await;
340
341 assert_eq!(report.checks["search"].error.as_deref(), Some("connection refused"));
342 }
343
344 #[tokio::test]
345 async fn the_status_is_503_rather_than_500() {
346 let health = Health::new().register("db", |_| async { Err(Error::internal("down")) });
349
350 assert_eq!(health.run(app()).await.status_code(), StatusCode::SERVICE_UNAVAILABLE);
351 }
352
353 #[tokio::test]
354 async fn a_hanging_check_fails_at_its_deadline() {
355 let health =
358 Health::new().timeout(Some(Duration::from_millis(30))).register("slow", |_| async {
359 tokio::time::sleep(Duration::from_secs(30)).await;
360 Ok(())
361 });
362
363 let report = health.run(app()).await;
364
365 assert!(!report.is_healthy());
366 assert!(report.checks["slow"].error.as_deref().unwrap().contains("30ms"));
367 }
368
369 #[tokio::test]
370 async fn checks_run_concurrently() {
371 let health = (0..6).fold(Health::new(), |health, i| {
374 health.register(format!("slow-{i}"), |_| async {
375 tokio::time::sleep(Duration::from_millis(80)).await;
376 Ok(())
377 })
378 });
379
380 let started = Instant::now();
381 let report = health.run(app()).await;
382
383 assert!(report.is_healthy());
384 assert!(
385 started.elapsed() < Duration::from_millis(400),
386 "took {:?}, so they ran one after another",
387 started.elapsed()
388 );
389 }
390
391 #[tokio::test]
392 async fn each_check_is_timed() {
393 let health = Health::new().register("slow", |_| async {
394 tokio::time::sleep(Duration::from_millis(40)).await;
395 Ok(())
396 });
397
398 assert!(health.run(app()).await.checks["slow"].duration_ms >= 30);
399 }
400
401 #[tokio::test]
402 async fn the_build_travels_with_the_report() {
403 let health = healthy().describing_build(json!({ "version": "2.4.1" }));
404
405 let body = health.render(app()).await.into_string().await.unwrap();
406 let document: Value = serde_json::from_str(&body).unwrap();
407
408 assert_eq!(document["build"]["version"], "2.4.1");
409 assert_eq!(document["status"], "ok");
410 }
411
412 #[tokio::test]
413 async fn an_empty_registry_is_healthy_and_says_nothing() {
414 let report = Health::new().run(app()).await;
417
418 assert!(report.is_healthy());
419 assert!(report.checks.is_empty());
420 }
421
422 #[tokio::test]
423 async fn a_check_can_resolve_from_the_application() {
424 struct Thing(&'static str);
425
426 let app = app();
427 app.instance(Thing("bound"));
428
429 let health = Health::new().register("thing", |app| async move {
430 match app.resolve::<Thing>() {
431 Ok(thing) if thing.0 == "bound" => Ok(()),
432 _ => Err(Error::internal("not bound")),
433 }
434 });
435
436 assert!(health.run(app).await.is_healthy());
437 }
438
439 #[tokio::test]
440 async fn a_panicking_check_does_not_take_the_probe_with_it() {
441 let health = Health::new()
446 .register("fine", |_| async { Ok(()) })
447 .register("panics", |_| async { panic!("this check is broken") });
448
449 let report = health.run(app()).await;
450
451 assert!(!report.is_healthy());
452 assert_eq!(report.failing(), vec!["panics"]);
453 assert!(report.checks["fine"].is_ok(), "the others still reported");
454 assert_eq!(report.checks["panics"].error.as_deref(), Some("the check panicked"));
455 }
456}