Skip to main content

postrust_proxy/health/
checker.rs

1//! Background health checker for backend servers.
2
3use crate::config::HealthCheckConfig;
4use chrono::{DateTime, Utc};
5use dashmap::DashMap;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio_util::sync::CancellationToken;
9use tracing::{info, warn};
10use uuid::Uuid;
11
12/// Health status for a backend.
13#[derive(Clone, Debug, serde::Serialize)]
14pub struct BackendHealth {
15    /// Whether the backend is healthy
16    pub is_healthy: bool,
17    /// Consecutive successful health checks
18    pub consecutive_successes: u32,
19    /// Consecutive failed health checks
20    pub consecutive_failures: u32,
21    /// Last health check time
22    pub last_check: DateTime<Utc>,
23    /// Last response time in milliseconds
24    pub response_time_ms: Option<u64>,
25    /// Last error message
26    pub last_error: Option<String>,
27}
28
29impl Default for BackendHealth {
30    fn default() -> Self {
31        Self {
32            is_healthy: true,
33            consecutive_successes: 0,
34            consecutive_failures: 0,
35            last_check: Utc::now(),
36            response_time_ms: None,
37            last_error: None,
38        }
39    }
40}
41
42/// Backend info for health checking.
43#[derive(Clone, Debug)]
44pub struct BackendInfo {
45    /// Backend ID
46    pub id: Uuid,
47    /// Backend address
48    pub address: String,
49    /// Backend scheme (http/https)
50    pub scheme: String,
51    /// Health check path
52    pub health_path: String,
53}
54
55/// Background health checker.
56pub struct HealthChecker {
57    /// Backend health status
58    health: DashMap<Uuid, BackendHealth>,
59    /// Backend info
60    backends: DashMap<Uuid, BackendInfo>,
61    /// HTTP client
62    client: reqwest::Client,
63}
64
65impl Default for HealthChecker {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl HealthChecker {
72    /// Create a new health checker.
73    /// A checker with no backends registered yet.
74    ///
75    /// Took a `PgPool` until health status was going to be persisted; it never
76    /// was, and a parameter nobody uses is a parameter every caller has to
77    /// supply for nothing.
78    pub fn new() -> Self {
79        let client = reqwest::Client::builder()
80            .timeout(Duration::from_secs(10))
81            .build()
82            .expect("Failed to build HTTP client");
83
84        Self {
85            health: DashMap::new(),
86            backends: DashMap::new(),
87            client,
88        }
89    }
90
91    /// Check if a backend is healthy.
92    pub fn is_healthy(&self, backend_id: Uuid) -> bool {
93        self.health
94            .get(&backend_id)
95            .map(|h| h.is_healthy)
96            .unwrap_or(true)
97    }
98
99    /// Get health status for a backend.
100    pub fn get_health(&self, backend_id: Uuid) -> Option<BackendHealth> {
101        self.health.get(&backend_id).map(|h| h.clone())
102    }
103
104    /// Register a backend for health checking.
105    pub fn register_backend(&self, info: BackendInfo) {
106        let id = info.id;
107        self.backends.insert(id, info);
108        self.health.insert(id, BackendHealth::default());
109    }
110
111    /// Mark a backend unhealthy without running a check.
112    #[cfg(test)]
113    pub(crate) fn mark_unhealthy_for_test(&self, backend_id: Uuid) {
114        self.health.insert(
115            backend_id,
116            BackendHealth {
117                is_healthy: false,
118                ..BackendHealth::default()
119            },
120        );
121    }
122
123    /// Unregister a backend.
124    pub fn unregister_backend(&self, backend_id: Uuid) {
125        self.backends.remove(&backend_id);
126        self.health.remove(&backend_id);
127    }
128
129    /// Start the health checker background task.
130    pub async fn start(
131        self: Arc<Self>,
132        config: HealthCheckConfig,
133        cancel_token: CancellationToken,
134    ) {
135        if !config.enabled {
136            info!("Health checking disabled");
137            return;
138        }
139
140        let interval = Duration::from_secs(config.interval_secs as u64);
141        let timeout = Duration::from_secs(config.timeout_secs as u64);
142        let healthy_threshold = config.healthy_threshold;
143        let unhealthy_threshold = config.unhealthy_threshold;
144
145        info!(
146            "Health checker started with {}s interval, {}s timeout",
147            config.interval_secs, config.timeout_secs
148        );
149
150        loop {
151            tokio::select! {
152                _ = cancel_token.cancelled() => {
153                    info!("Health checker stopped");
154                    break;
155                }
156                _ = tokio::time::sleep(interval) => {
157                    self.check_all_backends(timeout, healthy_threshold, unhealthy_threshold).await;
158                }
159            }
160        }
161    }
162
163    async fn check_all_backends(
164        &self,
165        timeout: Duration,
166        healthy_threshold: u32,
167        unhealthy_threshold: u32,
168    ) {
169        for entry in self.backends.iter() {
170            let backend = entry.value();
171            let health_url = format!(
172                "{}://{}{}",
173                backend.scheme, backend.address, backend.health_path
174            );
175
176            let start = Instant::now();
177            let result = self.client.get(&health_url).timeout(timeout).send().await;
178            let response_time = start.elapsed().as_millis() as u64;
179
180            self.update_health(
181                backend.id,
182                result,
183                response_time,
184                healthy_threshold,
185                unhealthy_threshold,
186            );
187        }
188    }
189
190    fn update_health(
191        &self,
192        backend_id: Uuid,
193        result: Result<reqwest::Response, reqwest::Error>,
194        response_time: u64,
195        healthy_threshold: u32,
196        unhealthy_threshold: u32,
197    ) {
198        let mut health = self.health.entry(backend_id).or_default();
199
200        health.last_check = Utc::now();
201        health.response_time_ms = Some(response_time);
202
203        match result {
204            Ok(response) if response.status().is_success() => {
205                health.consecutive_successes += 1;
206                health.consecutive_failures = 0;
207                health.last_error = None;
208
209                if health.consecutive_successes >= healthy_threshold && !health.is_healthy {
210                    info!("Backend {} is now healthy", backend_id);
211                    health.is_healthy = true;
212                }
213            }
214            Ok(response) => {
215                let error = format!("HTTP {}", response.status());
216                warn!("Health check failed for {}: {}", backend_id, error);
217
218                health.consecutive_failures += 1;
219                health.consecutive_successes = 0;
220                health.last_error = Some(error);
221
222                if health.consecutive_failures >= unhealthy_threshold && health.is_healthy {
223                    warn!("Backend {} is now unhealthy", backend_id);
224                    health.is_healthy = false;
225                }
226            }
227            Err(e) => {
228                warn!("Health check failed for {}: {}", backend_id, e);
229
230                health.consecutive_failures += 1;
231                health.consecutive_successes = 0;
232                health.last_error = Some(e.to_string());
233
234                if health.consecutive_failures >= unhealthy_threshold && health.is_healthy {
235                    warn!("Backend {} is now unhealthy", backend_id);
236                    health.is_healthy = false;
237                }
238            }
239        }
240    }
241}