Skip to main content

stygian_proxy/
manager.rs

1//! `ProxyManager`: unified proxy pool orchestrator.
2//!
3//! Assembles storage, rotation strategy, health checker, and per-proxy circuit
4//! breakers into a single ergonomic API.
5
6use 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// ─────────────────────────────────────────────────────────────────────────────
28// PoolStats
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// A snapshot of pool health at a point in time.
32#[derive(Debug, Serialize)]
33pub struct PoolStats {
34    /// Total proxies in the pool.
35    pub total: usize,
36    /// Proxies that passed the last health check.
37    pub healthy: usize,
38    /// Proxies whose circuit breaker is currently Open.
39    pub open: usize,
40    /// Active (non-expired) sticky sessions.
41    pub active_sessions: usize,
42}
43
44// ─────────────────────────────────────────────────────────────────────────────
45// ProxyHandle
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// RAII guard returned from [`ProxyManager::acquire_proxy`].
49///
50/// Call [`mark_success`](ProxyHandle::mark_success) once the request using
51/// this proxy completes successfully.  If the handle is dropped without a
52/// success mark the circuit breaker is notified of a failure.
53pub struct ProxyHandle {
54    /// URL of the selected proxy.
55    pub proxy_url: String,
56    circuit_breaker: Arc<CircuitBreaker>,
57    succeeded: AtomicBool,
58    /// Domain key to unbind from `sessions` on failure (sticky sessions only).
59    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    /// Create a no-proxy handle used when no proxy manager is configured.
90    ///
91    /// The handle targets an empty URL and uses a noop circuit breaker that
92    /// can never trip; its Drop records a success so there are no false failures.
93    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    /// Signal that the request succeeded.
105    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            // Invalidate the sticky session so the next request picks a fresh proxy.
125            if let (Some(key), Some(sessions)) = (&self.session_key, &self.sessions) {
126                sessions.unbind(key);
127            }
128        }
129    }
130}
131
132// ─────────────────────────────────────────────────────────────────────────────
133// ProxyManager
134// ─────────────────────────────────────────────────────────────────────────────
135
136/// Unified proxy pool orchestrator.
137///
138/// Manage proxies via [`add_proxy`](ProxyManager::add_proxy) and
139/// [`remove_proxy`](ProxyManager::remove_proxy), acquire one via
140/// [`acquire_proxy`](ProxyManager::acquire_proxy), and start background
141/// health checking with [`start`](ProxyManager::start).
142///
143/// # Quick start
144///
145/// ```rust,no_run
146/// # async fn run() -> stygian_proxy::ProxyResult<()> {
147/// use std::sync::Arc;
148/// use stygian_proxy::{ProxyManager, ProxyConfig, Proxy, ProxyType};
149/// use stygian_proxy::storage::MemoryProxyStore;
150/// use stygian_proxy::types::ProxyCapabilities;
151///
152/// let storage = Arc::new(MemoryProxyStore::default());
153/// let mgr = ProxyManager::with_round_robin(storage, ProxyConfig::default())?;
154/// let (token, _handle) = mgr.start();
155/// let proxy = mgr.add_proxy(Proxy {
156///     url: "http://proxy.example.com:8080".into(),
157///     proxy_type: ProxyType::Http,
158///     username: None,
159///     password: None,
160///     weight: 1,
161///     tags: vec![],
162///     capabilities: ProxyCapabilities::default(),
163/// }).await?;
164/// let handle = mgr.acquire_proxy().await?;
165/// handle.mark_success();
166/// token.cancel();
167/// # Ok(())
168/// # }
169/// ```
170pub 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    /// Domain→proxy sticky session map (always present; logic depends on `config.sticky_policy`).
177    sessions: SessionMap,
178}
179
180impl ProxyManager {
181    /// Start a [`ProxyManagerBuilder`].
182    pub fn builder() -> ProxyManagerBuilder {
183        ProxyManagerBuilder::default()
184    }
185
186    /// Convenience: round-robin rotation (default).
187    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    /// Convenience: random rotation.
199    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    /// Convenience: weighted rotation.
211    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    /// Convenience: least-used rotation.
223    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    // ── Pool mutations ────────────────────────────────────────────────────────
235
236    /// Add a proxy and register a circuit breaker for it.  Returns the new ID.
237    ///
238    /// The `circuit_breakers` write lock is held for the duration of the storage
239    /// write.  This is intentional: [`acquire_proxy`](Self::acquire_proxy) holds
240    /// a read lock on the same map while it inspects candidates, so it cannot
241    /// proceed past that point until both the storage record *and* its CB entry
242    /// exist.  Without this ordering a concurrent `acquire_proxy` could select
243    /// the new proxy before its CB was registered, breaking failure accounting.
244    #[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    /// Remove a proxy from the pool and drop its circuit breaker.
259    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    // ── Background task ───────────────────────────────────────────────────────
266
267    /// Spawn the background health-check and session-purge tasks.
268    ///
269    /// Returns a `(CancellationToken, JoinHandle)` pair.  Cancel the token to
270    /// trigger a graceful shutdown; await the handle to ensure it finishes.
271    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    // ── Proxy selection ───────────────────────────────────────────────────────
295
296    /// Select one proxy via the rotation strategy, returning its URL, circuit
297    /// breaker, and ID.  Used by both [`acquire_proxy`](Self::acquire_proxy) and
298    /// [`acquire_for_domain`](Self::acquire_for_domain).
299    #[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        // Drop both read guards before the async `strategy.select` await to avoid holding
307        // locks across await points. After selection, re-acquire for a single O(1) lookup.
308        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            // health_map and cb_map drop here
329        };
330
331        let selected = self.strategy.select(&candidates).await?;
332        let id = selected.id;
333
334        // Single O(1) lookup — re-acquire only after the await point.
335        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    /// Acquire a proxy from the pool.
352    ///
353    /// Builds [`ProxyCandidate`] entries from current storage, consulting the
354    /// health map and each proxy's circuit breaker to set the `healthy` flag.
355    /// Delegates selection to the configured [`crate::strategy::RotationStrategy`].
356    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    /// Acquire a proxy that satisfies `req` from the pool.
362    ///
363    /// Filters the candidate list to healthy proxies whose
364    /// [`ProxyCapabilities`](crate::types::ProxyCapabilities) satisfy every
365    /// flag in `req`, then delegates to the configured rotation strategy.
366    ///
367    /// Returns [`ProxyError::NoCompatibleProxy`] when no healthy proxy meets
368    /// the capability requirements.
369    ///
370    /// # Example
371    /// ```rust,no_run
372    /// use stygian_proxy::{ProxyManager, ProxyManagerBuilder, CapabilityRequirement};
373    ///
374    /// async fn example(manager: &ProxyManager) {
375    ///     let req = CapabilityRequirement { require_https_connect: true, ..Default::default() };
376    ///     let handle = manager.acquire_with_capabilities(&req).await.unwrap();
377    ///     println!("url: {}", handle.proxy_url);
378    /// }
379    /// ```
380    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        // Filter to only those that satisfy the capability requirement.
413        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    /// Acquire a proxy for `domain`, honouring the configured sticky-session
441    /// policy.
442    ///
443    /// - When [`StickyPolicy::Disabled`] is active, behaves identically to
444    ///   [`acquire_proxy`](Self::acquire_proxy).
445    /// - When [`StickyPolicy::Domain`] is active and a fresh session exists
446    ///   for `domain`, the **same proxy** is returned for the TTL duration.
447    /// - If the bound proxy's circuit breaker has tripped or the proxy has been
448    ///   removed, the stale session is invalidated and a fresh proxy is acquired
449    ///   and bound.
450    ///
451    /// The returned [`ProxyHandle`] automatically invalidates the session on
452    /// drop if not marked as successful.
453    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        // Check for an active, non-expired session.
460        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                // Lookup proxy URL from storage.
466                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            // CB tripped or proxy no longer in pool — invalidate.
479            drop(cb_map);
480            self.sessions.unbind(domain);
481        }
482
483        // No valid session: acquire fresh proxy via strategy and bind.
484        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    // ── Stats ─────────────────────────────────────────────────────────────────
495
496    /// Return a health snapshot of the pool.
497    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// ─────────────────────────────────────────────────────────────────────────────
525// ProxyManagerBuilder
526// ─────────────────────────────────────────────────────────────────────────────
527
528/// Fluent builder for [`ProxyManager`].
529#[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    /// Build the [`ProxyManager`].
556    ///
557    /// Defaults: strategy = `RoundRobinStrategy`, config = `ProxyConfig::default()`.
558    ///
559    /// Returns an error if no storage was set.
560    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// ─────────────────────────────────────────────────────────────────────────────
597// Tests
598// ─────────────────────────────────────────────────────────────────────────────
599
600#[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    /// Round-robin across 3 proxies × 10 acquisitions should hit all three.
633    #[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    /// When all circuit breakers are open the manager returns `AllProxiesUnhealthy`.
657    #[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        // Manually trip the circuit breaker.
674        {
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    /// Dropping a handle without `mark_success` records a failure.
688    #[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            // drop without mark_success → failure recorded
707        }
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    /// A handle marked as successful keeps the circuit breaker Closed.
715    #[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    /// `start()` launches the health checker and `cancel` causes clean exit.
734    #[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    // ── sticky session tests ─────────────────────────────────────────────────
799
800    fn sticky_config() -> ProxyConfig {
801        use crate::session::StickyPolicy;
802        ProxyConfig {
803            sticky_policy: StickyPolicy::domain_default(),
804            ..ProxyConfig::default()
805        }
806    }
807
808    /// Two consecutive `acquire_for_domain` calls return the same proxy.
809    #[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    /// Different domains each get their own proxy (when enough proxies exist).
832    #[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        // With round-robin and two proxies the second domain gets the other one.
852        assert_ne!(
853            url_a, url_b,
854            "different domains should get different proxies"
855        );
856    }
857
858    /// After TTL expiry the session is treated as gone; a (possibly different)
859    /// proxy is re-acquired and the basic contract (no panic) still holds.
860    #[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        // Let the session expire.
880        tokio::time::sleep(Duration::from_millis(5)).await;
881
882        // Re-acquiring should not panic or error.
883        let h2 = mgr.acquire_for_domain("expired.com").await.unwrap();
884        h2.mark_success();
885    }
886
887    /// When the bound proxy's CB trips, the session is invalidated and a new
888    /// proxy is acquired on next call.
889    #[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        // First acquire: bind "cb.com" to a proxy.
909        let h1 = mgr.acquire_for_domain("cb.com").await.unwrap();
910        let url1 = h1.proxy_url.clone();
911        // Drop without mark_success → circuit breaker trips + session unbinds.
912        drop(h1);
913
914        // Give the tokio runtime a moment to process.
915        tokio::task::yield_now().await;
916
917        // The tripped proxy is no longer available; next acquire should succeed
918        // from the remaining healthy proxy (or error if only one).
919        // We just verify no panic and the handle is valid.
920        let _h2 = mgr.acquire_for_domain("cb.com").await;
921        // url may differ from url1 or error if all CBs open — either is acceptable.
922        let _ = url1;
923    }
924
925    /// `purge_expired()` removes stale sessions from the map.
926    #[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        // Expire and purge.
948        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    /// `pool_stats` includes `active_sessions`.
955    #[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}