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::jobs::JobProducerError;
60    use crate::models::health::ComponentStatus;
61    use crate::utils::mocks::mockutils::create_mock_app_state;
62    use actix_web::body::to_bytes;
63    use actix_web::web::ThinData;
64    use std::sync::Arc;
65
66    // =========================================================================
67    // Health Endpoint Tests
68    // =========================================================================
69
70    #[actix_web::test]
71    async fn test_health_returns_ok() {
72        let result = health().await;
73
74        assert!(result.is_ok());
75        let response = result.unwrap();
76        assert_eq!(response.status().as_u16(), 200);
77    }
78
79    #[actix_web::test]
80    async fn test_health_response_body() {
81        let result = health().await;
82
83        let response = result.unwrap();
84        let body = to_bytes(response.into_body()).await.unwrap();
85        assert_eq!(body, "OK");
86    }
87
88    #[actix_web::test]
89    async fn test_health_is_idempotent() {
90        // Health endpoint should always return the same result
91        for _ in 0..3 {
92            let result = health().await;
93            assert!(result.is_ok());
94            let response = result.unwrap();
95            assert_eq!(response.status().as_u16(), 200);
96        }
97    }
98
99    // =========================================================================
100    // Readiness Endpoint Tests - Unhealthy Path (Queue Unavailable)
101    // =========================================================================
102
103    #[actix_web::test]
104    async fn test_readiness_returns_503_when_queue_unavailable() {
105        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
106
107        Arc::get_mut(&mut app_state.job_producer)
108            .unwrap()
109            .expect_get_queue()
110            .returning(|| {
111                Box::pin(async {
112                    Err(JobProducerError::QueueError(
113                        "Queue not available".to_string(),
114                    ))
115                })
116            });
117
118        let result = readiness(ThinData(app_state)).await;
119
120        assert!(result.is_ok());
121        let response = result.unwrap();
122        assert_eq!(response.status().as_u16(), 503);
123    }
124
125    #[actix_web::test]
126    async fn test_readiness_returns_json_when_unhealthy() {
127        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
128
129        Arc::get_mut(&mut app_state.job_producer)
130            .unwrap()
131            .expect_get_queue()
132            .returning(|| {
133                Box::pin(async {
134                    Err(JobProducerError::QueueError(
135                        "Queue not available".to_string(),
136                    ))
137                })
138            });
139
140        let result = readiness(ThinData(app_state)).await;
141        let response = result.unwrap();
142        let body = to_bytes(response.into_body()).await.unwrap();
143        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
144
145        // Verify response structure
146        assert_eq!(json["ready"], false);
147        assert_eq!(json["status"], "unhealthy");
148        assert!(json.get("components").is_some());
149        assert!(json.get("timestamp").is_some());
150    }
151
152    #[actix_web::test]
153    async fn test_readiness_includes_reason_when_unhealthy() {
154        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
155
156        Arc::get_mut(&mut app_state.job_producer)
157            .unwrap()
158            .expect_get_queue()
159            .returning(|| {
160                Box::pin(async {
161                    Err(JobProducerError::QueueError(
162                        "Queue not available".to_string(),
163                    ))
164                })
165            });
166
167        let result = readiness(ThinData(app_state)).await;
168        let response = result.unwrap();
169        let body = to_bytes(response.into_body()).await.unwrap();
170        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
171
172        // When unhealthy, reason should explain why
173        assert!(json.get("reason").is_some());
174        let reason = json["reason"].as_str().unwrap();
175        assert!(!reason.is_empty());
176    }
177
178    #[actix_web::test]
179    async fn test_readiness_components_show_unhealthy_state() {
180        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
181
182        Arc::get_mut(&mut app_state.job_producer)
183            .unwrap()
184            .expect_get_queue()
185            .returning(|| {
186                Box::pin(async {
187                    Err(JobProducerError::QueueError(
188                        "Queue not available".to_string(),
189                    ))
190                })
191            });
192
193        let result = readiness(ThinData(app_state)).await;
194        let response = result.unwrap();
195        let body = to_bytes(response.into_body()).await.unwrap();
196        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
197
198        // Redis and Queue should show unhealthy when queue is unavailable
199        let redis_status = json["components"]["redis"]["status"].as_str().unwrap();
200        let queue_status = json["components"]["queue"]["status"].as_str().unwrap();
201
202        assert_eq!(redis_status, "unhealthy");
203        assert_eq!(queue_status, "unhealthy");
204    }
205
206    #[actix_web::test]
207    async fn test_readiness_system_health_still_checked_when_queue_unavailable() {
208        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
209
210        Arc::get_mut(&mut app_state.job_producer)
211            .unwrap()
212            .expect_get_queue()
213            .returning(|| {
214                Box::pin(async {
215                    Err(JobProducerError::QueueError(
216                        "Queue not available".to_string(),
217                    ))
218                })
219            });
220
221        let result = readiness(ThinData(app_state)).await;
222        let response = result.unwrap();
223        let body = to_bytes(response.into_body()).await.unwrap();
224        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
225
226        // System health should still be checked even when queue unavailable
227        let system = &json["components"]["system"];
228        assert!(system.get("status").is_some());
229        assert!(system.get("fd_count").is_some());
230        assert!(system.get("fd_limit").is_some());
231        assert!(system.get("fd_usage_percent").is_some());
232        assert!(system.get("close_wait_count").is_some());
233
234        // System status should be valid (likely healthy since system checks don't depend on queue)
235        let system_status = system["status"].as_str().unwrap();
236        assert!(
237            system_status == "healthy"
238                || system_status == "degraded"
239                || system_status == "unhealthy"
240        );
241    }
242
243    // =========================================================================
244    // Response Correlation Tests
245    // =========================================================================
246
247    #[actix_web::test]
248    async fn test_readiness_status_code_matches_ready_field() {
249        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
250
251        // Test unhealthy path
252        Arc::get_mut(&mut app_state.job_producer)
253            .unwrap()
254            .expect_get_queue()
255            .returning(|| {
256                Box::pin(async { Err(JobProducerError::QueueError("Unavailable".to_string())) })
257            });
258
259        let result = readiness(ThinData(app_state)).await;
260        let response = result.unwrap();
261        let status_code = response.status().as_u16();
262        let body = to_bytes(response.into_body()).await.unwrap();
263        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
264
265        let ready = json["ready"].as_bool().unwrap();
266
267        // Verify correlation: ready=false should give 503
268        assert!(!ready);
269        assert_eq!(status_code, 503);
270    }
271
272    #[actix_web::test]
273    async fn test_readiness_timestamp_is_valid_rfc3339() {
274        let mut app_state = create_mock_app_state(None, None, None, None, None, None).await;
275
276        Arc::get_mut(&mut app_state.job_producer)
277            .unwrap()
278            .expect_get_queue()
279            .returning(|| {
280                Box::pin(async { Err(JobProducerError::QueueError("Unavailable".to_string())) })
281            });
282
283        let result = readiness(ThinData(app_state)).await;
284        let response = result.unwrap();
285        let body = to_bytes(response.into_body()).await.unwrap();
286        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
287
288        let timestamp = json["timestamp"].as_str().unwrap();
289        // Should be parseable as RFC3339
290        chrono::DateTime::parse_from_rfc3339(timestamp)
291            .expect("Timestamp should be valid RFC3339 format");
292    }
293
294    // =========================================================================
295    // Serialization Tests
296    // =========================================================================
297
298    #[test]
299    fn test_component_status_serializes_to_lowercase() {
300        assert_eq!(
301            serde_json::to_string(&ComponentStatus::Healthy).unwrap(),
302            "\"healthy\""
303        );
304        assert_eq!(
305            serde_json::to_string(&ComponentStatus::Degraded).unwrap(),
306            "\"degraded\""
307        );
308        assert_eq!(
309            serde_json::to_string(&ComponentStatus::Unhealthy).unwrap(),
310            "\"unhealthy\""
311        );
312    }
313}