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 state.db.ping().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                    "database_backend": state.db.backend(),
29                    "identity_cache": {
30                        "hits": metrics.hits,
31                        "misses": metrics.misses,
32                        "entries": metrics.entry_count,
33                    },
34                })),
35            )
36        }
37        Err(e) => {
38            tracing::error!(error = %e, "readiness check failed: database unreachable");
39            (
40                StatusCode::SERVICE_UNAVAILABLE,
41                Json(serde_json::json!({
42                    "ok": false,
43                    "database": "unreachable",
44                    "error": "database connectivity check failed",
45                })),
46            )
47        }
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use crate::config::{AppConfig, AuthConfig, Config, DatabaseConfig};
55    use axum::body::Body;
56    use axum::routing::get;
57    use axum::Router;
58    use http_body_util::BodyExt;
59    use hyper::Request;
60    use std::sync::Arc;
61    use tower::ServiceExt;
62
63    async fn test_state() -> AppState {
64        let db = atrg_db::connect("sqlite::memory:").await.unwrap();
65        atrg_db::run_internal_migrations(&db).await.unwrap();
66
67        let config = Config {
68            app: AppConfig {
69                name: "test-app".into(),
70                host: "127.0.0.1".into(),
71                port: 3000,
72                secret_key: "test-secret-key-for-health-tests".into(),
73                cors_origins: vec![],
74                environment: "development".into(),
75                admin_dids: vec![],
76            },
77            auth: AuthConfig {
78                client_id: "http://localhost:3000/client-metadata.json".into(),
79                redirect_uri: "http://localhost:3000/auth/callback".into(),
80                scope: "atproto transition:generic".into(),
81                post_login_redirect: "/".into(),
82            },
83            database: DatabaseConfig {
84                url: "sqlite::memory:".into(),
85            },
86            jetstream: None,
87            firehose: None,
88            feed_generator: None,
89            labeler: None,
90            rate_limit: None,
91        };
92
93        AppState {
94            config: Arc::new(config),
95            db,
96            http: reqwest::Client::new(),
97            identity: Arc::new(atrg_identity::IdentityResolver::with_defaults(
98                reqwest::Client::new(),
99            )),
100            extensions: Arc::new(crate::state::Extensions::new()),
101        }
102    }
103
104    #[tokio::test]
105    async fn healthz_returns_200_ok() {
106        let app: Router = Router::new().route("/healthz", get(healthz));
107
108        let req = Request::builder()
109            .uri("/healthz")
110            .body(Body::empty())
111            .unwrap();
112
113        let resp = app.oneshot(req).await.unwrap();
114        assert_eq!(resp.status(), StatusCode::OK);
115
116        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
117        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
118        assert_eq!(body["ok"], true);
119    }
120
121    #[tokio::test]
122    async fn readyz_returns_200_when_db_connected() {
123        let state = test_state().await;
124        let app: Router = Router::new()
125            .route("/readyz", get(readyz))
126            .with_state(state);
127
128        let req = Request::builder()
129            .uri("/readyz")
130            .body(Body::empty())
131            .unwrap();
132
133        let resp = app.oneshot(req).await.unwrap();
134        assert_eq!(resp.status(), StatusCode::OK);
135
136        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
137        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
138        assert_eq!(body["ok"], true);
139        assert_eq!(body["database"], "connected");
140        assert!(body["identity_cache"].is_object());
141    }
142}