pub mod router;
use crate::error::ServiceResult;
use std::sync::Arc;
pub use router::HealthRouter;
#[derive(Debug, Clone)]
pub struct HealthCheckResult {
pub healthy: bool,
pub details: Option<String>,
}
impl HealthCheckResult {
pub fn healthy() -> Self {
Self {
healthy: true,
details: None,
}
}
pub fn unhealthy(details: impl Into<String>) -> Self {
Self {
healthy: false,
details: Some(details.into()),
}
}
pub fn is_healthy(&self) -> bool {
self.healthy
}
}
pub trait HealthCheck: Send + Sync + 'static {
fn name(&self) -> &str;
fn check(
&self,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = ServiceResult<HealthCheckResult>> + Send + 'static>,
>;
}
#[cfg(feature = "sqlx-postgres")]
pub struct DatabaseHealthCheck {
pool: sqlx::PgPool,
}
#[cfg(feature = "sqlx-postgres")]
impl DatabaseHealthCheck {
pub fn new(pool: sqlx::PgPool) -> Self {
Self { pool }
}
}
#[cfg(feature = "sqlx-postgres")]
impl HealthCheck for DatabaseHealthCheck {
fn name(&self) -> &str {
"database"
}
fn check(
&self,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = ServiceResult<HealthCheckResult>> + Send + 'static>,
> {
let pool = self.pool.clone();
Box::pin(async move {
let timeout = tokio::time::timeout(
std::time::Duration::from_secs(2),
sqlx::query("SELECT 1").execute(&pool),
)
.await;
match timeout {
Ok(Ok(_)) => Ok(HealthCheckResult::healthy()),
Ok(Err(e)) => Ok(HealthCheckResult::unhealthy(format!("query failed: {e}"))),
Err(_) => Ok(HealthCheckResult::unhealthy("timed out after 2s")),
}
})
}
}
#[cfg(feature = "auth")]
pub struct PermissionHealthCheck {
client: crate::middleware::auth::authorization::AuthorizationClient,
}
#[cfg(feature = "auth")]
impl PermissionHealthCheck {
pub fn new(client: crate::middleware::auth::authorization::AuthorizationClient) -> Self {
Self { client }
}
}
#[cfg(feature = "auth")]
impl HealthCheck for PermissionHealthCheck {
fn name(&self) -> &str {
"authorization"
}
fn check(
&self,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = ServiceResult<HealthCheckResult>> + Send + 'static>,
> {
let client = self.client.clone();
Box::pin(async move {
let timeout = tokio::time::timeout(
std::time::Duration::from_secs(2),
client.check_permission("__health__", "__probe__", "probe", "__health_probe__"),
)
.await;
match timeout {
Ok(Ok(_))
| Ok(Err(crate::middleware::auth::error::AuthError::AuthorizationBackend(_))) => {
Ok(HealthCheckResult::healthy())
}
Ok(Err(e)) => Ok(HealthCheckResult::unhealthy(format!(
"authorization error: {e}"
))),
Err(_) => Ok(HealthCheckResult::unhealthy("timed out after 2s")),
}
})
}
}
#[cfg(feature = "nats")]
pub struct NatsHealthCheck {
client: async_nats::Client,
}
#[cfg(feature = "nats")]
impl NatsHealthCheck {
pub fn new(client: async_nats::Client) -> Self {
Self { client }
}
}
#[cfg(feature = "nats")]
impl HealthCheck for NatsHealthCheck {
fn name(&self) -> &str {
"nats"
}
fn check(
&self,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = ServiceResult<HealthCheckResult>> + Send + 'static>,
> {
use async_nats::connection::State;
let state = self.client.connection_state();
Box::pin(async move {
if state == State::Connected {
Ok(HealthCheckResult::healthy())
} else {
Ok(HealthCheckResult::unhealthy(format!(
"NATS connection state: {state:?}"
)))
}
})
}
}
pub struct HttpHealthCheck {
client: reqwest::Client,
url: String,
name: String,
}
impl HttpHealthCheck {
pub fn new(name: impl Into<String>, client: reqwest::Client, url: impl Into<String>) -> Self {
Self {
name: name.into(),
client,
url: url.into(),
}
}
pub fn with_url(name: impl Into<String>, url: impl Into<String>) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.expect("build reqwest client for HttpHealthCheck");
Self::new(name, client, url)
}
}
impl HealthCheck for HttpHealthCheck {
fn name(&self) -> &str {
&self.name
}
fn check(
&self,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = ServiceResult<HealthCheckResult>> + Send + 'static>,
> {
let client = self.client.clone();
let url = self.url.clone();
Box::pin(async move {
let timeout =
tokio::time::timeout(std::time::Duration::from_secs(2), client.get(&url).send())
.await;
match timeout {
Ok(Ok(resp)) if resp.status().is_success() => Ok(HealthCheckResult::healthy()),
Ok(Ok(resp)) => Ok(HealthCheckResult::unhealthy(format!(
"HTTP {} from {url}",
resp.status()
))),
Ok(Err(e)) => Ok(HealthCheckResult::unhealthy(format!("request error: {e}"))),
Err(_) => Ok(HealthCheckResult::unhealthy("timed out after 2s")),
}
})
}
}
pub struct CompositeHealthCheck {
checks: Vec<Arc<dyn HealthCheck>>,
}
impl CompositeHealthCheck {
pub fn new() -> Self {
Self { checks: Vec::new() }
}
pub fn add_check(mut self, check: Arc<dyn HealthCheck>) -> Self {
self.checks.push(check);
self
}
}
impl Default for CompositeHealthCheck {
fn default() -> Self {
Self::new()
}
}
impl Clone for CompositeHealthCheck {
fn clone(&self) -> Self {
Self {
checks: self.checks.clone(),
}
}
}
impl HealthCheck for CompositeHealthCheck {
fn name(&self) -> &str {
"composite"
}
fn check(
&self,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = ServiceResult<HealthCheckResult>> + Send + 'static>,
> {
let checks = self.checks.clone();
Box::pin(async move {
for check in &checks {
let result = check.check().await?;
if !result.is_healthy() {
return Ok(result);
}
}
Ok(HealthCheckResult::healthy())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn test_health_check_result_healthy() {
let result = HealthCheckResult::healthy();
assert!(result.is_healthy());
assert!(result.details.is_none());
}
#[test]
fn test_health_check_result_unhealthy() {
let result = HealthCheckResult::unhealthy("Connection failed");
assert!(!result.is_healthy());
assert_eq!(result.details, Some("Connection failed".to_string()));
}
struct AlwaysHealthy;
impl HealthCheck for AlwaysHealthy {
fn name(&self) -> &str {
"always-healthy"
}
fn check(
&self,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = ServiceResult<HealthCheckResult>> + Send + 'static,
>,
> {
Box::pin(async { Ok(HealthCheckResult::healthy()) })
}
}
struct AlwaysUnhealthy;
impl HealthCheck for AlwaysUnhealthy {
fn name(&self) -> &str {
"always-unhealthy"
}
fn check(
&self,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = ServiceResult<HealthCheckResult>> + Send + 'static,
>,
> {
Box::pin(async { Ok(HealthCheckResult::unhealthy("injected failure")) })
}
}
#[tokio::test]
async fn test_composite_all_healthy() {
let composite = CompositeHealthCheck::new()
.add_check(Arc::new(AlwaysHealthy))
.add_check(Arc::new(AlwaysHealthy));
let result = composite.check().await.unwrap();
assert!(result.is_healthy());
}
#[tokio::test]
async fn test_composite_short_circuits_on_first_failure() {
let composite = CompositeHealthCheck::new()
.add_check(Arc::new(AlwaysUnhealthy))
.add_check(Arc::new(AlwaysHealthy));
let result = composite.check().await.unwrap();
assert!(!result.is_healthy());
assert_eq!(result.details, Some("injected failure".to_string()));
}
#[tokio::test]
async fn test_composite_empty_is_healthy() {
let composite = CompositeHealthCheck::new();
let result = composite.check().await.unwrap();
assert!(result.is_healthy());
}
#[test]
fn test_health_router_new_is_empty() {
let r = HealthRouter::new();
assert!(r.checks.is_empty());
}
#[test]
fn test_health_router_with_check_adds() {
let r = HealthRouter::new()
.with_check(Arc::new(AlwaysHealthy))
.with_check(Arc::new(AlwaysUnhealthy));
assert_eq!(r.checks.len(), 2);
}
#[tokio::test]
async fn test_live_always_200() {
use crate::testing::TestHarness;
let health_router = HealthRouter::new()
.with_check(Arc::new(AlwaysUnhealthy)) .into_axum_router();
let mut harness = TestHarness::new().with_routes(health_router);
harness.start().await.unwrap();
let resp = harness.get("/health/live").await.unwrap();
assert_eq!(resp.status, 200, "live must always be 200");
harness.stop().await.unwrap();
}
#[tokio::test]
async fn test_ready_200_when_all_pass() {
use crate::testing::TestHarness;
let health_router = HealthRouter::new()
.with_check(Arc::new(AlwaysHealthy))
.with_check(Arc::new(AlwaysHealthy))
.into_axum_router();
let mut harness = TestHarness::new().with_routes(health_router);
harness.start().await.unwrap();
let resp = harness.get("/health/ready").await.unwrap();
assert_eq!(resp.status, 200);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(body["status"], "ok");
assert!(body["checks"].is_array());
harness.stop().await.unwrap();
}
#[tokio::test]
async fn test_ready_503_when_one_fails() {
use crate::testing::TestHarness;
let health_router = HealthRouter::new()
.with_check(Arc::new(AlwaysHealthy))
.with_check(Arc::new(AlwaysUnhealthy))
.into_axum_router();
let mut harness = TestHarness::new().with_routes(health_router);
harness.start().await.unwrap();
let resp = harness.get("/health/ready").await.unwrap();
assert_eq!(resp.status, 503);
let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(body["status"], "degraded");
harness.stop().await.unwrap();
}
}