lmrc_http_common/
health.rs1use async_trait::async_trait;
32use axum::{http::StatusCode, response::IntoResponse, Json};
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::sync::Arc;
36use std::time::Instant;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Status {
42 Healthy,
44 Degraded,
46 Unhealthy,
48}
49
50impl Status {
51 pub fn status_code(&self) -> StatusCode {
53 match self {
54 Status::Healthy => StatusCode::OK,
55 Status::Degraded => StatusCode::OK, Status::Unhealthy => StatusCode::SERVICE_UNAVAILABLE,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct CheckResult {
64 pub status: Status,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub message: Option<String>,
69 pub duration_ms: u64,
71}
72
73impl CheckResult {
74 pub fn healthy() -> Self {
76 Self {
77 status: Status::Healthy,
78 message: None,
79 duration_ms: 0,
80 }
81 }
82
83 pub fn healthy_with_message(message: impl Into<String>) -> Self {
85 Self {
86 status: Status::Healthy,
87 message: Some(message.into()),
88 duration_ms: 0,
89 }
90 }
91
92 pub fn unhealthy(message: impl Into<String>) -> Self {
94 Self {
95 status: Status::Unhealthy,
96 message: Some(message.into()),
97 duration_ms: 0,
98 }
99 }
100
101 pub fn degraded(message: impl Into<String>) -> Self {
103 Self {
104 status: Status::Degraded,
105 message: Some(message.into()),
106 duration_ms: 0,
107 }
108 }
109
110 pub fn with_duration(mut self, duration_ms: u64) -> Self {
112 self.duration_ms = duration_ms;
113 self
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct HealthStatus {
120 pub status: Status,
122 pub version: String,
124 pub checks: HashMap<String, CheckResult>,
126}
127
128impl HealthStatus {
129 pub fn new(version: impl Into<String>) -> Self {
131 Self {
132 status: Status::Healthy,
133 version: version.into(),
134 checks: HashMap::new(),
135 }
136 }
137
138 pub fn with_check(mut self, name: impl Into<String>, result: CheckResult) -> Self {
140 self.checks.insert(name.into(), result);
141 self.update_overall_status();
142 self
143 }
144
145 fn update_overall_status(&mut self) {
147 if self.checks.is_empty() {
148 self.status = Status::Healthy;
149 return;
150 }
151
152 let has_unhealthy = self.checks.values().any(|c| c.status == Status::Unhealthy);
153 let has_degraded = self.checks.values().any(|c| c.status == Status::Degraded);
154
155 self.status = if has_unhealthy {
156 Status::Unhealthy
157 } else if has_degraded {
158 Status::Degraded
159 } else {
160 Status::Healthy
161 };
162 }
163
164 pub fn status_code(&self) -> StatusCode {
166 self.status.status_code()
167 }
168}
169
170impl IntoResponse for HealthStatus {
171 fn into_response(self) -> axum::response::Response {
172 let status_code = self.status_code();
173 (status_code, Json(self)).into_response()
174 }
175}
176
177#[async_trait]
179pub trait HealthCheck: Send + Sync {
180 async fn check(&self) -> CheckResult;
182
183 fn name(&self) -> &str;
185}
186
187pub struct HealthChecker {
189 checks: Vec<Arc<dyn HealthCheck>>,
190 version: String,
191}
192
193impl HealthChecker {
194 pub fn new(version: impl Into<String>) -> Self {
196 Self {
197 checks: Vec::new(),
198 version: version.into(),
199 }
200 }
201
202 pub fn add_check(mut self, check: Arc<dyn HealthCheck>) -> Self {
204 self.checks.push(check);
205 self
206 }
207
208 pub async fn check_health(&self) -> HealthStatus {
210 let mut status = HealthStatus::new(&self.version);
211
212 for check in &self.checks {
213 let start = Instant::now();
214 let mut result = check.check().await;
215 result.duration_ms = start.elapsed().as_millis() as u64;
216
217 status = status.with_check(check.name(), result);
218 }
219
220 status
221 }
222}
223
224pub struct AlwaysHealthyCheck {
226 name: String,
227}
228
229impl AlwaysHealthyCheck {
230 pub fn new(name: impl Into<String>) -> Self {
231 Self { name: name.into() }
232 }
233}
234
235#[async_trait]
236impl HealthCheck for AlwaysHealthyCheck {
237 async fn check(&self) -> CheckResult {
238 CheckResult::healthy()
239 }
240
241 fn name(&self) -> &str {
242 &self.name
243 }
244}
245
246pub async fn health_handler(
267 axum::extract::State(checker): axum::extract::State<Arc<HealthChecker>>,
268) -> impl IntoResponse {
269 checker.check_health().await
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn test_status_code() {
278 assert_eq!(Status::Healthy.status_code(), StatusCode::OK);
279 assert_eq!(Status::Degraded.status_code(), StatusCode::OK);
280 assert_eq!(
281 Status::Unhealthy.status_code(),
282 StatusCode::SERVICE_UNAVAILABLE
283 );
284 }
285
286 #[test]
287 fn test_check_result_builders() {
288 let healthy = CheckResult::healthy();
289 assert_eq!(healthy.status, Status::Healthy);
290 assert!(healthy.message.is_none());
291
292 let unhealthy = CheckResult::unhealthy("Database down");
293 assert_eq!(unhealthy.status, Status::Unhealthy);
294 assert_eq!(unhealthy.message, Some("Database down".to_string()));
295
296 let degraded = CheckResult::degraded("Cache unavailable");
297 assert_eq!(degraded.status, Status::Degraded);
298 }
299
300 #[test]
301 fn test_health_status_overall() {
302 let mut status = HealthStatus::new("1.0.0");
303
304 status = status
306 .with_check("db", CheckResult::healthy())
307 .with_check("cache", CheckResult::healthy());
308 assert_eq!(status.status, Status::Healthy);
309
310 let mut status = HealthStatus::new("1.0.0");
312 status = status
313 .with_check("db", CheckResult::healthy())
314 .with_check("cache", CheckResult::degraded("Slow response"));
315 assert_eq!(status.status, Status::Degraded);
316
317 let mut status = HealthStatus::new("1.0.0");
319 status = status
320 .with_check("db", CheckResult::unhealthy("Connection refused"))
321 .with_check("cache", CheckResult::healthy());
322 assert_eq!(status.status, Status::Unhealthy);
323 }
324
325 #[tokio::test]
326 async fn test_health_checker() {
327 let checker = HealthChecker::new("1.0.0")
328 .add_check(Arc::new(AlwaysHealthyCheck::new("test")));
329
330 let health = checker.check_health().await;
331 assert_eq!(health.status, Status::Healthy);
332 assert_eq!(health.version, "1.0.0");
333 assert!(health.checks.contains_key("test"));
334 }
335
336 #[tokio::test]
337 async fn test_always_healthy_check() {
338 let check = AlwaysHealthyCheck::new("test");
339 assert_eq!(check.name(), "test");
340
341 let result = check.check().await;
342 assert_eq!(result.status, Status::Healthy);
343 }
344}