1use std::collections::HashMap;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9
10use serde::Serialize;
11use tokio::sync::RwLock;
12use tokio::task::JoinHandle;
13use tokio_util::sync::CancellationToken;
14use uuid::Uuid;
15
16use crate::circuit_breaker::CircuitBreaker;
17use crate::error::{ProxyError, ProxyResult};
18use crate::health::{HealthChecker, HealthMap};
19use crate::session::{SessionMap, StickyPolicy};
20use crate::storage::ProxyStoragePort;
21use crate::strategy::{
22 BoxedRotationStrategy, LeastUsedStrategy, ProxyCandidate, RandomStrategy, RoundRobinStrategy,
23 WeightedStrategy, capable_healthy_candidates,
24};
25use crate::types::{CapabilityRequirement, Proxy, ProxyConfig};
26
27#[derive(Debug, Serialize)]
33pub struct PoolStats {
34 pub total: usize,
36 pub healthy: usize,
38 pub open: usize,
40 pub active_sessions: usize,
42}
43
44pub struct ProxyHandle {
54 pub proxy_url: String,
56 circuit_breaker: Arc<CircuitBreaker>,
57 succeeded: AtomicBool,
58 session_key: Option<String>,
60 sessions: Option<SessionMap>,
61}
62
63impl ProxyHandle {
64 const fn new(proxy_url: String, circuit_breaker: Arc<CircuitBreaker>) -> Self {
65 Self {
66 proxy_url,
67 circuit_breaker,
68 succeeded: AtomicBool::new(false),
69 session_key: None,
70 sessions: None,
71 }
72 }
73
74 const fn new_sticky(
75 proxy_url: String,
76 circuit_breaker: Arc<CircuitBreaker>,
77 session_key: String,
78 sessions: SessionMap,
79 ) -> Self {
80 Self {
81 proxy_url,
82 circuit_breaker,
83 succeeded: AtomicBool::new(false),
84 session_key: Some(session_key),
85 sessions: Some(sessions),
86 }
87 }
88
89 pub fn direct() -> Self {
94 let noop_cb = Arc::new(CircuitBreaker::new(u32::MAX, u64::MAX));
95 Self {
96 proxy_url: String::new(),
97 circuit_breaker: noop_cb,
98 succeeded: AtomicBool::new(true),
99 session_key: None,
100 sessions: None,
101 }
102 }
103
104 pub fn mark_success(&self) {
106 self.succeeded.store(true, Ordering::Release);
107 }
108}
109
110impl std::fmt::Debug for ProxyHandle {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.debug_struct("ProxyHandle")
113 .field("proxy_url", &self.proxy_url)
114 .finish_non_exhaustive()
115 }
116}
117
118impl Drop for ProxyHandle {
119 fn drop(&mut self) {
120 if self.succeeded.load(Ordering::Acquire) {
121 self.circuit_breaker.record_success();
122 } else {
123 self.circuit_breaker.record_failure();
124 if let (Some(key), Some(sessions)) = (&self.session_key, &self.sessions) {
126 sessions.unbind(key);
127 }
128 }
129 }
130}
131
132pub struct ProxyManager {
171 storage: Arc<dyn ProxyStoragePort>,
172 strategy: BoxedRotationStrategy,
173 health_checker: HealthChecker,
174 circuit_breakers: Arc<RwLock<HashMap<Uuid, Arc<CircuitBreaker>>>>,
175 config: ProxyConfig,
176 sessions: SessionMap,
178}
179
180impl ProxyManager {
181 pub fn builder() -> ProxyManagerBuilder {
183 ProxyManagerBuilder::default()
184 }
185
186 pub fn with_round_robin(
188 storage: Arc<dyn ProxyStoragePort>,
189 config: ProxyConfig,
190 ) -> ProxyResult<Self> {
191 Self::builder()
192 .storage(storage)
193 .strategy(Arc::new(RoundRobinStrategy::default()))
194 .config(config)
195 .build()
196 }
197
198 pub fn with_random(
200 storage: Arc<dyn ProxyStoragePort>,
201 config: ProxyConfig,
202 ) -> ProxyResult<Self> {
203 Self::builder()
204 .storage(storage)
205 .strategy(Arc::new(RandomStrategy))
206 .config(config)
207 .build()
208 }
209
210 pub fn with_weighted(
212 storage: Arc<dyn ProxyStoragePort>,
213 config: ProxyConfig,
214 ) -> ProxyResult<Self> {
215 Self::builder()
216 .storage(storage)
217 .strategy(Arc::new(WeightedStrategy))
218 .config(config)
219 .build()
220 }
221
222 pub fn with_least_used(
224 storage: Arc<dyn ProxyStoragePort>,
225 config: ProxyConfig,
226 ) -> ProxyResult<Self> {
227 Self::builder()
228 .storage(storage)
229 .strategy(Arc::new(LeastUsedStrategy))
230 .config(config)
231 .build()
232 }
233
234 #[allow(clippy::significant_drop_tightening)]
245 pub async fn add_proxy(&self, proxy: Proxy) -> ProxyResult<Uuid> {
246 let mut cb_map = self.circuit_breakers.write().await;
247 let record = self.storage.add(proxy).await?;
248 cb_map.insert(
249 record.id,
250 Arc::new(CircuitBreaker::new(
251 self.config.circuit_open_threshold,
252 u64::try_from(self.config.circuit_half_open_after.as_millis()).unwrap_or(u64::MAX),
253 )),
254 );
255 Ok(record.id)
256 }
257
258 pub async fn remove_proxy(&self, id: Uuid) -> ProxyResult<()> {
260 self.storage.remove(id).await?;
261 self.circuit_breakers.write().await.remove(&id);
262 Ok(())
263 }
264
265 pub fn start(&self) -> (CancellationToken, JoinHandle<()>) {
272 let token = CancellationToken::new();
273 let health_handle = self.health_checker.clone().spawn(token.clone());
274
275 let sessions = self.sessions.clone();
276 let purge_token = token.clone();
277 let purge_handle = tokio::spawn(async move {
278 let mut interval = tokio::time::interval(std::time::Duration::from_mins(1));
279 loop {
280 tokio::select! {
281 _ = interval.tick() => { sessions.purge_expired(); }
282 () = purge_token.cancelled() => break,
283 }
284 }
285 });
286
287 let combined = tokio::spawn(async move {
288 let _ = tokio::join!(health_handle, purge_handle);
289 });
290
291 (token, combined)
292 }
293
294 #[allow(clippy::significant_drop_tightening)]
300 async fn select_proxy_inner(&self) -> ProxyResult<(String, Arc<CircuitBreaker>, Uuid)> {
301 let with_metrics = self.storage.list_with_metrics().await?;
302 if with_metrics.is_empty() {
303 return Err(ProxyError::PoolExhausted);
304 }
305
306 let candidates = {
309 let health_map_ref = Arc::clone(self.health_checker.health_map());
310 let health_map = health_map_ref.read().await;
311 let cb_map_ref = Arc::clone(&self.circuit_breakers);
312 let cb_map = cb_map_ref.read().await;
313 let candidates: Vec<ProxyCandidate> = with_metrics
314 .iter()
315 .map(|(record, metrics)| {
316 let healthy = health_map.get(&record.id).copied().unwrap_or(true);
317 let available = cb_map.get(&record.id).is_none_or(|cb| cb.is_available());
318 ProxyCandidate {
319 id: record.id,
320 weight: record.proxy.weight,
321 metrics: Arc::clone(metrics),
322 healthy: healthy && available,
323 capabilities: record.proxy.capabilities.clone(),
324 }
325 })
326 .collect();
327 candidates
328 };
330
331 let selected = self.strategy.select(&candidates).await?;
332 let id = selected.id;
333
334 let cb = self
336 .circuit_breakers
337 .read()
338 .await
339 .get(&id)
340 .cloned()
341 .ok_or(ProxyError::PoolExhausted)?;
342 let url = with_metrics
343 .iter()
344 .find(|(r, _)| r.id == id)
345 .map(|(r, _)| r.proxy.url.clone())
346 .unwrap_or_default();
347
348 Ok((url, cb, id))
349 }
350
351 pub async fn acquire_proxy(&self) -> ProxyResult<ProxyHandle> {
357 let (url, cb, _id) = self.select_proxy_inner().await?;
358 Ok(ProxyHandle::new(url, cb))
359 }
360
361 pub async fn acquire_with_capabilities(
381 &self,
382 req: &CapabilityRequirement,
383 ) -> ProxyResult<ProxyHandle> {
384 let with_metrics = self.storage.list_with_metrics().await?;
385
386 if with_metrics.is_empty() {
387 return Err(ProxyError::PoolExhausted);
388 }
389
390 let candidates = {
391 let health_map_ref = Arc::clone(self.health_checker.health_map());
392 let health_map = health_map_ref.read().await;
393 let cb_map_ref = Arc::clone(&self.circuit_breakers);
394 let cb_map = cb_map_ref.read().await;
395 let candidates: Vec<ProxyCandidate> = with_metrics
396 .iter()
397 .map(|(record, metrics)| {
398 let healthy = health_map.get(&record.id).copied().unwrap_or(true);
399 let available = cb_map.get(&record.id).is_none_or(|cb| cb.is_available());
400 ProxyCandidate {
401 id: record.id,
402 weight: record.proxy.weight,
403 metrics: Arc::clone(metrics),
404 healthy: healthy && available,
405 capabilities: record.proxy.capabilities.clone(),
406 }
407 })
408 .collect();
409 candidates
410 };
411
412 let compatible: Vec<ProxyCandidate> = capable_healthy_candidates(&candidates, req)
414 .into_iter()
415 .cloned()
416 .collect();
417 if compatible.is_empty() {
418 return Err(ProxyError::NoCompatibleProxy);
419 }
420
421 let selected = self.strategy.select(&compatible).await?;
422 let id = selected.id;
423
424 let cb = self
425 .circuit_breakers
426 .read()
427 .await
428 .get(&id)
429 .cloned()
430 .ok_or(ProxyError::PoolExhausted)?;
431 let url = with_metrics
432 .iter()
433 .find(|(r, _)| r.id == id)
434 .map(|(r, _)| r.proxy.url.clone())
435 .unwrap_or_default();
436
437 Ok(ProxyHandle::new(url, cb))
438 }
439
440 pub async fn acquire_for_domain(&self, domain: &str) -> ProxyResult<ProxyHandle> {
454 let ttl = match &self.config.sticky_policy {
455 StickyPolicy::Disabled => return self.acquire_proxy().await,
456 StickyPolicy::Domain { ttl } => *ttl,
457 };
458
459 if let Some(proxy_id) = self.sessions.lookup(domain) {
461 let cb_map = self.circuit_breakers.read().await;
462 if let Some(cb) = cb_map.get(&proxy_id).cloned()
463 && cb.is_available()
464 {
465 let with_metrics = self.storage.list_with_metrics().await?;
467 if let Some((record, _)) = with_metrics.iter().find(|(r, _)| r.id == proxy_id) {
468 let url = record.proxy.url.clone();
469 drop(cb_map);
470 return Ok(ProxyHandle::new_sticky(
471 url,
472 cb,
473 domain.to_string(),
474 self.sessions.clone(),
475 ));
476 }
477 }
478 drop(cb_map);
480 self.sessions.unbind(domain);
481 }
482
483 let (url, cb, proxy_id) = self.select_proxy_inner().await?;
485 self.sessions.bind(domain, proxy_id, ttl);
486 Ok(ProxyHandle::new_sticky(
487 url,
488 cb,
489 domain.to_string(),
490 self.sessions.clone(),
491 ))
492 }
493
494 pub async fn pool_stats(&self) -> ProxyResult<PoolStats> {
498 let records = self.storage.list().await?;
499 let total = records.len();
500 let health_map = self.health_checker.health_map().read().await;
501 let cb_map = self.circuit_breakers.read().await;
502
503 let mut healthy = 0usize;
504 let mut open = 0usize;
505 for r in &records {
506 if health_map.get(&r.id).copied().unwrap_or(true) {
507 healthy += 1;
508 }
509 if cb_map.get(&r.id).is_some_and(|cb| !cb.is_available()) {
510 open += 1;
511 }
512 }
513 drop(health_map);
514 drop(cb_map);
515 Ok(PoolStats {
516 total,
517 healthy,
518 open,
519 active_sessions: self.sessions.active_count(),
520 })
521 }
522}
523
524#[derive(Default)]
530pub struct ProxyManagerBuilder {
531 storage: Option<Arc<dyn ProxyStoragePort>>,
532 strategy: Option<BoxedRotationStrategy>,
533 config: Option<ProxyConfig>,
534}
535
536impl ProxyManagerBuilder {
537 #[must_use]
538 pub fn storage(mut self, s: Arc<dyn ProxyStoragePort>) -> Self {
539 self.storage = Some(s);
540 self
541 }
542
543 #[must_use]
544 pub fn strategy(mut self, s: BoxedRotationStrategy) -> Self {
545 self.strategy = Some(s);
546 self
547 }
548
549 #[must_use]
550 pub fn config(mut self, c: ProxyConfig) -> Self {
551 self.config = Some(c);
552 self
553 }
554
555 pub fn build(self) -> ProxyResult<ProxyManager> {
561 let storage = self.storage.ok_or_else(|| {
562 ProxyError::ConfigError("ProxyManagerBuilder: storage is required".into())
563 })?;
564 let strategy = self
565 .strategy
566 .unwrap_or_else(|| Arc::new(RoundRobinStrategy::default()));
567 let config = self.config.unwrap_or_default();
568 let health_map: HealthMap = Arc::new(RwLock::new(HashMap::new()));
569 let checker = HealthChecker::new(
570 config.clone(),
571 Arc::clone(&storage),
572 Arc::clone(&health_map),
573 );
574
575 #[cfg(feature = "tls-profiled")]
576 let health_checker = if let Some(mode) = config.profiled_request_mode {
577 checker.with_profiled_mode(mode)?
578 } else {
579 checker
580 };
581
582 #[cfg(not(feature = "tls-profiled"))]
583 let health_checker = checker;
584
585 Ok(ProxyManager {
586 storage,
587 strategy,
588 health_checker,
589 circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
590 config,
591 sessions: SessionMap::new(),
592 })
593 }
594}
595
596#[cfg(test)]
601#[allow(
602 clippy::unwrap_used,
603 clippy::significant_drop_tightening,
604 clippy::manual_let_else,
605 clippy::panic
606)]
607mod tests {
608 use std::collections::HashSet;
609 use std::time::Duration;
610
611 use super::*;
612 use crate::circuit_breaker::{STATE_CLOSED, STATE_OPEN};
613 use crate::storage::MemoryProxyStore;
614 use crate::types::ProxyType;
615
616 fn make_proxy(url: &str) -> Proxy {
617 Proxy {
618 url: url.into(),
619 proxy_type: ProxyType::Http,
620 username: None,
621 password: None,
622 weight: 1,
623 tags: vec![],
624 capabilities: crate::types::ProxyCapabilities::default(),
625 }
626 }
627
628 fn storage() -> Arc<MemoryProxyStore> {
629 Arc::new(MemoryProxyStore::default())
630 }
631
632 #[tokio::test]
634 async fn round_robin_distribution() {
635 let store = storage();
636 let mgr = ProxyManager::with_round_robin(store.clone(), ProxyConfig::default()).unwrap();
637 mgr.add_proxy(make_proxy("http://a.test:8080"))
638 .await
639 .unwrap();
640 mgr.add_proxy(make_proxy("http://b.test:8080"))
641 .await
642 .unwrap();
643 mgr.add_proxy(make_proxy("http://c.test:8080"))
644 .await
645 .unwrap();
646
647 let mut seen = HashSet::new();
648 for _ in 0..10 {
649 let h = mgr.acquire_proxy().await.unwrap();
650 h.mark_success();
651 seen.insert(h.proxy_url.clone());
652 }
653 assert_eq!(seen.len(), 3, "all three proxies should have been selected");
654 }
655
656 #[tokio::test]
658 async fn all_open_returns_error() {
659 let store = storage();
660 let mgr = ProxyManager::with_round_robin(
661 store.clone(),
662 ProxyConfig {
663 circuit_open_threshold: 1,
664 ..ProxyConfig::default()
665 },
666 )
667 .unwrap();
668 let id = mgr
669 .add_proxy(make_proxy("http://x.test:8080"))
670 .await
671 .unwrap();
672
673 {
675 let map = mgr.circuit_breakers.read().await;
676 let cb = map.get(&id).unwrap();
677 cb.record_failure();
678 }
679
680 let err = mgr.acquire_proxy().await.unwrap_err();
681 assert!(
682 matches!(err, ProxyError::AllProxiesUnhealthy),
683 "expected AllProxiesUnhealthy, got {err:?}"
684 );
685 }
686
687 #[tokio::test]
689 async fn handle_drop_records_failure() {
690 let store = storage();
691 let mgr = ProxyManager::with_round_robin(
692 store.clone(),
693 ProxyConfig {
694 circuit_open_threshold: 1,
695 ..ProxyConfig::default()
696 },
697 )
698 .unwrap();
699 let id = mgr
700 .add_proxy(make_proxy("http://y.test:8080"))
701 .await
702 .unwrap();
703
704 {
705 let _h = mgr.acquire_proxy().await.unwrap();
706 }
708
709 let cb_map = mgr.circuit_breakers.read().await;
710 let cb = cb_map.get(&id).unwrap();
711 assert_eq!(cb.state(), STATE_OPEN);
712 }
713
714 #[tokio::test]
716 async fn handle_success_keeps_closed() {
717 let store = storage();
718 let mgr = ProxyManager::with_round_robin(store.clone(), ProxyConfig::default()).unwrap();
719 let id = mgr
720 .add_proxy(make_proxy("http://z.test:8080"))
721 .await
722 .unwrap();
723
724 let h = mgr.acquire_proxy().await.unwrap();
725 h.mark_success();
726 drop(h);
727
728 let cb_map = mgr.circuit_breakers.read().await;
729 let cb = cb_map.get(&id).unwrap();
730 assert_eq!(cb.state(), STATE_CLOSED);
731 }
732
733 #[tokio::test]
735 async fn start_and_graceful_shutdown() {
736 let store = storage();
737 let mgr = ProxyManager::with_round_robin(
738 store,
739 ProxyConfig {
740 health_check_interval: Duration::from_hours(1),
741 ..ProxyConfig::default()
742 },
743 )
744 .unwrap();
745 let (token, handle) = mgr.start();
746 token.cancel();
747 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
748 assert!(result.is_ok(), "health checker task should exit within 1s");
749 }
750
751 #[cfg(feature = "tls-profiled")]
752 #[tokio::test]
753 async fn builder_accepts_profiled_request_mode_preset() {
754 let store = storage();
755 let cfg = ProxyConfig {
756 profiled_request_mode: Some(crate::types::ProfiledRequestMode::Preset),
757 ..ProxyConfig::default()
758 };
759
760 let result = ProxyManager::builder()
761 .storage(store)
762 .strategy(Arc::new(RoundRobinStrategy::default()))
763 .config(cfg)
764 .build();
765
766 assert!(
767 result.is_ok(),
768 "builder should accept profiled preset mode: {:?}",
769 result.err()
770 );
771 }
772
773 #[cfg(feature = "tls-profiled")]
774 #[tokio::test]
775 async fn builder_rejects_profiled_request_mode_strict_all_for_chrome() {
776 let store = storage();
777 let cfg = ProxyConfig {
778 profiled_request_mode: Some(crate::types::ProfiledRequestMode::StrictAll),
779 ..ProxyConfig::default()
780 };
781
782 let result = ProxyManager::builder()
783 .storage(store)
784 .strategy(Arc::new(RoundRobinStrategy::default()))
785 .config(cfg)
786 .build();
787
788 let Err(err) = result else {
789 panic!("strict_all should fail for default Chrome baseline profile")
790 };
791
792 assert!(
793 matches!(err, ProxyError::ConfigError(_)),
794 "expected ConfigError, got {err:?}"
795 );
796 }
797
798 fn sticky_config() -> ProxyConfig {
801 use crate::session::StickyPolicy;
802 ProxyConfig {
803 sticky_policy: StickyPolicy::domain_default(),
804 ..ProxyConfig::default()
805 }
806 }
807
808 #[tokio::test]
810 async fn sticky_same_domain_returns_same_proxy() {
811 let store = storage();
812 let mgr = ProxyManager::with_round_robin(store, sticky_config()).unwrap();
813 mgr.add_proxy(make_proxy("http://p1.test:8080"))
814 .await
815 .unwrap();
816 mgr.add_proxy(make_proxy("http://p2.test:8080"))
817 .await
818 .unwrap();
819
820 let h1 = mgr.acquire_for_domain("example.com").await.unwrap();
821 let url1 = h1.proxy_url.clone();
822 h1.mark_success();
823
824 let h2 = mgr.acquire_for_domain("example.com").await.unwrap();
825 let url2 = h2.proxy_url.clone();
826 h2.mark_success();
827
828 assert_eq!(url1, url2, "same domain should return the same proxy");
829 }
830
831 #[tokio::test]
833 async fn sticky_different_domains_may_differ() {
834 let store = storage();
835 let mgr = ProxyManager::with_round_robin(store, sticky_config()).unwrap();
836 mgr.add_proxy(make_proxy("http://pa.test:8080"))
837 .await
838 .unwrap();
839 mgr.add_proxy(make_proxy("http://pb.test:8080"))
840 .await
841 .unwrap();
842
843 let ha = mgr.acquire_for_domain("a.com").await.unwrap();
844 let url_a = ha.proxy_url.clone();
845 ha.mark_success();
846
847 let hb = mgr.acquire_for_domain("b.com").await.unwrap();
848 let url_b = hb.proxy_url.clone();
849 hb.mark_success();
850
851 assert_ne!(
853 url_a, url_b,
854 "different domains should get different proxies"
855 );
856 }
857
858 #[tokio::test]
861 async fn sticky_expired_session_re_acquires() {
862 use crate::session::StickyPolicy;
863 let store = storage();
864 let mgr = ProxyManager::with_round_robin(
865 store,
866 ProxyConfig {
867 sticky_policy: StickyPolicy::domain(Duration::from_millis(1)),
868 ..ProxyConfig::default()
869 },
870 )
871 .unwrap();
872 mgr.add_proxy(make_proxy("http://x.test:8080"))
873 .await
874 .unwrap();
875
876 let h1 = mgr.acquire_for_domain("expired.com").await.unwrap();
877 h1.mark_success();
878
879 tokio::time::sleep(Duration::from_millis(5)).await;
881
882 let h2 = mgr.acquire_for_domain("expired.com").await.unwrap();
884 h2.mark_success();
885 }
886
887 #[tokio::test]
890 async fn sticky_cb_trip_invalidates_session() {
891 let store = storage();
892 let mgr = ProxyManager::with_round_robin(
893 store,
894 ProxyConfig {
895 circuit_open_threshold: 1,
896 sticky_policy: sticky_config().sticky_policy,
897 ..ProxyConfig::default()
898 },
899 )
900 .unwrap();
901 mgr.add_proxy(make_proxy("http://q1.test:8080"))
902 .await
903 .unwrap();
904 mgr.add_proxy(make_proxy("http://q2.test:8080"))
905 .await
906 .unwrap();
907
908 let h1 = mgr.acquire_for_domain("cb.com").await.unwrap();
910 let url1 = h1.proxy_url.clone();
911 drop(h1);
913
914 tokio::task::yield_now().await;
916
917 let _h2 = mgr.acquire_for_domain("cb.com").await;
921 let _ = url1;
923 }
924
925 #[tokio::test]
927 async fn sticky_purge_expired() {
928 use crate::session::StickyPolicy;
929 let store = storage();
930 let mgr = ProxyManager::with_round_robin(
931 store,
932 ProxyConfig {
933 sticky_policy: StickyPolicy::domain(Duration::from_millis(1)),
934 ..ProxyConfig::default()
935 },
936 )
937 .unwrap();
938 mgr.add_proxy(make_proxy("http://r.test:8080"))
939 .await
940 .unwrap();
941
942 let h = mgr.acquire_for_domain("purge.com").await.unwrap();
943 h.mark_success();
944
945 assert_eq!(mgr.sessions.active_count(), 1);
946
947 tokio::time::sleep(Duration::from_millis(5)).await;
949 mgr.sessions.purge_expired();
950
951 assert_eq!(mgr.sessions.active_count(), 0);
952 }
953
954 #[tokio::test]
956 async fn pool_stats_includes_sessions() {
957 let store = storage();
958 let mgr = ProxyManager::with_round_robin(store, sticky_config()).unwrap();
959 mgr.add_proxy(make_proxy("http://s.test:8080"))
960 .await
961 .unwrap();
962
963 let stats = mgr.pool_stats().await.unwrap();
964 assert_eq!(stats.active_sessions, 0);
965
966 let h = mgr.acquire_for_domain("stats.com").await.unwrap();
967 h.mark_success();
968
969 let stats = mgr.pool_stats().await.unwrap();
970 assert_eq!(stats.active_sessions, 1);
971 }
972}