use crate::handler::BoxFuture;
use crate::plugin::{Plugin, Setup};
use crate::request::Request;
use crate::response::Response;
use crate::status::Status;
use rustlavel_core::Json;
use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};
type CheckFn = Arc<dyn Fn(&Request) -> BoxFuture<Result<(), String>> + Send + Sync>;
#[derive(Clone)]
pub struct Health {
path: String,
checks: Vec<(String, CheckFn)>,
timeout: Duration,
}
impl Default for Health {
fn default() -> Self {
Self::new()
}
}
impl Health {
pub fn new() -> Self {
Health { path: "/up".to_string(), checks: Vec::new(), timeout: Duration::from_secs(5) }
}
pub fn at(mut self, path: &str) -> Self {
self.path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
self
}
pub fn check<F, Fut>(mut self, name: &str, check: F) -> Self
where
F: Fn(&Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<(), String>> + Send + 'static,
{
self.checks.push((name.to_string(), Arc::new(move |req| Box::pin(check(req)))));
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
async fn readiness(checks: Vec<(String, CheckFn)>, timeout: Duration, request: Request) -> Response {
let futures: Vec<_> = checks
.iter()
.map(|(name, check)| {
let name = name.clone();
let future = check(&request);
async move {
let started = Instant::now();
let outcome = match tokio::time::timeout(timeout, future).await {
Ok(Ok(())) => Ok(()),
Ok(Err(reason)) => Err(reason),
Err(_) => Err(format!("no answer within {} ms", timeout.as_millis())),
};
(name, outcome, started.elapsed())
}
})
.collect();
let results = join_all(futures).await;
let healthy = results.iter().all(|(_, outcome, _)| outcome.is_ok());
let checks = Json::object(results.into_iter().map(|(name, outcome, took)| {
let mut fields = vec![
("status", Json::from(if outcome.is_ok() { "ok" } else { "failed" })),
("duration_ms", Json::Number(took.as_secs_f64() * 1000.0)),
];
if let Err(reason) = outcome {
fields.push(("error", Json::from(reason)));
}
(name, Json::object(fields))
}));
let body = Json::object([
("status", Json::from(if healthy { "ok" } else { "failed" })),
("checks", checks),
]);
let status = if healthy { Status::OK } else { Status::SERVICE_UNAVAILABLE };
Response::new(status).with_json(body).with_header("cache-control", "no-store")
}
}
async fn join_all<F: Future>(futures: Vec<F>) -> Vec<F::Output> {
let mut handles = Vec::with_capacity(futures.len());
for future in futures {
handles.push(Box::pin(future));
}
let mut results: Vec<Option<F::Output>> = (0..handles.len()).map(|_| None).collect();
std::future::poll_fn(|cx| {
let mut pending = false;
for (slot, handle) in results.iter_mut().zip(handles.iter_mut()) {
if slot.is_none() {
match handle.as_mut().poll(cx) {
std::task::Poll::Ready(value) => *slot = Some(value),
std::task::Poll::Pending => pending = true,
}
}
}
if pending { std::task::Poll::Pending } else { std::task::Poll::Ready(()) }
})
.await;
results.into_iter().map(|slot| slot.expect("every future completed")).collect()
}
impl Plugin for Health {
fn name(&self) -> &'static str {
"health"
}
fn register(self: Box<Self>, setup: &mut Setup<'_>) {
let ready_path = format!("{}/ready", self.path);
let checks = self.checks;
let timeout = self.timeout;
setup
.router
.get(&self.path, |_req: Request| async {
Response::json(Json::object([("status", Json::from("ok"))]))
.with_header("cache-control", "no-store")
})
.name("health.up")
.describe("Liveness: the process is running and answering")
.tag("Health");
setup
.router
.get(&ready_path, move |req: Request| Health::readiness(checks.clone(), timeout, req))
.name("health.ready")
.describe("Readiness: every dependency check passes")
.responds(503, "At least one check failed")
.tag("Health");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::Router;
use crate::testing::TestClient;
fn client(health: Health) -> TestClient {
let mut router = Router::new();
let config = rustlavel_core::Config::new();
let mut context = Some(rustlavel_core::Context::builder());
let mut setup = Setup { router: &mut router, config: &config, context: &mut context };
Box::new(health).register(&mut setup);
TestClient::new(router)
}
#[tokio::test]
async fn liveness_is_always_ok() {
let response = client(Health::new()).get("/up").await;
let response = response.assert_ok();
assert_eq!(response.json().get("status").and_then(Json::as_str), Some("ok"));
assert_eq!(response.header("cache-control"), Some("no-store"));
}
#[tokio::test]
async fn readiness_with_no_checks_is_ok() {
let response = client(Health::new()).get("/up/ready").await;
response.assert_ok().assert_json("status", "ok");
}
#[tokio::test]
async fn readiness_reports_each_check_and_fails_if_any_does() {
let health = Health::new()
.check("database", |_req| async { Ok(()) })
.check("cache", |_req| async { Err("connection refused".to_string()) });
let response = client(health).get("/up/ready").await;
let response = response.assert_status(503);
let body = response.json();
assert_eq!(body.get("status").and_then(Json::as_str), Some("failed"));
assert_eq!(body.get("checks.database.status").and_then(Json::as_str), Some("ok"));
assert_eq!(body.get("checks.cache.status").and_then(Json::as_str), Some("failed"));
assert_eq!(body.get("checks.cache.error").and_then(Json::as_str), Some("connection refused"));
assert!(body.get("checks.database.duration_ms").is_some());
}
#[tokio::test]
async fn a_hanging_check_is_reported_as_failed_not_waited_for() {
let health = Health::new()
.timeout(Duration::from_millis(50))
.check("slow", |_req| async {
tokio::time::sleep(Duration::from_secs(30)).await;
Ok(())
});
let started = Instant::now();
let response = client(health).get("/up/ready").await;
assert!(started.elapsed() < Duration::from_secs(5), "the probe must not hang with the check");
let response = response.assert_status(503);
let error = response.json().get("checks.slow.error").and_then(Json::as_str).unwrap().to_string();
assert!(error.contains("no answer within 50 ms"), "{error}");
}
#[tokio::test]
async fn checks_run_concurrently() {
let mut health = Health::new();
for i in 0..5 {
health = health.check(&format!("dep{i}"), |_req| async {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(())
});
}
let started = Instant::now();
client(health).get("/up/ready").await.assert_ok();
assert!(started.elapsed() < Duration::from_millis(400), "took {:?}", started.elapsed());
}
#[tokio::test]
async fn a_check_can_reach_application_state() {
struct Flag(bool);
let health = Health::new().check("flag", |req| {
let ok = req.state::<Flag>().map(|f| f.0);
async move { if ok == Some(true) { Ok(()) } else { Err("flag not set".to_string()) } }
});
let mut router = Router::new();
let config = rustlavel_core::Config::new();
let mut context = Some(rustlavel_core::Context::builder().state(Flag(true)));
let mut setup = Setup { router: &mut router, config: &config, context: &mut context };
Box::new(health).register(&mut setup);
let client = TestClient::new(router).with_context(context.unwrap().build());
client.get("/up/ready").await.assert_ok();
}
#[tokio::test]
async fn the_path_is_configurable() {
let client = client(Health::new().at("healthz"));
client.get("/healthz").await.assert_ok();
client.get("/healthz/ready").await.assert_ok();
client.get("/up").await.assert_not_found();
}
}