use std::sync::{Arc, OnceLock, RwLock};
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
#[derive(Debug, Clone)]
pub struct CheckOutcome {
pub name: String,
pub healthy: bool,
pub details: Option<serde_json::Value>,
}
impl CheckOutcome {
pub fn healthy(name: impl Into<String>) -> Self {
Self {
name: name.into(),
healthy: true,
details: None,
}
}
pub fn unhealthy(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
name: name.into(),
healthy: false,
details: Some(serde_json::json!({ "error": reason.into() })),
}
}
}
pub trait ReadinessCheck: Send + Sync {
fn name(&self) -> &str;
fn check(&self) -> CheckOutcome;
}
pub trait HealthDataSource: Send + Sync {
fn health_json(&self) -> String;
}
struct FnHealthSource<F>(F)
where
F: Fn() -> String + Send + Sync;
impl<F> HealthDataSource for FnHealthSource<F>
where
F: Fn() -> String + Send + Sync,
{
fn health_json(&self) -> String {
(self.0)()
}
}
pub fn register_health_source_fn(f: impl Fn() -> String + Send + Sync + 'static) {
register_health_source(Arc::new(FnHealthSource(f)));
}
static READINESS_CHECKS: OnceLock<RwLock<Vec<Arc<dyn ReadinessCheck>>>> = OnceLock::new();
static HEALTH_SOURCE: OnceLock<RwLock<Option<Arc<dyn HealthDataSource>>>> = OnceLock::new();
fn readiness_checks() -> &'static RwLock<Vec<Arc<dyn ReadinessCheck>>> {
READINESS_CHECKS.get_or_init(|| RwLock::new(Vec::new()))
}
fn health_source() -> &'static RwLock<Option<Arc<dyn HealthDataSource>>> {
HEALTH_SOURCE.get_or_init(|| RwLock::new(None))
}
pub fn register_readiness_check(check: Arc<dyn ReadinessCheck>) {
if let Ok(mut guard) = readiness_checks().write() {
guard.push(check);
}
}
pub fn register_readiness_check_fn(
name: impl Into<String>,
check: impl Fn() -> CheckOutcome + Send + Sync + 'static,
) {
struct FnCheck<F> {
name: String,
check: F,
}
impl<F> ReadinessCheck for FnCheck<F>
where
F: Fn() -> CheckOutcome + Send + Sync,
{
fn name(&self) -> &str {
&self.name
}
fn check(&self) -> CheckOutcome {
(self.check)()
}
}
register_readiness_check(Arc::new(FnCheck {
name: name.into(),
check,
}));
}
pub fn clear_readiness_checks() {
if let Ok(mut guard) = readiness_checks().write() {
guard.clear();
}
}
pub fn register_health_source(source: Arc<dyn HealthDataSource>) {
if let Ok(mut guard) = health_source().write() {
*guard = Some(source);
}
}
pub fn clear_health_source() {
if let Ok(mut guard) = health_source().write() {
*guard = None;
}
}
pub(crate) fn run_readiness_checks() -> (bool, Vec<CheckOutcome>) {
let mut outcomes = Vec::new();
let mut all_healthy = true;
let checks: Vec<Arc<dyn ReadinessCheck>> = match readiness_checks().read() {
Ok(guard) => guard.clone(),
Err(_) => {
log::warn!("readiness checks lock poisoned; running with an empty check set");
Vec::new()
}
};
for check in &checks {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check.check()))
.unwrap_or_else(|_| {
CheckOutcome::unhealthy(check.name(), "check panicked".to_string())
});
if !outcome.healthy {
all_healthy = false;
}
outcomes.push(outcome);
}
let source = match health_source().read() {
Ok(guard) => guard.clone(),
Err(_) => {
log::warn!("health source lock poisoned; skipping the kit aggregate");
None
}
};
if let Some(source) = source {
let payload =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| source.health_json()))
.unwrap_or_else(|_| {
serde_json::json!({"status": "unhealthy", "healthy": false}).to_string()
});
let value: serde_json::Value = serde_json::from_str(&payload)
.unwrap_or_else(|_| serde_json::json!({"status": "unhealthy"}));
let healthy = value
.get("healthy")
.and_then(|h| h.as_bool())
.unwrap_or(false);
if !healthy {
all_healthy = false;
}
outcomes.push(CheckOutcome {
name: "kit".to_string(),
healthy,
details: Some(value),
});
}
(all_healthy, outcomes)
}
pub async fn healthz_handler() -> impl IntoResponse {
Json(serde_json::json!({
"status": "healthy",
"version": env!("CARGO_PKG_VERSION"),
}))
}
pub async fn readyz_handler() -> Response {
let (all_healthy, checks) = run_readiness_checks();
let status = if all_healthy { "ready" } else { "unavailable" };
let body = serde_json::json!({
"status": status,
"checks": checks.iter().map(|c| serde_json::json!({
"name": c.name,
"healthy": c.healthy,
"details": c.details,
})).collect::<Vec<_>>(),
});
let code = if all_healthy {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
(code, Json(body)).into_response()
}
pub(crate) fn mount_probes(router: axum::Router) -> axum::Router {
let mut router = router;
if !crate::http::route_path_taken("/healthz") {
router = router.route("/healthz", axum::routing::get(healthz_handler));
}
if !crate::http::route_path_taken("/readyz") {
router = router.route("/readyz", axum::routing::get(readyz_handler));
}
router
}
#[cfg(all(test, feature = "health"))]
mod tests {
use super::*;
use axum::body::Body;
use tower::ServiceExt;
fn probe_router() -> axum::Router {
axum::Router::new()
.route("/healthz", axum::routing::get(healthz_handler))
.route("/readyz", axum::routing::get(readyz_handler))
}
async fn get(router: axum::Router, uri: &str) -> axum::http::Response<Body> {
router
.oneshot(
axum::http::Request::builder()
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
#[tokio::test]
#[serial_test::serial]
async fn healthz_always_200() {
let resp = get(probe_router(), "/healthz").await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "healthy");
}
#[tokio::test]
#[serial_test::serial]
async fn readyz_ready_without_checks() {
clear_readiness_checks();
clear_health_source();
let resp = get(probe_router(), "/readyz").await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
#[serial_test::serial]
async fn readyz_503_when_any_check_fails() {
clear_readiness_checks();
clear_health_source();
register_readiness_check_fn("dep-a", || CheckOutcome::healthy("dep-a"));
register_readiness_check_fn("dep-b", || {
CheckOutcome::unhealthy("dep-b", "connection refused")
});
let resp = get(probe_router(), "/readyz").await;
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "unavailable");
let names: Vec<&str> = json["checks"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"dep-a") && names.contains(&"dep-b"));
clear_readiness_checks();
}
#[tokio::test]
#[serial_test::serial]
async fn readyz_passing_checks_yield_200() {
clear_readiness_checks();
clear_health_source();
register_readiness_check_fn("ok-dep", || CheckOutcome::healthy("ok-dep"));
let resp = get(probe_router(), "/readyz").await;
assert_eq!(resp.status(), StatusCode::OK);
clear_readiness_checks();
}
#[tokio::test]
#[serial_test::serial]
async fn health_source_unhealthy_forces_503() {
clear_readiness_checks();
clear_health_source();
register_health_source_fn(|| {
serde_json::json!({"status": "unhealthy", "healthy": false, "modules": []}).to_string()
});
let resp = get(probe_router(), "/readyz").await;
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
clear_health_source();
}
#[tokio::test]
#[serial_test::serial]
async fn health_source_healthy_keeps_200() {
clear_readiness_checks();
clear_health_source();
register_health_source_fn(|| {
serde_json::json!({
"status": "healthy",
"healthy": true,
"modules": [{"module": "m", "status": "healthy", "detail": null}]
})
.to_string()
});
let resp = get(probe_router(), "/readyz").await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["checks"][0]["name"], "kit");
assert_eq!(json["checks"][0]["details"]["status"], "healthy");
clear_health_source();
}
#[test]
#[serial_test::serial]
fn panicking_check_is_reported_unhealthy() {
clear_readiness_checks();
clear_health_source();
register_readiness_check_fn("boom", || -> CheckOutcome {
panic!("exploding check");
});
let (all_healthy, checks) = run_readiness_checks();
assert!(!all_healthy);
assert_eq!(checks[0].name, "boom");
assert!(!checks[0].healthy);
clear_readiness_checks();
}
#[test]
fn route_path_taken_detects_registered_path() {
assert!(!crate::http::route_path_taken(
"/__definitely_not_registered__"
));
}
}
#[cfg(feature = "kit")]
pub mod kit_source {
use super::HealthDataSource;
use std::sync::Arc;
use trait_kit::{AsyncKit, AsyncReady};
pub struct KitHealthSource {
kit: Arc<AsyncKit<AsyncReady>>,
}
impl KitHealthSource {
pub fn new(kit: Arc<AsyncKit<AsyncReady>>) -> Self {
Self { kit }
}
}
impl HealthDataSource for KitHealthSource {
fn health_json(&self) -> String {
let report = self.kit.health_report();
let mut worst_rank = 0u8;
let modules: Vec<serde_json::Value> = report
.into_iter()
.map(|(name, status)| {
worst_rank = worst_rank.max(status.severity_rank());
serde_json::json!({
"module": name,
"status": status.as_status_name(),
"detail": status.detail(),
})
})
.collect();
let status = match worst_rank {
0 => "healthy",
1 => "degraded",
_ => "unhealthy",
};
serde_json::json!({
"status": status,
"healthy": worst_rank == 0,
"modules": modules,
})
.to_string()
}
}
pub fn register_kit_health_source(kit: Arc<AsyncKit<AsyncReady>>) {
super::register_health_source(Arc::new(KitHealthSource::new(kit)));
}
#[cfg(all(test, feature = "health"))]
mod tests {
use super::*;
use crate::health::{clear_health_source, run_readiness_checks};
use trait_kit::AsyncKit;
#[tokio::test]
async fn kit_health_source_reports_healthy_without_checkers() {
let kit = AsyncKit::new();
let built = kit.build().await.expect("empty kit builds");
let source = KitHealthSource::new(Arc::new(built));
let json = source.health_json();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["status"], "healthy");
assert_eq!(value["healthy"], true);
}
#[tokio::test]
#[serial_test::serial]
async fn registered_kit_source_folds_into_readyz() {
let kit = AsyncKit::new();
let built = Arc::new(kit.build().await.expect("empty kit builds"));
crate::health::clear_readiness_checks();
clear_health_source();
crate::health::kit_source::register_kit_health_source(built);
let (all_healthy, checks) = run_readiness_checks();
assert!(all_healthy);
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].name, "kit");
clear_health_source();
}
#[test]
fn check_outcome_helpers_shape_payload() {
let bad = super::super::CheckOutcome::unhealthy("db", "down");
assert_eq!(bad.details.unwrap()["error"], "down");
}
}
}