use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use fraiseql_core::db::traits::DatabaseAdapter;
use serde::Serialize;
use tracing::{debug, error};
use crate::routes::graphql::AppState;
#[derive(Debug, Serialize)]
pub struct HealthResponse {
pub status: String,
pub database: DatabaseStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub observers: Option<ObserverHealth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache: Option<CacheHealth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secrets: Option<SecretsHealth>,
#[cfg(feature = "federation")]
#[serde(skip_serializing_if = "Option::is_none")]
pub federation: Option<FederationHealth>,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_hash: Option<String>,
}
#[cfg(feature = "federation")]
#[derive(Debug, Serialize)]
pub struct FederationHealth {
pub configured: bool,
pub subgraphs: Vec<crate::federation::circuit_breaker::SubgraphCircuitHealth>,
}
#[derive(Debug, Serialize)]
pub struct ObserverHealth {
pub running: bool,
pub pending_events: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CacheHealth {
pub connected: bool,
pub backend: String,
}
#[derive(Debug, Serialize)]
pub struct SecretsHealth {
pub connected: bool,
pub backend: String,
}
#[derive(Debug, Serialize)]
pub struct ReadinessResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DatabaseStatus {
pub connected: bool,
pub database_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_connections: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idle_connections: Option<usize>,
}
#[cfg(feature = "federation")]
#[derive(Debug, Serialize)]
pub struct FederationHealthResponse {
pub status: String,
pub subgraphs: Vec<crate::federation::SubgraphHealthStatus>,
pub timestamp: String,
}
pub async fn health_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
) -> impl IntoResponse {
debug!("Health check requested");
let executor = state.executor();
let health_result = executor.adapter().health_check().await;
let db_healthy = health_result.is_ok();
let adapter = executor.adapter();
let db_type = adapter.database_type();
let metrics = adapter.pool_metrics();
let database = if db_healthy {
DatabaseStatus {
connected: true,
database_type: format!("{db_type:?}"),
active_connections: Some(metrics.active_connections as usize),
idle_connections: Some(metrics.idle_connections as usize),
}
} else {
error!("Database health check failed: {:?}", health_result.err());
DatabaseStatus {
connected: false,
database_type: format!("{db_type:?}"),
active_connections: Some(metrics.active_connections as usize),
idle_connections: Some(metrics.idle_connections as usize),
}
};
let schema_hash = Some(executor.schema().content_hash());
#[cfg(feature = "federation")]
let federation = state.circuit_breaker.as_ref().map(|cb| FederationHealth {
configured: true,
subgraphs: cb.health_snapshot(),
});
#[cfg(feature = "observers")]
let observers = if let Some(ref runtime) = state.observer_runtime {
let rt = runtime.read().await;
let health = rt.health();
#[allow(clippy::cast_possible_truncation)]
let pending = health.events_processed as usize;
Some(ObserverHealth {
running: health.running,
pending_events: pending,
last_error: if health.errors > 0 {
Some(format!("{} errors encountered", health.errors))
} else {
None
},
})
} else {
None
};
#[cfg(not(feature = "observers"))]
let observers: Option<ObserverHealth> = None;
#[cfg(feature = "arrow")]
let cache = state.cache.as_ref().map(|_| CacheHealth {
connected: true, backend: "in-memory".to_string(),
});
#[cfg(not(feature = "arrow"))]
let cache: Option<CacheHealth> = None;
#[cfg(feature = "secrets")]
let secrets = if let Some(ref sm) = state.secrets_manager {
let connected = sm.health_check().await.is_ok();
Some(SecretsHealth {
connected,
backend: sm.backend_name().to_string(),
})
} else {
None
};
#[cfg(not(feature = "secrets"))]
let secrets: Option<SecretsHealth> = None;
#[cfg(feature = "federation")]
let status =
determine_status(db_healthy, observers.as_ref(), secrets.as_ref(), federation.as_ref());
#[cfg(not(feature = "federation"))]
let status = determine_status(db_healthy, observers.as_ref(), secrets.as_ref());
let response = HealthResponse {
status: status.to_string(),
database,
observers,
cache,
secrets,
#[cfg(feature = "federation")]
federation,
version: env!("CARGO_PKG_VERSION").to_string(),
schema_hash,
};
let status_code = if status == "unhealthy" {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::OK
};
(status_code, Json(response))
}
pub async fn readiness_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
) -> impl IntoResponse {
debug!("Readiness check requested");
let db_healthy = state.executor().adapter().health_check().await.is_ok();
if db_healthy {
(
StatusCode::OK,
Json(ReadinessResponse {
status: "ready".to_string(),
reason: None,
}),
)
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ReadinessResponse {
status: "not_ready".to_string(),
reason: Some("Database connection unavailable".to_string()),
}),
)
}
}
#[cfg(feature = "federation")]
pub async fn federation_health_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
) -> impl IntoResponse {
debug!("Federation health check requested");
let executor = state.executor();
let schema = executor.schema();
let (status, status_code) = match schema.federation.as_ref() {
Some(fed) if fed.enabled => ("healthy", StatusCode::OK),
_ => ("not_configured", StatusCode::OK),
};
let subgraphs = state.circuit_breaker.as_ref().map_or_else(Vec::new, |cb| {
cb.health_snapshot()
.into_iter()
.map(|entry| {
let available = matches!(
entry.state,
crate::federation::circuit_breaker::CircuitHealthState::Closed
| crate::federation::circuit_breaker::CircuitHealthState::HalfOpen
);
crate::federation::SubgraphHealthStatus {
name: entry.subgraph,
available,
latency_ms: 0.0,
last_check: chrono::Utc::now().to_rfc3339(),
error_count_last_60s: 0,
error_rate_percent: 0.0,
}
})
.collect()
});
let response = FederationHealthResponse {
status: status.to_string(),
subgraphs,
timestamp: chrono::Utc::now().to_rfc3339(),
};
(status_code, Json(response))
}
#[cfg(feature = "federation")]
fn determine_status(
db_healthy: bool,
observers: Option<&ObserverHealth>,
secrets: Option<&SecretsHealth>,
federation: Option<&FederationHealth>,
) -> &'static str {
if !db_healthy {
return "unhealthy";
}
let observers_degraded = observers.is_some_and(|o| !o.running);
let secrets_degraded = secrets.is_some_and(|s| !s.connected);
let federation_degraded = federation.is_some_and(|f| {
f.configured
&& f.subgraphs.iter().any(|sg| {
matches!(sg.state, crate::federation::circuit_breaker::CircuitHealthState::Open)
})
});
if observers_degraded || secrets_degraded || federation_degraded {
"degraded"
} else {
"healthy"
}
}
#[cfg(not(feature = "federation"))]
fn determine_status(
db_healthy: bool,
observers: Option<&ObserverHealth>,
secrets: Option<&SecretsHealth>,
) -> &'static str {
if !db_healthy {
return "unhealthy";
}
let observers_degraded = observers.is_some_and(|o| !o.running);
let secrets_degraded = secrets.is_some_and(|s| !s.connected);
if observers_degraded || secrets_degraded {
"degraded"
} else {
"healthy"
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)] #![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_sign_loss)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_possible_wrap)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc)] #![allow(missing_docs)] #![allow(clippy::items_after_statements)]
use super::*;
#[test]
fn test_determine_status_all_healthy() {
#[cfg(feature = "federation")]
assert_eq!(determine_status(true, None, None, None), "healthy");
#[cfg(not(feature = "federation"))]
assert_eq!(determine_status(true, None, None), "healthy");
}
#[test]
fn test_determine_status_db_down_is_unhealthy() {
#[cfg(feature = "federation")]
assert_eq!(determine_status(false, None, None, None), "unhealthy");
#[cfg(not(feature = "federation"))]
assert_eq!(determine_status(false, None, None), "unhealthy");
}
#[test]
fn test_determine_status_observers_not_running_is_degraded() {
let observers = Some(ObserverHealth {
running: false,
pending_events: 0,
last_error: None,
});
#[cfg(feature = "federation")]
assert_eq!(determine_status(true, observers.as_ref(), None, None), "degraded");
#[cfg(not(feature = "federation"))]
assert_eq!(determine_status(true, observers.as_ref(), None), "degraded");
}
#[test]
fn test_determine_status_secrets_disconnected_is_degraded() {
let secrets = Some(SecretsHealth {
connected: false,
backend: "vault".to_string(),
});
#[cfg(feature = "federation")]
assert_eq!(determine_status(true, None, secrets.as_ref(), None), "degraded");
#[cfg(not(feature = "federation"))]
assert_eq!(determine_status(true, None, secrets.as_ref()), "degraded");
}
#[cfg(feature = "federation")]
#[test]
fn test_determine_status_federation_circuit_open_is_degraded() {
use crate::federation::circuit_breaker::{CircuitHealthState, SubgraphCircuitHealth};
let federation = Some(FederationHealth {
configured: true,
subgraphs: vec![SubgraphCircuitHealth {
subgraph: "Product".to_string(),
state: CircuitHealthState::Open,
}],
});
assert_eq!(determine_status(true, None, None, federation.as_ref()), "degraded");
}
#[test]
fn test_determine_status_db_down_overrides_degraded() {
let secrets = Some(SecretsHealth {
connected: false,
backend: "vault".to_string(),
});
#[cfg(feature = "federation")]
assert_eq!(determine_status(false, None, secrets.as_ref(), None), "unhealthy");
#[cfg(not(feature = "federation"))]
assert_eq!(determine_status(false, None, secrets.as_ref()), "unhealthy");
}
#[test]
fn test_health_response_serialization() {
let response = HealthResponse {
status: "healthy".to_string(),
database: DatabaseStatus {
connected: true,
database_type: "PostgreSQL".to_string(),
active_connections: Some(2),
idle_connections: Some(8),
},
observers: None,
cache: None,
secrets: None,
#[cfg(feature = "federation")]
federation: None,
version: "2.0.0-a1".to_string(),
schema_hash: Some("abc123def456abc1".to_string()),
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("healthy"));
assert!(json.contains("PostgreSQL"));
}
#[cfg(feature = "federation")]
#[test]
fn test_health_response_omits_federation_when_none() {
let response = HealthResponse {
status: "healthy".to_string(),
database: DatabaseStatus {
connected: true,
database_type: "PostgreSQL".to_string(),
active_connections: None,
idle_connections: None,
},
observers: None,
cache: None,
secrets: None,
federation: None,
version: "2.0.0".to_string(),
schema_hash: None,
};
let json = serde_json::to_string(&response).unwrap();
assert!(!json.contains("federation"), "federation key must be absent when field is None");
}
#[cfg(feature = "federation")]
#[test]
fn test_health_response_includes_federation_when_present() {
use crate::federation::circuit_breaker::{CircuitHealthState, SubgraphCircuitHealth};
let response = HealthResponse {
status: "healthy".to_string(),
database: DatabaseStatus {
connected: true,
database_type: "PostgreSQL".to_string(),
active_connections: None,
idle_connections: None,
},
observers: None,
cache: None,
secrets: None,
federation: Some(FederationHealth {
configured: true,
subgraphs: vec![SubgraphCircuitHealth {
subgraph: "Product".to_string(),
state: CircuitHealthState::Open,
}],
}),
version: "2.0.0".to_string(),
schema_hash: None,
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("federation"), "federation key must be present");
assert!(json.contains("configured"), "configured field must appear");
assert!(json.contains("Product"), "subgraph name must appear");
assert!(json.contains("open"), "circuit state must be serialised as snake_case");
}
}