camel_component_http/
health.rs1use async_trait::async_trait;
2use camel_api::{AsyncHealthCheck, CheckResult};
3use camel_component_api::CamelError;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::time::Duration;
9
10type ProbeFuture = Pin<Box<dyn Future<Output = Result<(), CamelError>> + Send>>;
11
12trait HttpHealthProbe: Send + Sync {
13 fn probe(&self) -> ProbeFuture;
14}
15
16struct HttpListenerProbe {
17 host: String,
18 port: u16,
19}
20
21impl HttpListenerProbe {
22 fn new(host: String, port: u16) -> Self {
23 let probe_host = match host.as_str() {
24 "0.0.0.0" => "127.0.0.1".to_string(),
25 "::" | "[::]" => "::1".to_string(),
26 _ => host,
27 };
28 Self {
29 host: probe_host,
30 port,
31 }
32 }
33}
34
35impl HttpHealthProbe for HttpListenerProbe {
36 fn probe(&self) -> ProbeFuture {
37 let addr = format!("{}:{}", self.host, self.port);
38 Box::pin(async move {
39 tokio::net::TcpStream::connect(&addr)
40 .await
41 .map(|_| ())
42 .map_err(|e| {
43 CamelError::ProcessorError(format!(
44 "HTTP listener health check failed for '{}': {}",
45 addr, e
46 ))
47 })
48 })
49 }
50}
51
52#[derive(Clone, Default)]
60pub(crate) struct ListenerGate(Arc<AtomicBool>);
61
62impl ListenerGate {
63 pub(crate) fn open(&self) {
64 self.0.store(true, Ordering::Release);
65 }
66
67 fn is_open(&self) -> bool {
68 self.0.load(Ordering::Acquire)
69 }
70}
71
72pub struct HttpHealthCheck {
73 probe: Arc<dyn HttpHealthProbe>,
74 timeout: Duration,
75 gate: Option<ListenerGate>,
77}
78
79impl HttpHealthCheck {
80 pub fn new(host: String, port: u16) -> Self {
81 Self {
82 probe: Arc::new(HttpListenerProbe::new(host, port)),
83 timeout: Duration::from_secs(3),
84 gate: None,
85 }
86 }
87
88 pub(crate) fn gated(host: String, port: u16, gate: ListenerGate) -> Self {
90 Self {
91 gate: Some(gate),
92 ..Self::new(host, port)
93 }
94 }
95
96 #[cfg(test)]
97 fn with_probe_for_tests(probe: Arc<dyn HttpHealthProbe>, timeout: Duration) -> Self {
98 Self {
99 probe,
100 timeout,
101 gate: None,
102 }
103 }
104}
105
106#[async_trait]
107impl AsyncHealthCheck for HttpHealthCheck {
108 fn name(&self) -> &str {
109 "http"
110 }
111
112 async fn check(&self) -> CheckResult {
113 if self.gate.as_ref().is_some_and(|g| !g.is_open()) {
114 return CheckResult::healthy(self.name());
116 }
117 match tokio::time::timeout(self.timeout, self.probe.probe()).await {
118 Ok(Ok(())) => CheckResult::healthy(self.name()),
119 Ok(Err(err)) => CheckResult::unhealthy(self.name(), &err.to_string()),
120 Err(_) => CheckResult::unhealthy(self.name(), "HTTP listener probe timed out"),
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use camel_api::HealthStatus;
129
130 struct MockProbe {
131 responder: Arc<dyn Fn() -> ProbeFuture + Send + Sync>,
132 }
133
134 impl MockProbe {
135 fn new<F>(f: F) -> Self
136 where
137 F: Fn() -> ProbeFuture + Send + Sync + 'static,
138 {
139 Self {
140 responder: Arc::new(f),
141 }
142 }
143 }
144
145 impl HttpHealthProbe for MockProbe {
146 fn probe(&self) -> ProbeFuture {
147 (self.responder)()
148 }
149 }
150
151 #[tokio::test(start_paused = true)]
159 async fn http_health_check_healthy_when_probe_succeeds() {
160 let probe = Arc::new(MockProbe::new(|| Box::pin(async { Ok(()) })));
161 let check = HttpHealthCheck::with_probe_for_tests(probe, Duration::from_millis(50));
162
163 let result = check.check().await;
164
165 assert_eq!(result.name, "http");
166 assert_eq!(result.status, HealthStatus::Healthy);
167 assert!(result.message.is_none());
168 }
169
170 #[tokio::test(start_paused = true)]
171 async fn http_health_check_unhealthy_when_probe_fails() {
172 let probe = Arc::new(MockProbe::new(|| {
173 Box::pin(async { Err(CamelError::ProcessorError("listener not bound".to_string())) })
174 }));
175 let check = HttpHealthCheck::with_probe_for_tests(probe, Duration::from_millis(50));
176
177 let result = check.check().await;
178
179 assert_eq!(result.name, "http");
180 assert_eq!(result.status, HealthStatus::Unhealthy);
181 assert!(
182 result
183 .message
184 .as_deref()
185 .is_some_and(|m| m.contains("listener not bound"))
186 );
187 }
188
189 fn failing_probe() -> Arc<MockProbe> {
190 Arc::new(MockProbe::new(|| {
191 Box::pin(async { Err(CamelError::ProcessorError("remote down".to_string())) })
192 }))
193 }
194
195 #[tokio::test(start_paused = true)]
198 async fn gated_check_is_healthy_until_a_consumer_opens_the_gate() {
199 let gate = ListenerGate::default();
200 let mut check =
201 HttpHealthCheck::with_probe_for_tests(failing_probe(), Duration::from_millis(50));
202 check.gate = Some(gate.clone());
203
204 assert_eq!(check.check().await.status, HealthStatus::Healthy);
205
206 gate.open();
207 let result = check.check().await;
208 assert_eq!(result.status, HealthStatus::Unhealthy);
209 assert!(
210 result
211 .message
212 .as_deref()
213 .is_some_and(|m| m.contains("remote down"))
214 );
215 }
216
217 #[tokio::test(start_paused = true)]
218 async fn http_health_check_unhealthy_when_probe_times_out() {
219 let probe = Arc::new(MockProbe::new(|| {
220 Box::pin(async {
221 tokio::time::sleep(Duration::from_millis(50)).await;
222 Ok(())
223 })
224 }));
225 let check = HttpHealthCheck::with_probe_for_tests(probe, Duration::from_millis(5));
226
227 let result = check.check().await;
228
229 assert_eq!(result.name, "http");
230 assert_eq!(result.status, HealthStatus::Unhealthy);
231 assert_eq!(
232 result.message.as_deref(),
233 Some("HTTP listener probe timed out")
234 );
235 }
236}