Skip to main content

camel_component_http/
health.rs

1use 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/// Marks an `HttpEndpoint` as a listener (a consumer was created from it).
53///
54/// `HttpComponent::create_endpoint` cannot tell whether the endpoint will be
55/// a consumer (`from`) or a producer (`to`), so it registers the check for
56/// both. The gate keeps the check inert until `create_consumer` opens it:
57/// a producer's URI names a remote target, and probing that target makes
58/// `/readyz` depend on a downstream service (rc-hcica).
59#[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    /// `None`: always probe (public `new`). `Some`: probe only once opened.
76    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    /// Listener check that stays healthy until `gate` is opened.
89    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            // Producer-only endpoint: no listener of ours to probe.
115            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    // Timeout-semantics tests run under `start_paused = true` (rc-wsx2y):
152    // the virtual clock removes wall-clock dependence — the probe's 50ms
153    // sleep and the 5ms check timeout resolve instantly via auto-advance.
154    // Caveat: this proves the timeout SEMANTICS on tokio's paused clock; the
155    // real-time path is not exercised here (rc-8cw1 forensics: the one-time
156    // red there was OOM attribution, not an assertion race).
157
158    #[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    /// rc-hcica: a producer endpoint never opens its gate, so a dead remote
196    /// must not turn readiness unhealthy.
197    #[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}