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    /// Unregister a backend.
112    pub fn unregister_backend(&self, backend_id: Uuid) {
113        self.backends.remove(&backend_id);
114        self.health.remove(&backend_id);
115    }
116
117    /// Start the health checker background task.
118    pub async fn start(
119        self: Arc<Self>,
120        config: HealthCheckConfig,
121        cancel_token: CancellationToken,
122    ) {
123        if !config.enabled {
124            info!("Health checking disabled");
125            return;
126        }
127
128        let interval = Duration::from_secs(config.interval_secs as u64);
129        let timeout = Duration::from_secs(config.timeout_secs as u64);
130        let healthy_threshold = config.healthy_threshold;
131        let unhealthy_threshold = config.unhealthy_threshold;
132
133        info!(
134            "Health checker started with {}s interval, {}s timeout",
135            config.interval_secs, config.timeout_secs
136        );
137
138        loop {
139            tokio::select! {
140                _ = cancel_token.cancelled() => {
141                    info!("Health checker stopped");
142                    break;
143                }
144                _ = tokio::time::sleep(interval) => {
145                    self.check_all_backends(timeout, healthy_threshold, unhealthy_threshold).await;
146                }
147            }
148        }
149    }
150
151    async fn check_all_backends(
152        &self,
153        timeout: Duration,
154        healthy_threshold: u32,
155        unhealthy_threshold: u32,
156    ) {
157        for entry in self.backends.iter() {
158            let backend = entry.value();
159            let health_url = format!(
160                "{}://{}{}",
161                backend.scheme, backend.address, backend.health_path
162            );
163
164            let start = Instant::now();
165            let result = self.client.get(&health_url).timeout(timeout).send().await;
166            let response_time = start.elapsed().as_millis() as u64;
167
168            self.update_health(
169                backend.id,
170                result,
171                response_time,
172                healthy_threshold,
173                unhealthy_threshold,
174            );
175        }
176    }
177
178    fn update_health(
179        &self,
180        backend_id: Uuid,
181        result: Result<reqwest::Response, reqwest::Error>,
182        response_time: u64,
183        healthy_threshold: u32,
184        unhealthy_threshold: u32,
185    ) {
186        let mut health = self.health.entry(backend_id).or_default();
187
188        health.last_check = Utc::now();
189        health.response_time_ms = Some(response_time);
190
191        match result {
192            Ok(response) if response.status().is_success() => {
193                health.consecutive_successes += 1;
194                health.consecutive_failures = 0;
195                health.last_error = None;
196
197                if health.consecutive_successes >= healthy_threshold && !health.is_healthy {
198                    info!("Backend {} is now healthy", backend_id);
199                    health.is_healthy = true;
200                }
201            }
202            Ok(response) => {
203                let error = format!("HTTP {}", response.status());
204                warn!("Health check failed for {}: {}", backend_id, error);
205
206                health.consecutive_failures += 1;
207                health.consecutive_successes = 0;
208                health.last_error = Some(error);
209
210                if health.consecutive_failures >= unhealthy_threshold && health.is_healthy {
211                    warn!("Backend {} is now unhealthy", backend_id);
212                    health.is_healthy = false;
213                }
214            }
215            Err(e) => {
216                warn!("Health check failed for {}: {}", backend_id, e);
217
218                health.consecutive_failures += 1;
219                health.consecutive_successes = 0;
220                health.last_error = Some(e.to_string());
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        }
228    }
229}