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