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