Add health check routes and state management #6

Merged
GW_MC merged 3 commits from feature/health-check into master 2025-12-05 15:15:33 +08:00
2 changed files with 19 additions and 54 deletions
Showing only changes of commit 5210c64c5d - Show all commits

View File

@@ -1,9 +1,8 @@
use std::sync::Arc;
use axum::{Json, extract::State, http::StatusCode};
use chrono::{Duration, Utc};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use crate::routes::api::health::state::HealthState;
@@ -14,43 +13,34 @@ const STATUS_UNHEALTHY: &str = "unhealthy";
pub struct HealthInfo {
pub status: String,
pub version: String,
// timestamp in milliseconds when the service started
pub up_since: String,
// RFC 3339 formatted timestamp
pub up_since: DateTime<Utc>,
pub errors: Option<Vec<String>>,
}
pub async fn get_health_info(
State(state): State<Arc<HealthState>>,
) -> (StatusCode, Json<HealthInfo>) {
#[allow(unused_mut)]
let mut errors = vec![];
let up_since = if *state.get_start_time() > Instant::now() {
errors.push("Invalid up time".into());
// Fall back to reporting 'now' as the up_since timestamp when invalid.
Utc::now()
} else {
let elapsed = state.get_start_time().elapsed();
Utc::now()
- Duration::from_std(elapsed).unwrap_or_else(|_| {
errors.push("Invalid up time".into());
Duration::zero()
})
};
let is_healthy = errors.is_empty();
(
StatusCode::OK,
if is_healthy {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
},
Json(HealthInfo {
status: if errors.is_empty() {
status: if is_healthy {
STATUS_HEALTHY.into()
} else {
STATUS_UNHEALTHY.into()
},
version: env!("CARGO_PKG_VERSION").into(),
up_since: up_since.to_rfc3339(),
errors: if errors.is_empty() {
None
} else {
Some(errors)
},
up_since: *state.get_start_at(),
errors: if is_healthy { None } else { Some(errors) },
}),
)
}
@@ -85,24 +75,4 @@ mod test {
assert_eq!(health_info.version, env!("CARGO_PKG_VERSION"));
assert!(health_info.errors.is_none());
}
#[tokio::test]
async fn test_invalid_up_time() {
let health_state = Arc::new(HealthState::new(
tokio::time::Instant::now() + std::time::Duration::from_secs(60000),
));
let app = Router::new()
.route("/info", axum::routing::get(get_health_info))
.with_state(health_state);
let response = app
.oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); // Set limit to 1 MB
let health_info: HealthInfo = serde_json::from_slice(&body).unwrap();
assert_eq!(health_info.status, STATUS_UNHEALTHY);
assert_eq!(health_info.version, env!("CARGO_PKG_VERSION"));
assert!(health_info.errors.as_ref().is_some_and(|e| !e.is_empty()));
}
}

View File

@@ -1,24 +1,19 @@
use tokio::time::Instant;
use chrono::{DateTime, Utc};
pub struct HealthState {
start_time: Instant,
start_at: DateTime<Utc>,
}
impl Default for HealthState {
fn default() -> Self {
Self {
start_time: Instant::now(),
start_at: Utc::now(),
}
}
}
impl HealthState {
#[allow(dead_code)] // used in tests
pub fn new(start_time: Instant) -> Self {
Self { start_time }
}
pub fn get_start_time(&self) -> &Instant {
&self.start_time
pub fn get_start_at(&self) -> &DateTime<Utc> {
&self.start_at
}
}