openzeppelin_relayer/api/controllers/
health.rs

1//! Health check controller.
2//!
3//! This module handles HTTP endpoints for health checks, including basic health
4//! and readiness endpoints.
5
6use actix_web::HttpResponse;
7
8use crate::jobs::JobProducerTrait;
9use crate::models::ThinDataAppState;
10use crate::models::{
11    NetworkRepoModel, NotificationRepoModel, RelayerRepoModel, SignerRepoModel,
12    TransactionRepoModel,
13};
14use crate::repositories::{
15    ApiKeyRepositoryTrait, NetworkRepository, PluginRepositoryTrait, RelayerRepository, Repository,
16    TransactionCounterTrait, TransactionRepository,
17};
18use crate::services::health::get_readiness;
19
20/// Handles the health check endpoint.
21///
22/// Returns an `HttpResponse` with a status of `200 OK` and a body of `"OK"`.
23pub async fn health() -> Result<HttpResponse, actix_web::Error> {
24    Ok(HttpResponse::Ok().body("OK"))
25}
26
27/// Handles the readiness check endpoint.
28///
29/// Returns 200 OK if the service is ready to accept traffic, or 503 Service Unavailable if not.
30///
31/// Health check results are cached for 10 seconds to prevent excessive load from frequent
32/// health checks (e.g., from AWS ECS or Kubernetes).
33pub async fn readiness<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>(
34    data: ThinDataAppState<J, RR, TR, NR, NFR, SR, TCR, PR, AKR>,
35) -> Result<HttpResponse, actix_web::Error>
36where
37    J: JobProducerTrait + Send + Sync + 'static,
38    RR: RelayerRepository + Repository<RelayerRepoModel, String> + Send + Sync + 'static,
39    TR: TransactionRepository + Repository<TransactionRepoModel, String> + Send + Sync + 'static,
40    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
41    NFR: Repository<NotificationRepoModel, String> + Send + Sync + 'static,
42    SR: Repository<SignerRepoModel, String> + Send + Sync + 'static,
43    TCR: TransactionCounterTrait + Send + Sync + 'static,
44    PR: PluginRepositoryTrait + Send + Sync + 'static,
45    AKR: ApiKeyRepositoryTrait + Send + Sync + 'static,
46{
47    let response = get_readiness(data).await;
48
49    if response.ready {
50        Ok(HttpResponse::Ok().json(response))
51    } else {
52        Ok(HttpResponse::ServiceUnavailable().json(response))
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use crate::models::health::ComponentStatus;
60    use crate::utils::mocks::mockutils::create_mock_app_state;
61    use actix_web::body::to_bytes;
62    use actix_web::web::ThinData;
63    use std::sync::Arc;
64
65    // =========================================================================
66    // Health Endpoint Tests
67    // =========================================================================
68
69    #[actix_web::test]
70    async fn test_health_returns_ok() {
71        let result = health().await;
72
73        assert!(result.is_ok());
74        let response = result.unwrap();
75        assert_eq!(response.status().as_u16(), 200);
76    }
77
78    #[actix_web::test]
79    async fn test_health_response_body() {
80        let result = health().await;
81
82        let response = result.unwrap();
83        let body = to_bytes(response.into_body()).await.unwrap();
84        assert_eq!(body, "OK");
85    }
86
87    #[actix_web::test]
88    async fn test_health_is_idempotent() {
89        // Health endpoint should always return the same result
90        for _ in 0..3 {
91            let result = health().await;
92            assert!(result.is_ok());
93            let response = result.unwrap();
94            assert_eq!(response.status().as_u16(), 200);
95        }
96    }
97
98    // =========================================================================
99    // Readiness Endpoint Tests - Unhealthy Path (Queue Unavailable)
100    // =========================================================================
101
102    #[actix_web::test]
103    async fn test_readiness_returns_503_when_queue_unavailable() {
104        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
105
106        Arc::get_mut(&mut app_state.job_producer)
107            .unwrap()
108            .expect_get_queue_backend()
109            .return_const(None);
110
111        let result = readiness(ThinData(app_state)).await;
112
113        assert!(result.is_ok());
114        let response = result.unwrap();
115        assert_eq!(response.status().as_u16(), 503);
116    }
117
118    #[actix_web::test]
119    async fn test_readiness_returns_json_when_unhealthy() {
120        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
121
122        Arc::get_mut(&mut app_state.job_producer)
123            .unwrap()
124            .expect_get_queue_backend()
125            .return_const(None);
126
127        let result = readiness(ThinData(app_state)).await;
128        let response = result.unwrap();
129        let body = to_bytes(response.into_body()).await.unwrap();
130        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
131
132        // Verify response structure
133        assert_eq!(json["ready"], false);
134        assert_eq!(json["status"], "unhealthy");
135        assert!(json.get("components").is_some());
136        assert!(json.get("timestamp").is_some());
137    }
138
139    #[actix_web::test]
140    async fn test_readiness_includes_reason_when_unhealthy() {
141        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
142
143        Arc::get_mut(&mut app_state.job_producer)
144            .unwrap()
145            .expect_get_queue_backend()
146            .return_const(None);
147
148        let result = readiness(ThinData(app_state)).await;
149        let response = result.unwrap();
150        let body = to_bytes(response.into_body()).await.unwrap();
151        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
152
153        // When unhealthy, reason should explain why
154        assert!(json.get("reason").is_some());
155        let reason = json["reason"].as_str().unwrap();
156        assert!(!reason.is_empty());
157    }
158
159    #[actix_web::test]
160    async fn test_readiness_components_show_unhealthy_state() {
161        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
162
163        Arc::get_mut(&mut app_state.job_producer)
164            .unwrap()
165            .expect_get_queue_backend()
166            .return_const(None);
167
168        let result = readiness(ThinData(app_state)).await;
169        let response = result.unwrap();
170        let body = to_bytes(response.into_body()).await.unwrap();
171        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
172
173        // Queue should be unhealthy when queue backend is unavailable.
174        // Redis is neutral in in-memory repository-backed tests.
175        let redis_status = json["components"]["redis"]["status"].as_str().unwrap();
176        let queue_status = json["components"]["queue"]["status"].as_str().unwrap();
177
178        assert_eq!(redis_status, "healthy");
179        assert_eq!(queue_status, "unhealthy");
180    }
181
182    #[actix_web::test]
183    async fn test_readiness_system_health_still_checked_when_queue_unavailable() {
184        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
185
186        Arc::get_mut(&mut app_state.job_producer)
187            .unwrap()
188            .expect_get_queue_backend()
189            .return_const(None);
190
191        let result = readiness(ThinData(app_state)).await;
192        let response = result.unwrap();
193        let body = to_bytes(response.into_body()).await.unwrap();
194        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
195
196        // System health should still be checked even when queue unavailable
197        let system = &json["components"]["system"];
198        assert!(system.get("status").is_some());
199        assert!(system.get("fd_count").is_some());
200        assert!(system.get("fd_limit").is_some());
201        assert!(system.get("fd_usage_percent").is_some());
202        assert!(system.get("close_wait_count").is_some());
203
204        // System status should be valid (likely healthy since system checks don't depend on queue)
205        let system_status = system["status"].as_str().unwrap();
206        assert!(
207            system_status == "healthy"
208                || system_status == "degraded"
209                || system_status == "unhealthy"
210        );
211    }
212
213    // =========================================================================
214    // Response Correlation Tests
215    // =========================================================================
216
217    #[actix_web::test]
218    async fn test_readiness_status_code_matches_ready_field() {
219        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
220
221        // Test unhealthy path
222        Arc::get_mut(&mut app_state.job_producer)
223            .unwrap()
224            .expect_get_queue_backend()
225            .return_const(None);
226
227        let result = readiness(ThinData(app_state)).await;
228        let response = result.unwrap();
229        let status_code = response.status().as_u16();
230        let body = to_bytes(response.into_body()).await.unwrap();
231        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
232
233        let ready = json["ready"].as_bool().unwrap();
234
235        // Verify correlation: ready=false should give 503
236        assert!(!ready);
237        assert_eq!(status_code, 503);
238    }
239
240    #[actix_web::test]
241    async fn test_readiness_timestamp_is_valid_rfc3339() {
242        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
243
244        Arc::get_mut(&mut app_state.job_producer)
245            .unwrap()
246            .expect_get_queue_backend()
247            .return_const(None);
248
249        let result = readiness(ThinData(app_state)).await;
250        let response = result.unwrap();
251        let body = to_bytes(response.into_body()).await.unwrap();
252        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
253
254        let timestamp = json["timestamp"].as_str().unwrap();
255        // Should be parseable as RFC3339
256        chrono::DateTime::parse_from_rfc3339(timestamp)
257            .expect("Timestamp should be valid RFC3339 format");
258    }
259
260    // =========================================================================
261    // Serialization Tests
262    // =========================================================================
263
264    #[test]
265    fn test_component_status_serializes_to_lowercase() {
266        assert_eq!(
267            serde_json::to_string(&ComponentStatus::Healthy).unwrap(),
268            "\"healthy\""
269        );
270        assert_eq!(
271            serde_json::to_string(&ComponentStatus::Degraded).unwrap(),
272            "\"degraded\""
273        );
274        assert_eq!(
275            serde_json::to_string(&ComponentStatus::Unhealthy).unwrap(),
276            "\"unhealthy\""
277        );
278    }
279}