use std::collections::HashMap;
use std::time::SystemTime;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", content = "reason", rename_all = "lowercase")]
pub enum HealthStatus {
Healthy,
Degraded(String),
Unhealthy(String),
}
impl HealthStatus {
pub const fn is_healthy(&self) -> bool {
matches!(self, Self::Healthy)
}
pub const fn is_operational(&self) -> bool {
!matches!(self, Self::Unhealthy(_))
}
}
impl std::fmt::Display for HealthStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Healthy => write!(f, "healthy"),
Self::Degraded(r) => write!(f, "degraded: {r}"),
Self::Unhealthy(r) => write!(f, "unhealthy: {r}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentHealth {
pub name: String,
pub status: HealthStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl ComponentHealth {
pub fn healthy(name: impl Into<String>) -> Self {
Self {
name: name.into(),
status: HealthStatus::Healthy,
details: None,
}
}
pub fn degraded(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
name: name.into(),
status: HealthStatus::Degraded(reason.into()),
details: None,
}
}
pub fn unhealthy(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
name: name.into(),
status: HealthStatus::Unhealthy(reason.into()),
details: None,
}
}
#[must_use]
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthReport {
pub overall: HealthStatus,
pub components: Vec<ComponentHealth>,
#[serde(with = "system_time_serde")]
pub checked_at: SystemTime,
}
impl HealthReport {
pub fn is_ready(&self) -> bool {
self.components.iter().all(|c| c.status.is_operational())
}
pub fn is_live(&self) -> bool {
if self.components.is_empty() {
return true;
}
self.components.iter().any(|c| c.status.is_operational())
}
pub fn http_status_code(&self) -> u16 {
if self.is_ready() { 200 } else { 503 }
}
}
mod system_time_serde {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::{SystemTime, UNIX_EPOCH};
pub fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
s.serialize_u64(secs)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
let secs = u64::deserialize(d)?;
Ok(UNIX_EPOCH + std::time::Duration::from_secs(secs))
}
}
pub struct HealthReporter {
components: RwLock<HashMap<String, ComponentHealth>>,
}
impl HealthReporter {
pub fn new() -> Self {
Self {
components: RwLock::new(HashMap::new()),
}
}
pub fn register(&self, name: impl Into<String>, status: HealthStatus) {
let name = name.into();
let component = ComponentHealth {
name: name.clone(),
status,
details: None,
};
self.components.write().insert(name, component);
}
pub fn register_component(&self, component: ComponentHealth) {
self.components
.write()
.insert(component.name.clone(), component);
}
pub fn deregister(&self, name: &str) {
self.components.write().remove(name);
}
pub fn report(&self) -> HealthReport {
let components: Vec<ComponentHealth> = self.components.read().values().cloned().collect();
let overall = aggregate_status(&components);
HealthReport {
overall,
components,
checked_at: SystemTime::now(),
}
}
}
impl Default for HealthReporter {
fn default() -> Self {
Self::new()
}
}
fn aggregate_status(components: &[ComponentHealth]) -> HealthStatus {
let mut worst = HealthStatus::Healthy;
for c in components {
match &c.status {
HealthStatus::Unhealthy(r) => {
return HealthStatus::Unhealthy(r.clone());
}
HealthStatus::Degraded(r) => {
if worst == HealthStatus::Healthy {
worst = HealthStatus::Degraded(r.clone());
}
}
HealthStatus::Healthy => {}
}
}
worst
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn healthy_status_is_healthy() {
assert!(HealthStatus::Healthy.is_healthy());
assert!(HealthStatus::Healthy.is_operational());
}
#[test]
fn degraded_status_is_not_healthy_but_operational() {
let s = HealthStatus::Degraded("reason".into());
assert!(!s.is_healthy());
assert!(s.is_operational());
}
#[test]
fn unhealthy_status_is_not_operational() {
let s = HealthStatus::Unhealthy("crashed".into());
assert!(!s.is_healthy());
assert!(!s.is_operational());
}
#[test]
fn empty_reporter_overall_is_healthy() {
let reporter = HealthReporter::new();
assert_eq!(reporter.report().overall, HealthStatus::Healthy);
}
#[test]
fn all_healthy_report_is_ready_and_live() {
let r = HealthReporter::new();
r.register("db", HealthStatus::Healthy);
r.register("cache", HealthStatus::Healthy);
let report = r.report();
assert!(report.is_ready());
assert!(report.is_live());
assert_eq!(report.http_status_code(), 200);
}
#[test]
fn degraded_component_report_not_ready_but_still_live() {
let r = HealthReporter::new();
r.register("db", HealthStatus::Healthy);
r.register("cache", HealthStatus::Degraded("high latency".into()));
let report = r.report();
assert!(report.is_ready());
assert!(report.is_live());
}
#[test]
fn unhealthy_component_makes_report_not_ready() {
let r = HealthReporter::new();
r.register("db", HealthStatus::Unhealthy("connection refused".into()));
let report = r.report();
assert!(!report.is_ready());
assert_eq!(report.http_status_code(), 503);
}
#[test]
fn all_unhealthy_not_live() {
let r = HealthReporter::new();
r.register("a", HealthStatus::Unhealthy("x".into()));
r.register("b", HealthStatus::Unhealthy("y".into()));
assert!(!r.report().is_live());
}
#[test]
fn register_same_component_updates_status() {
let r = HealthReporter::new();
r.register("db", HealthStatus::Healthy);
r.register("db", HealthStatus::Unhealthy("disk full".into()));
let report = r.report();
assert_eq!(report.components.len(), 1);
assert!(!report.is_ready());
}
#[test]
fn deregister_removes_component() {
let r = HealthReporter::new();
r.register("db", HealthStatus::Healthy);
r.deregister("db");
assert!(r.report().components.is_empty());
}
#[test]
fn component_health_builders() {
assert!(ComponentHealth::healthy("x").status.is_healthy());
assert!(
ComponentHealth::degraded("x", "slow")
.status
.is_operational()
);
assert!(
!ComponentHealth::unhealthy("x", "down")
.status
.is_operational()
);
}
#[test]
fn component_with_details_serializes() {
let c = ComponentHealth::healthy("pool").with_details(serde_json::json!({ "idle": 8 }));
assert!(c.details.is_some());
let json = serde_json::to_string(&c).unwrap();
assert!(json.contains("idle"));
}
#[test]
fn health_report_serializes_to_json() {
let r = HealthReporter::new();
r.register("db", HealthStatus::Healthy);
let report = r.report();
let json = serde_json::to_string(&report).expect("serialize");
assert!(json.contains("healthy"));
}
#[test]
fn aggregate_status_worst_wins() {
let components = vec![
ComponentHealth::healthy("a"),
ComponentHealth::degraded("b", "slow"),
ComponentHealth::unhealthy("c", "down"),
];
let status = aggregate_status(&components);
assert!(matches!(status, HealthStatus::Unhealthy(_)));
}
}