Skip to main content

atrg_core/
health.rs

1//! Built-in health and readiness endpoints.
2//!
3//! - `GET /healthz` — always returns 200 `{"ok": true}`
4//! - `GET /readyz` — returns 200 if DB is reachable, 503 otherwise
5
6use axum::extract::State;
7use axum::http::StatusCode;
8use axum::response::IntoResponse;
9use axum::Json;
10
11use crate::state::AppState;
12
13/// `GET /healthz` — liveness probe, always 200.
14pub async fn healthz() -> Json<serde_json::Value> {
15    Json(serde_json::json!({ "ok": true }))
16}
17
18/// `GET /readyz` — readiness probe, checks DB connectivity.
19pub async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
20    match sqlx::query("SELECT 1").execute(&state.db).await {
21        Ok(_) => {
22            let metrics = state.identity.metrics();
23            (
24                StatusCode::OK,
25                Json(serde_json::json!({
26                    "ok": true,
27                    "database": "connected",
28                    "identity_cache": {
29                        "hits": metrics.hits,
30                        "misses": metrics.misses,
31                        "entries": metrics.entry_count,
32                    },
33                })),
34            )
35        }
36        Err(e) => {
37            tracing::error!(error = %e, "readiness check failed: database unreachable");
38            (
39                StatusCode::SERVICE_UNAVAILABLE,
40                Json(serde_json::json!({
41                    "ok": false,
42                    "database": "unreachable",
43                    "error": "database connectivity check failed",
44                })),
45            )
46        }
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::config::{AppConfig, AuthConfig, Config, DatabaseConfig};
54    use axum::body::Body;
55    use axum::routing::get;
56    use axum::Router;
57    use http_body_util::BodyExt;
58    use hyper::Request;
59    use std::sync::Arc;
60    use tower::ServiceExt;
61
62    async fn test_state() -> AppState {
63        let db = atrg_db::connect("sqlite::memory:").await.unwrap();
64        atrg_db::run_internal_migrations(&db).await.unwrap();
65
66        let config = Config {
67            app: AppConfig {
68                name: "test-app".into(),
69                host: "127.0.0.1".into(),
70                port: 3000,
71                secret_key: "test-secret-key-for-health-tests".into(),
72                cors_origins: vec![],
73                environment: "development".into(),
74            },
75            auth: AuthConfig {
76                client_id: "http://localhost:3000/client-metadata.json".into(),
77                redirect_uri: "http://localhost:3000/auth/callback".into(),
78                scope: "atproto transition:generic".into(),
79            },
80            database: DatabaseConfig {
81                url: "sqlite::memory:".into(),
82            },
83            jetstream: None,
84            firehose: None,
85            feed_generator: None,
86            labeler: None,
87            rate_limit: None,
88        };
89
90        AppState {
91            config: Arc::new(config),
92            db,
93            http: reqwest::Client::new(),
94            identity: Arc::new(atrg_identity::IdentityResolver::with_defaults(
95                reqwest::Client::new(),
96            )),
97        }
98    }
99
100    #[tokio::test]
101    async fn healthz_returns_200_ok() {
102        let app: Router = Router::new().route("/healthz", get(healthz));
103
104        let req = Request::builder()
105            .uri("/healthz")
106            .body(Body::empty())
107            .unwrap();
108
109        let resp = app.oneshot(req).await.unwrap();
110        assert_eq!(resp.status(), StatusCode::OK);
111
112        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
113        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
114        assert_eq!(body["ok"], true);
115    }
116
117    #[tokio::test]
118    async fn readyz_returns_200_when_db_connected() {
119        let state = test_state().await;
120        let app: Router = Router::new()
121            .route("/readyz", get(readyz))
122            .with_state(state);
123
124        let req = Request::builder()
125            .uri("/readyz")
126            .body(Body::empty())
127            .unwrap();
128
129        let resp = app.oneshot(req).await.unwrap();
130        assert_eq!(resp.status(), StatusCode::OK);
131
132        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
133        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
134        assert_eq!(body["ok"], true);
135        assert_eq!(body["database"], "connected");
136        assert!(body["identity_cache"].is_object());
137    }
138}