postrust_proxy/health/
checker.rs1use 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#[derive(Clone, Debug, serde::Serialize)]
14pub struct BackendHealth {
15 pub is_healthy: bool,
17 pub consecutive_successes: u32,
19 pub consecutive_failures: u32,
21 pub last_check: DateTime<Utc>,
23 pub response_time_ms: Option<u64>,
25 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#[derive(Clone, Debug)]
44pub struct BackendInfo {
45 pub id: Uuid,
47 pub address: String,
49 pub scheme: String,
51 pub health_path: String,
53}
54
55pub struct HealthChecker {
57 health: DashMap<Uuid, BackendHealth>,
59 backends: DashMap<Uuid, BackendInfo>,
61 client: reqwest::Client,
63}
64
65impl Default for HealthChecker {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl HealthChecker {
72 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 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 pub fn get_health(&self, backend_id: Uuid) -> Option<BackendHealth> {
101 self.health.get(&backend_id).map(|h| h.clone())
102 }
103
104 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 #[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 pub fn unregister_backend(&self, backend_id: Uuid) {
125 self.backends.remove(&backend_id);
126 self.health.remove(&backend_id);
127 }
128
129 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}