postrust_proxy/health/
checker.rs1use 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#[derive(Clone, Debug, serde::Serialize)]
15pub struct BackendHealth {
16 pub is_healthy: bool,
18 pub consecutive_successes: u32,
20 pub consecutive_failures: u32,
22 pub last_check: DateTime<Utc>,
24 pub response_time_ms: Option<u64>,
26 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#[derive(Clone, Debug)]
45pub struct BackendInfo {
46 pub id: Uuid,
48 pub address: String,
50 pub scheme: String,
52 pub health_path: String,
54}
55
56pub struct HealthChecker {
58 pool: PgPool,
60 health: DashMap<Uuid, BackendHealth>,
62 backends: DashMap<Uuid, BackendInfo>,
64 client: reqwest::Client,
66}
67
68impl HealthChecker {
69 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 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 pub fn get_health(&self, backend_id: Uuid) -> Option<BackendHealth> {
94 self.health.get(&backend_id).map(|h| h.clone())
95 }
96
97 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 pub fn unregister_backend(&self, backend_id: Uuid) {
106 self.backends.remove(&backend_id);
107 self.health.remove(&backend_id);
108 }
109
110 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}