1use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use rand::RngExt;
8
9use tokio::sync::RwLock;
10use tokio::task::{JoinHandle, JoinSet};
11use tokio_util::sync::CancellationToken;
12use uuid::Uuid;
13
14use crate::storage::ProxyStoragePort;
15use crate::types::ProxyConfig;
16
17pub type HealthMap = Arc<RwLock<HashMap<Uuid, bool>>>;
20
21#[derive(Clone)]
31pub struct HealthChecker {
32 config: ProxyConfig,
33 storage: Arc<dyn ProxyStoragePort>,
34 health_map: HealthMap,
35 #[cfg(feature = "tls-profiled")]
38 profiled: Option<crate::http_client::ProfiledRequester>,
39}
40
41impl HealthChecker {
42 #[must_use]
44 pub const fn health_map(&self) -> &HealthMap {
45 &self.health_map
46 }
47
48 pub fn new(
53 config: ProxyConfig,
54 storage: Arc<dyn ProxyStoragePort>,
55 health_map: HealthMap,
56 ) -> Self {
57 Self {
58 config,
59 storage,
60 health_map,
61 #[cfg(feature = "tls-profiled")]
62 profiled: None,
63 }
64 }
65
66 #[cfg(feature = "tls-profiled")]
92 #[must_use]
93 pub fn with_profiled_client(
94 mut self,
95 requester: crate::http_client::ProfiledRequester,
96 ) -> Self {
97 self.profiled = Some(requester);
98 self
99 }
100
101 #[cfg(feature = "tls-profiled")]
113 pub fn with_profiled_mode(
114 self,
115 mode: crate::types::ProfiledRequestMode,
116 ) -> crate::error::ProxyResult<Self> {
117 let requester = crate::http_client::ProfiledRequester::chrome_mode(mode)
118 .map_err(|e| crate::error::ProxyError::ConfigError(e.to_string()))?;
119 Ok(self.with_profiled_client(requester))
120 }
121
122 #[must_use]
128 pub fn spawn(self, token: CancellationToken) -> JoinHandle<()> {
129 tokio::spawn(async move {
130 loop {
131 let sleep_dur = jitter_duration(
132 self.config.health_check_interval,
133 self.config.health_check_jitter_pct,
134 );
135 tokio::select! {
136 () = token.cancelled() => {
137 tracing::info!("health checker: shutdown requested");
138 break;
139 }
140 () = tokio::time::sleep(sleep_dur) => {
141 self.check_all().await;
142 }
143 }
144 }
145 tracing::info!("health checker: stopped");
146 })
147 }
148
149 pub async fn check_once(&self) {
151 self.check_all().await;
152 }
153
154 async fn check_all(&self) {
155 let records = match self.storage.list().await {
156 Ok(r) => r,
157 Err(e) => {
158 tracing::error!("health checker: storage list failed: {e}");
159 return;
160 }
161 };
162
163 let health_url = self.config.health_check_url.clone();
164 let timeout = self.config.health_check_timeout;
165
166 let mut set: JoinSet<(Uuid, Result<u64, String>)> = JoinSet::new();
167 for record in records {
168 let proxy_url = record.proxy.url.clone();
169 let username = record.proxy.username.clone();
170 let password = record.proxy.password.clone();
171 let id = record.id;
172 let check_url = health_url.clone();
173
174 #[cfg(feature = "tls-profiled")]
178 let routed_proxy_url =
179 proxy_url_with_auth(&proxy_url, username.as_deref(), password.as_deref());
180
181 #[cfg(feature = "tls-profiled")]
182 let preset_client: Option<reqwest::Client> = self.profiled.as_ref().and_then(|p| {
183 crate::http_client::ProfiledRequester::from_profile(
184 p.profile(),
185 Some(&routed_proxy_url),
186 )
187 .map(crate::http_client::ProfiledRequester::into_client)
188 .map_err(|e| {
189 tracing::warn!(
190 error = %e,
191 proxy = %routed_proxy_url,
192 "tls-profiled health-check client build failed; falling back to vanilla"
193 );
194 })
195 .ok()
196 });
197
198 #[cfg(not(feature = "tls-profiled"))]
199 let preset_client: Option<reqwest::Client> = None;
200
201 set.spawn(async move {
202 let result = do_check(
203 &proxy_url,
204 username.as_deref(),
205 password.as_deref(),
206 &check_url,
207 timeout,
208 preset_client,
209 )
210 .await;
211 (id, result)
212 });
213 }
214
215 let mut updates: Vec<(Uuid, bool, u64)> = Vec::new();
216 while let Some(task_result) = set.join_next().await {
217 match task_result {
218 Ok((id, Ok(latency_ms))) => updates.push((id, true, latency_ms)),
219 Ok((id, Err(e))) => {
220 tracing::warn!(proxy = %id, error = %e, "health check failed");
221 updates.push((id, false, 0));
222 }
223 Err(join_err) => {
224 tracing::error!("health check task panicked: {join_err}");
225 }
226 }
227 }
228
229 let total = u32::try_from(updates.len()).unwrap_or(u32::MAX);
230 let healthy_count =
231 u32::try_from(updates.iter().filter(|(_, h, _)| *h).count()).unwrap_or(u32::MAX);
232
233 {
234 let mut map = self.health_map.write().await;
235 for (id, healthy, _) in &updates {
236 map.insert(*id, *healthy);
237 }
238 }
239
240 for (id, success, latency) in updates {
241 if let Err(e) = self.storage.update_metrics(id, success, latency).await {
242 tracing::warn!("health checker: metrics update failed for {id}: {e}");
243 }
244 }
245
246 tracing::info!(
247 total,
248 healthy = healthy_count,
249 unhealthy = total - healthy_count,
250 "health check cycle complete"
251 );
252 }
253}
254
255fn jitter_duration(base: Duration, jitter_pct: f32) -> Duration {
260 if jitter_pct <= 0.0 {
261 return base;
262 }
263 let pct = jitter_pct.clamp(0.0, 0.99);
264 let factor = rand::rng()
266 .random::<f32>()
267 .mul_add(2.0_f32, -1.0_f32)
268 .mul_add(pct, 1.0_f32);
269 base.mul_f32(factor.max(0.01))
270}
271
272#[cfg(any(test, feature = "tls-profiled"))]
273fn proxy_url_with_auth(proxy_url: &str, username: Option<&str>, password: Option<&str>) -> String {
274 let (Some(user), Some(pass)) = (username, password) else {
275 return proxy_url.to_string();
276 };
277
278 let Ok(mut url) = reqwest::Url::parse(proxy_url) else {
279 return proxy_url.to_string();
280 };
281
282 if url.username().is_empty() && url.set_username(user).is_err() {
283 return proxy_url.to_string();
284 }
285 if url.password().is_none() && url.set_password(Some(pass)).is_err() {
286 return proxy_url.to_string();
287 }
288
289 url.to_string()
290}
291
292async fn do_check(
293 proxy_url: &str,
294 username: Option<&str>,
295 password: Option<&str>,
296 health_url: &str,
297 timeout: std::time::Duration,
298 preset_client: Option<reqwest::Client>,
299) -> Result<u64, String> {
300 let client = if let Some(c) = preset_client {
304 c
305 } else {
306 let mut proxy = reqwest::Proxy::all(proxy_url).map_err(|e| e.to_string())?;
307 if let (Some(user), Some(pass)) = (username, password) {
308 proxy = proxy.basic_auth(user, pass);
309 }
310 reqwest::Client::builder()
311 .proxy(proxy)
312 .timeout(timeout)
313 .build()
314 .map_err(|e| e.to_string())?
315 };
316
317 let start = Instant::now();
318 client
319 .get(health_url)
320 .timeout(timeout)
321 .send()
322 .await
323 .map_err(|e| e.to_string())?
324 .error_for_status()
325 .map_err(|e| e.to_string())?;
326 Ok(start.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
327}
328
329#[cfg(test)]
334mod tests {
335 use std::time::Duration;
336
337 use wiremock::matchers::method;
338 use wiremock::{Mock, MockServer, ResponseTemplate};
339
340 use super::*;
341 use crate::storage::MemoryProxyStore;
342 use crate::types::{Proxy, ProxyType};
343
344 fn make_proxy(url: &str) -> Proxy {
345 Proxy {
346 url: url.into(),
347 proxy_type: ProxyType::Http,
348 username: None,
349 password: None,
350 weight: 1,
351 tags: vec![],
352 capabilities: crate::types::ProxyCapabilities::default(),
353 ip_class: crate::types::IpClass::Unknown,
354 target_compatibility: crate::types::TargetVendorCompatibility::default(),
355 }
356 }
357 #[test]
358 fn proxy_url_with_auth_injects_credentials() {
359 let proxy_url = proxy_url_with_auth(
360 "http://proxy.example.com:8080",
361 Some("alice"),
362 Some("s3cr3t"),
363 );
364 assert!(proxy_url.starts_with("http://alice:s3cr3t@proxy.example.com:8080"));
365 }
366
367 #[cfg(feature = "tls-profiled")]
368 #[test]
369 fn proxy_url_with_auth_leaves_existing_credentials_untouched() {
370 let proxy_url = proxy_url_with_auth(
371 "http://already:present@proxy.example.com:8080",
372 Some("alice"),
373 Some("s3cr3t"),
374 );
375 assert!(proxy_url.starts_with("http://already:present@proxy.example.com:8080"));
376 }
377
378 #[tokio::test]
379 async fn healthy_and_unhealthy_proxies() -> crate::error::ProxyResult<()> {
380 let server = MockServer::start().await;
383 Mock::given(method("GET"))
384 .respond_with(ResponseTemplate::new(200))
385 .mount(&server)
386 .await;
387
388 let storage = Arc::new(MemoryProxyStore::default());
389 storage.add(make_proxy(&server.uri())).await?;
391 storage.add(make_proxy("http://192.0.2.1:9999")).await?;
393
394 let health_map: HealthMap = Arc::new(RwLock::new(HashMap::new()));
395 let config = ProxyConfig {
396 health_check_url: format!("{}/", server.uri()),
397 health_check_interval: Duration::from_hours(1),
398 health_check_timeout: Duration::from_secs(2),
399 ..ProxyConfig::default()
400 };
401 let checker = HealthChecker::new(config, storage.clone(), health_map.clone());
402 checker.check_once().await;
403
404 let map = health_map.read().await;
405 let healthy = map.values().filter(|&&v| v).count();
406 let unhealthy = map.values().filter(|&&v| !v).count();
407 drop(map);
408 assert_eq!(healthy, 1, "expected 1 healthy proxy");
409 assert_eq!(unhealthy, 1, "expected 1 unhealthy proxy");
410 Ok(())
411 }
412
413 #[tokio::test]
414 async fn graceful_shutdown() {
415 let storage = Arc::new(MemoryProxyStore::default());
416 let health_map: HealthMap = Arc::new(RwLock::new(HashMap::new()));
417 let config = ProxyConfig {
418 health_check_interval: Duration::from_hours(1),
419 ..ProxyConfig::default()
420 };
421 let token = CancellationToken::new();
422 let checker = HealthChecker::new(config, storage, health_map);
423 let handle = checker.spawn(token.clone());
424
425 token.cancel();
426 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
427 assert!(
428 result.is_ok(),
429 "task should exit within 1s after cancellation"
430 );
431 }
432}