Skip to main content

pingora_load_balancing/
health_check.rs

1// Copyright 2026 Cloudflare, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Health Check interface and methods.
16
17use crate::Backend;
18use arc_swap::ArcSwap;
19use async_trait::async_trait;
20use pingora_core::connectors::http::custom;
21use pingora_core::connectors::{http::Connector as HttpConnector, TransportConnector};
22use pingora_core::custom_session;
23use pingora_core::protocols::http::custom::client::Session;
24use pingora_core::upstreams::peer::{BasicPeer, HttpPeer, Peer};
25use pingora_error::{Error, ErrorType::CustomCode, Result};
26use pingora_http::{RequestHeader, ResponseHeader};
27use std::sync::Arc;
28use std::time::Duration;
29
30/// [HealthObserve] is an interface for observing health changes of backends,
31/// this is what's used for our health observation callback.
32#[async_trait]
33pub trait HealthObserve {
34    /// Observes the health of a [Backend], can be used for monitoring purposes.
35    async fn observe(&self, target: &Backend, healthy: bool);
36}
37/// Provided to a [HealthCheck] to observe changes to [Backend] health.
38pub type HealthObserveCallback = Box<dyn HealthObserve + Send + Sync>;
39
40/// Provided to a [HealthCheck] to fetch [Backend] summary for detailed logging.
41pub type BackendSummary = Box<dyn Fn(&Backend) -> String + Send + Sync>;
42
43/// [HealthCheck] is the interface to implement health check for backends
44#[async_trait]
45pub trait HealthCheck {
46    /// Check the given backend.
47    ///
48    /// `Ok(())`` if the check passes, otherwise the check fails.
49    ///
50    /// A health-check pass drops this future to cancel the check on shutdown,
51    /// so an implementation must not rely on running to completion once polled.
52    async fn check(&self, target: &Backend) -> Result<()>;
53
54    /// Called when the health changes for a [Backend].
55    async fn health_status_change(&self, _target: &Backend, _healthy: bool) {}
56
57    /// Called when a detailed [Backend] summary is needed.
58    fn backend_summary(&self, target: &Backend) -> String {
59        format!("{target:?}")
60    }
61
62    /// This function defines how many *consecutive* checks should flip the health of a backend.
63    ///
64    /// For example: with `success``: `true`: this function should return the
65    /// number of check need to flip from unhealthy to healthy.
66    fn health_threshold(&self, success: bool) -> usize;
67}
68
69/// TCP health check
70///
71/// This health check checks if a TCP (or TLS) connection can be established to a given backend.
72pub struct TcpHealthCheck {
73    /// Number of successful checks to flip from unhealthy to healthy.
74    pub consecutive_success: usize,
75    /// Number of failed checks to flip from healthy to unhealthy.
76    pub consecutive_failure: usize,
77    /// How to connect to the backend.
78    ///
79    /// This field defines settings like the connect timeout and src IP to bind.
80    /// The SocketAddr of `peer_template` is just a placeholder which will be replaced by the
81    /// actual address of the backend when the health check runs.
82    ///
83    /// By default, this check will try to establish a TCP connection. When the `sni` field is
84    /// set, it will also try to establish a TLS connection on top of the TCP connection.
85    pub peer_template: BasicPeer,
86    connector: TransportConnector,
87    /// A callback that is invoked when the `healthy` status changes for a [Backend].
88    pub health_changed_callback: Option<HealthObserveCallback>,
89}
90
91impl Default for TcpHealthCheck {
92    fn default() -> Self {
93        let mut peer_template = BasicPeer::new("0.0.0.0:1");
94        peer_template.options.connection_timeout = Some(Duration::from_secs(1));
95        TcpHealthCheck {
96            consecutive_success: 1,
97            consecutive_failure: 1,
98            peer_template,
99            connector: TransportConnector::new(None),
100            health_changed_callback: None,
101        }
102    }
103}
104
105impl TcpHealthCheck {
106    /// Create a new [TcpHealthCheck] with the following default values
107    /// * connect timeout: 1 second
108    /// * consecutive_success: 1
109    /// * consecutive_failure: 1
110    pub fn new() -> Box<Self> {
111        Box::<TcpHealthCheck>::default()
112    }
113
114    /// Create a new [TcpHealthCheck] that tries to establish a TLS connection.
115    ///
116    /// The default values are the same as [Self::new()].
117    pub fn new_tls(sni: &str) -> Box<Self> {
118        let mut new = Self::default();
119        new.peer_template.sni = sni.into();
120        Box::new(new)
121    }
122
123    /// Replace the internal tcp connector with the given [TransportConnector]
124    pub fn set_connector(&mut self, connector: TransportConnector) {
125        self.connector = connector;
126    }
127}
128
129#[async_trait]
130impl HealthCheck for TcpHealthCheck {
131    fn health_threshold(&self, success: bool) -> usize {
132        if success {
133            self.consecutive_success
134        } else {
135            self.consecutive_failure
136        }
137    }
138
139    async fn check(&self, target: &Backend) -> Result<()> {
140        let mut peer = self.peer_template.clone();
141        peer._address = target.addr.clone();
142        self.connector.get_stream(&peer).await.map(|_| {})
143    }
144
145    async fn health_status_change(&self, target: &Backend, healthy: bool) {
146        if let Some(callback) = &self.health_changed_callback {
147            callback.observe(target, healthy).await;
148        }
149    }
150}
151
152type Validator = Box<dyn Fn(&ResponseHeader) -> Result<()> + Send + Sync>;
153
154/// HTTP health check
155///
156/// This health check checks if it can receive the expected HTTP(s) response from the given backend.
157pub struct HttpHealthCheck<C = ()>
158where
159    C: custom::Connector,
160{
161    /// Number of successful checks to flip from unhealthy to healthy.
162    pub consecutive_success: usize,
163    /// Number of failed checks to flip from healthy to unhealthy.
164    pub consecutive_failure: usize,
165    /// How to connect to the backend.
166    ///
167    /// This field defines settings like the connect timeout and src IP to bind.
168    /// The SocketAddr of `peer_template` is just a placeholder which will be replaced by the
169    /// actual address of the backend when the health check runs.
170    ///
171    /// Set the `scheme` field to use HTTPs.
172    pub peer_template: HttpPeer,
173    /// Whether the underlying TCP/TLS connection can be reused across checks.
174    ///
175    /// * `false` will make sure that every health check goes through TCP (and TLS) handshakes.
176    ///   Established connections sometimes hide the issue of firewalls and L4 LB.
177    /// * `true` will try to reuse connections across checks, this is the more efficient and fast way
178    ///   to perform health checks.
179    pub reuse_connection: bool,
180    /// The request header to send to the backend
181    pub req: RequestHeader,
182    connector: HttpConnector<C>,
183    /// Optional field to define how to validate the response from the server.
184    ///
185    /// If not set, any response with a `200 OK` is considered a successful check.
186    pub validator: Option<Validator>,
187    /// Sometimes the health check endpoint lives one a different port than the actual backend.
188    /// Setting this option allows the health check to perform on the given port of the backend IP.
189    pub port_override: Option<u16>,
190    /// A callback that is invoked when the `healthy` status changes for a [Backend].
191    pub health_changed_callback: Option<HealthObserveCallback>,
192    /// An optional callback for backend summary reporting.
193    pub backend_summary_callback: Option<BackendSummary>,
194}
195
196impl HttpHealthCheck<()> {
197    /// Create a new [HttpHealthCheck] with the following default settings
198    /// * connect timeout: 1 second
199    /// * read timeout: 1 second
200    /// * req: a GET to the `/` of the given host name
201    /// * consecutive_success: 1
202    /// * consecutive_failure: 1
203    /// * reuse_connection: false
204    /// * validator: `None`, any 200 response is considered successful
205    pub fn new(host: &str, tls: bool) -> Self {
206        let mut req = RequestHeader::build("GET", b"/", None).unwrap();
207        req.append_header("Host", host).unwrap();
208        let sni = if tls { host.into() } else { String::new() };
209        let mut peer_template = HttpPeer::new("0.0.0.0:1", tls, sni);
210        peer_template.options.connection_timeout = Some(Duration::from_secs(1));
211        peer_template.options.read_timeout = Some(Duration::from_secs(1));
212        HttpHealthCheck {
213            consecutive_success: 1,
214            consecutive_failure: 1,
215            peer_template,
216            connector: HttpConnector::new(None),
217            reuse_connection: false,
218            req,
219            validator: None,
220            port_override: None,
221            health_changed_callback: None,
222            backend_summary_callback: None,
223        }
224    }
225}
226
227impl<C> HttpHealthCheck<C>
228where
229    C: custom::Connector,
230{
231    /// Create a new [HttpHealthCheck] with the following default settings
232    /// * connect timeout: 1 second
233    /// * read timeout: 1 second
234    /// * req: a GET to the `/` of the given host name
235    /// * consecutive_success: 1
236    /// * consecutive_failure: 1
237    /// * reuse_connection: false
238    /// * validator: `None`, any 200 response is considered successful
239    pub fn new_custom(host: &str, tls: bool, custom: HttpConnector<C>) -> Self {
240        let mut req = RequestHeader::build("GET", b"/", None).unwrap();
241        req.append_header("Host", host).unwrap();
242        let sni = if tls { host.into() } else { String::new() };
243        let mut peer_template = HttpPeer::new("0.0.0.0:1", tls, sni);
244        peer_template.options.connection_timeout = Some(Duration::from_secs(1));
245        peer_template.options.read_timeout = Some(Duration::from_secs(1));
246        HttpHealthCheck {
247            consecutive_success: 1,
248            consecutive_failure: 1,
249            peer_template,
250            connector: custom,
251            reuse_connection: false,
252            req,
253            validator: None,
254            port_override: None,
255            health_changed_callback: None,
256            backend_summary_callback: None,
257        }
258    }
259
260    /// Replace the internal http connector with the given [HttpConnector]
261    pub fn set_connector(&mut self, connector: HttpConnector<C>) {
262        self.connector = connector;
263    }
264
265    pub fn set_backend_summary<F>(&mut self, callback: F)
266    where
267        F: Fn(&Backend) -> String + Send + Sync + 'static,
268    {
269        self.backend_summary_callback = Some(Box::new(callback));
270    }
271}
272
273#[async_trait]
274impl<C> HealthCheck for HttpHealthCheck<C>
275where
276    C: custom::Connector,
277{
278    fn health_threshold(&self, success: bool) -> usize {
279        if success {
280            self.consecutive_success
281        } else {
282            self.consecutive_failure
283        }
284    }
285
286    async fn check(&self, target: &Backend) -> Result<()> {
287        let mut peer = self.peer_template.clone();
288        peer._address = target.addr.clone();
289        if let Some(port) = self.port_override {
290            peer._address.set_port(port);
291        }
292        let session = self.connector.get_http_session(&peer).await?;
293
294        let mut session = session.0;
295
296        session.set_write_timeout(peer.options.write_timeout);
297
298        let req = Box::new(self.req.clone());
299        session.write_request_header(req).await?;
300        session.finish_request_body().await?;
301
302        custom_session!(session.finish_custom().await?);
303
304        session.set_read_timeout(peer.options.read_timeout);
305
306        session.read_response_header().await?;
307
308        let resp = session.response_header().expect("just read");
309
310        if let Some(validator) = self.validator.as_ref() {
311            validator(resp)?;
312        } else if resp.status != 200 {
313            return Error::e_explain(
314                CustomCode("non 200 code", resp.status.as_u16()),
315                "during http healthcheck",
316            );
317        };
318
319        while session.read_response_body().await?.is_some() {
320            // drain the body if any
321        }
322
323        // TODO(slava): do it concurrently wtih body drain?
324        custom_session!(session.drain_custom_messages().await?);
325
326        if self.reuse_connection {
327            let idle_timeout = peer.idle_timeout();
328            self.connector
329                .release_http_session(session, &peer, idle_timeout)
330                .await;
331        }
332
333        Ok(())
334    }
335    async fn health_status_change(&self, target: &Backend, healthy: bool) {
336        if let Some(callback) = &self.health_changed_callback {
337            callback.observe(target, healthy).await;
338        }
339    }
340    fn backend_summary(&self, target: &Backend) -> String {
341        if let Some(callback) = &self.backend_summary_callback {
342            callback(target)
343        } else {
344            format!("{target:?}")
345        }
346    }
347}
348
349#[derive(Clone)]
350struct HealthInner {
351    /// Whether the endpoint is healthy to serve traffic
352    healthy: bool,
353    /// The counter for stateful transition between healthy and unhealthy.
354    /// When [healthy] is true, this counts the number of consecutive health check failures
355    /// so that the caller can flip the healthy when a certain threshold is met, and vise versa.
356    consecutive_counter: usize,
357}
358
359/// Health of backends that can be updated atomically.
360///
361/// Clones share the same state so registry reconciliation cannot lose a health
362/// observation that completes concurrently.
363#[derive(Clone)]
364pub(crate) struct Health(Arc<ArcSwap<HealthInner>>);
365
366impl Default for Health {
367    fn default() -> Self {
368        Health(Arc::new(ArcSwap::new(Arc::new(HealthInner {
369            healthy: true, // TODO: allow to start with unhealthy
370            consecutive_counter: 0,
371        }))))
372    }
373}
374
375impl Health {
376    pub fn ready(&self) -> bool {
377        self.0.load().healthy
378    }
379
380    // return true when the health is flipped
381    pub fn observe_health(&self, health: bool, flip_threshold: usize) -> bool {
382        let h = self.0.load();
383        let mut flipped = false;
384        if h.healthy != health {
385            // opposite health observed, ready to increase the counter
386            // clone the inner
387            let mut new_health = (**h).clone();
388            new_health.consecutive_counter += 1;
389            if new_health.consecutive_counter >= flip_threshold {
390                new_health.healthy = health;
391                new_health.consecutive_counter = 0;
392                flipped = true;
393            }
394            self.0.store(Arc::new(new_health));
395        } else if h.consecutive_counter > 0 {
396            // observing the same health as the current state.
397            // reset the counter, if it is non-zero, because it is no longer consecutive
398            let mut new_health = (**h).clone();
399            new_health.consecutive_counter = 0;
400            self.0.store(Arc::new(new_health));
401        }
402        flipped
403    }
404}
405
406#[cfg(test)]
407mod test {
408    use std::{
409        collections::{BTreeSet, HashMap},
410        sync::atomic::{AtomicU16, Ordering},
411    };
412
413    use super::*;
414    use crate::{discovery, Backends, SocketAddr};
415    use async_trait::async_trait;
416    use http::Extensions;
417
418    #[tokio::test]
419    async fn test_tcp_check() {
420        let tcp_check = TcpHealthCheck::default();
421
422        let backend = Backend {
423            addr: SocketAddr::Inet("1.1.1.1:80".parse().unwrap()),
424            weight: 1,
425            ext: Extensions::new(),
426        };
427
428        assert!(tcp_check.check(&backend).await.is_ok());
429
430        let backend = Backend {
431            addr: SocketAddr::Inet("1.1.1.1:79".parse().unwrap()),
432            weight: 1,
433            ext: Extensions::new(),
434        };
435
436        assert!(tcp_check.check(&backend).await.is_err());
437    }
438
439    #[cfg(feature = "any_tls")]
440    #[tokio::test]
441    async fn test_tls_check() {
442        let tls_check = TcpHealthCheck::new_tls("one.one.one.one");
443        let backend = Backend {
444            addr: SocketAddr::Inet("1.1.1.1:443".parse().unwrap()),
445            weight: 1,
446            ext: Extensions::new(),
447        };
448
449        assert!(tls_check.check(&backend).await.is_ok());
450    }
451
452    #[cfg(feature = "any_tls")]
453    #[tokio::test]
454    async fn test_https_check() {
455        let https_check = HttpHealthCheck::new("one.one.one.one", true);
456
457        let backend = Backend {
458            addr: SocketAddr::Inet("1.1.1.1:443".parse().unwrap()),
459            weight: 1,
460            ext: Extensions::new(),
461        };
462
463        assert!(https_check.check(&backend).await.is_ok());
464    }
465
466    #[tokio::test]
467    async fn test_http_custom_check() {
468        let mut http_check = HttpHealthCheck::new("one.one.one.one", false);
469        http_check.validator = Some(Box::new(|resp: &ResponseHeader| {
470            if resp.status == 301 {
471                Ok(())
472            } else {
473                Error::e_explain(
474                    CustomCode("non 301 code", resp.status.as_u16()),
475                    "during http healthcheck",
476                )
477            }
478        }));
479
480        let backend = Backend {
481            addr: SocketAddr::Inet("1.1.1.1:80".parse().unwrap()),
482            weight: 1,
483            ext: Extensions::new(),
484        };
485
486        http_check.check(&backend).await.unwrap();
487
488        assert!(http_check.check(&backend).await.is_ok());
489    }
490
491    #[tokio::test]
492    async fn test_health_observe() {
493        struct Observe {
494            unhealthy_count: Arc<AtomicU16>,
495        }
496        #[async_trait]
497        impl HealthObserve for Observe {
498            async fn observe(&self, _target: &Backend, healthy: bool) {
499                if !healthy {
500                    self.unhealthy_count.fetch_add(1, Ordering::Relaxed);
501                }
502            }
503        }
504
505        let good_backend = Backend::new("127.0.0.1:79").unwrap();
506        let new_good_backends = || -> (BTreeSet<Backend>, HashMap<u64, bool>) {
507            let mut healthy = HashMap::new();
508            healthy.insert(good_backend.hash_key(), true);
509            let mut backends = BTreeSet::new();
510            backends.extend(vec![good_backend.clone()]);
511            (backends, healthy)
512        };
513        // tcp health check
514        {
515            let unhealthy_count = Arc::new(AtomicU16::new(0));
516            let ob = Observe {
517                unhealthy_count: unhealthy_count.clone(),
518            };
519            let bob = Box::new(ob);
520            let tcp_check = TcpHealthCheck {
521                health_changed_callback: Some(bob),
522                ..Default::default()
523            };
524
525            let discovery = discovery::Static::default();
526            let mut backends = Backends::new(Box::new(discovery));
527            backends.set_health_check(Box::new(tcp_check));
528            let result = new_good_backends();
529            backends.do_update(result.0, result.1, |_backend: Arc<BTreeSet<Backend>>| {});
530            // the backend is ready
531            assert!(backends.ready(&good_backend));
532
533            // run health check
534            backends.run_health_check(false).await;
535            assert!(1 == unhealthy_count.load(Ordering::Relaxed));
536            // backend is unhealthy
537            assert!(!backends.ready(&good_backend));
538        }
539
540        // http health check
541        {
542            let unhealthy_count = Arc::new(AtomicU16::new(0));
543            let ob = Observe {
544                unhealthy_count: unhealthy_count.clone(),
545            };
546            let bob = Box::new(ob);
547
548            let mut https_check = HttpHealthCheck::new("one.one.one.one", true);
549            https_check.health_changed_callback = Some(bob);
550
551            let discovery = discovery::Static::default();
552            let mut backends = Backends::new(Box::new(discovery));
553            backends.set_health_check(Box::new(https_check));
554            let result = new_good_backends();
555            backends.do_update(result.0, result.1, |_backend: Arc<BTreeSet<Backend>>| {});
556            // the backend is ready
557            assert!(backends.ready(&good_backend));
558            // run health check
559            backends.run_health_check(false).await;
560            assert!(1 == unhealthy_count.load(Ordering::Relaxed));
561            assert!(!backends.ready(&good_backend));
562        }
563    }
564}