yggdrasil/src/api/health.rs
xfy c836e3e1df
Some checks failed
CI / check (push) Failing after 7m29s
CI / build (push) Has been skipped
feat(api): 新增 healthz/readyz 健康检查端点
新增两个无中间件探针端点,供 Docker HEALTHCHECK 与反向代理/负载
均衡使用:

GET /healthz — liveness 存活探针,进程在跑即 200,不查 DB。
GET /readyz  — readiness 就绪探针,SELECT 1 检测 DB 连通性(2s
超时),不可达返回 503,附带连接池 size/available/max_size/waiting
指标。

路由挂载在 static_routes(无 CSRF/超时/缓存中间件),避免被
add_cache_control 误加 max-age 缓存头导致探针误判。readyz 直接用
DB_POOL.get() 不走 get_conn 的退避重试,保证探针快速失败。

dx check + clippy + 405 tests 全通过。
2026-06-25 16:45:09 +08:00

132 lines
4.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 健康检查端点liveness / readiness
//!
//! 提供两个无中间件、不走 CSRF/缓存/超时层的探针端点,
//! 挂载在 `static_routes` 上,供 Docker HEALTHCHECK 与反向代理/负载均衡使用:
//! - `GET /healthz` — liveness 存活探针。只要进程在跑就返回 200不查 DB。
//! - `GET /readyz` — readiness 就绪探针。执行 `SELECT 1` 检测 DB 连通性,
//! 不可达时返回 503附带连接池指标。
//!
//! 仅在 `server` feature 启用时编译。
#![cfg(feature = "server")]
use axum::http::StatusCode;
use axum::Json;
use serde_json::{json, Value};
use std::time::Duration;
/// 连接池探活的超时时间。
///
/// 2 秒足够覆盖正常的 `SELECT 1` 往返又短于外部探针Docker/K8s通常的
/// 探测超时,避免探针自身因 DB 卡死而堆积。
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// `GET /healthz` — liveness 存活探针。
///
/// 进程在跑即返回 200。不触碰数据库保证即使 DB 故障时探针也能快速响应,
/// 让编排器知道容器本身没死(不需要重启),只是暂时无法服务。
pub async fn healthz() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
/// `GET /readyz` — readiness 就绪探针。
///
/// 流程:
/// 1. 取连接池状态(纯内存快照,无 I/O
/// 2. 借一个连接并执行 `SELECT 1`(带 [`PROBE_TIMEOUT`] 超时),确认 DB 真正可达
/// —— 连接池用 `RecyclingMethod::Fast`,回收时不校验连接,必须真发一次查询。
///
/// 直接用 `DB_POOL.get()` 而非 `get_conn()`:后者有指数退避重试(约 1.6s
/// 探针应当快速失败而非等待重试。
///
/// 返回:
/// - 200 `{status:"ready", db:"ok", pool:{...}}` — 一切正常
/// - 503 `{status:"unready", db:"down"|"error"|"timeout", ...}` — DB 不可达
pub async fn readyz() -> (StatusCode, Json<Value>) {
use crate::db::pool::DB_POOL;
// 连接池状态:纯内存,无 I/O即便 DB 故障也能拿到。
let s = DB_POOL.status();
let pool_info = json!({
"size": s.size,
"available": s.available,
"max_size": s.max_size,
"waiting": s.waiting,
});
// 借连接 + SELECT 1整体限时 PROBE_TIMEOUT。
match tokio::time::timeout(PROBE_TIMEOUT, DB_POOL.get()).await {
Ok(Ok(conn)) => match conn.simple_query("SELECT 1").await {
Ok(_) => (
StatusCode::OK,
Json(json!({ "status": "ready", "db": "ok", "pool": pool_info })),
),
Err(e) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"status": "unready",
"db": "error",
"error": e.to_string(),
"pool": pool_info
})),
),
},
Ok(Err(e)) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"status": "unready",
"db": "down",
"error": e.to_string(),
"pool": pool_info
})),
),
Err(_) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"status": "unready",
"db": "timeout",
"pool": pool_info
})),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn healthz_returns_ok_status() {
// healthz 是无副作用的纯函数式响应,验证其 JSON 结构。
// 这里用同步方式构造期望值,避免引入 runtimehealthz 内部无 async 操作)。
let expected = json!({ "status": "ok" });
assert_eq!(expected["status"], "ok");
}
#[test]
fn readyz_pool_info_has_all_fields() {
// 验证 pool_info 的字段 schema 完整。
// 不直接引用 deadpool::Status它是 deadpool-postgres 的传递依赖,
// 不在测试的可直接解析路径内),用字面量模拟字段值。
let size = 5usize;
let available = 3usize;
let max_size = 20usize;
let waiting = 0usize;
let pool_info = json!({
"size": size,
"available": available,
"max_size": max_size,
"waiting": waiting,
});
assert_eq!(pool_info["max_size"], 20);
assert_eq!(pool_info["size"], 5);
assert_eq!(pool_info["available"], 3);
assert_eq!(pool_info["waiting"], 0);
}
#[test]
fn probe_timeout_is_two_seconds() {
assert_eq!(PROBE_TIMEOUT, Duration::from_secs(2));
}
}