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 pub fn unregister_backend(&self, backend_id: Uuid) {
113 self.backends.remove(&backend_id);
114 self.health.remove(&backend_id);
115 }
116
117 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}