use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use serde::Serialize;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::circuit_breaker::CircuitBreaker;
use crate::error::{ProxyError, ProxyResult};
use crate::health::{HealthChecker, HealthMap};
use crate::session::{SessionMap, StickyPolicy};
use crate::storage::ProxyStoragePort;
use crate::strategy::{
BoxedRotationStrategy, LeastUsedStrategy, ProxyCandidate, RandomStrategy, RoundRobinStrategy,
WeightedStrategy, capable_healthy_candidates,
};
use crate::types::{CapabilityRequirement, Proxy, ProxyConfig};
#[derive(Debug, Serialize)]
pub struct PoolStats {
pub total: usize,
pub healthy: usize,
pub open: usize,
pub active_sessions: usize,
}
pub struct ProxyHandle {
pub proxy_url: String,
circuit_breaker: Arc<CircuitBreaker>,
succeeded: AtomicBool,
session_key: Option<String>,
sessions: Option<SessionMap>,
}
impl ProxyHandle {
const fn new(proxy_url: String, circuit_breaker: Arc<CircuitBreaker>) -> Self {
Self {
proxy_url,
circuit_breaker,
succeeded: AtomicBool::new(false),
session_key: None,
sessions: None,
}
}
const fn new_sticky(
proxy_url: String,
circuit_breaker: Arc<CircuitBreaker>,
session_key: String,
sessions: SessionMap,
) -> Self {
Self {
proxy_url,
circuit_breaker,
succeeded: AtomicBool::new(false),
session_key: Some(session_key),
sessions: Some(sessions),
}
}
pub fn direct() -> Self {
let noop_cb = Arc::new(CircuitBreaker::new(u32::MAX, u64::MAX));
Self {
proxy_url: String::new(),
circuit_breaker: noop_cb,
succeeded: AtomicBool::new(true),
session_key: None,
sessions: None,
}
}
pub fn mark_success(&self) {
self.succeeded.store(true, Ordering::Release);
}
}
impl std::fmt::Debug for ProxyHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProxyHandle")
.field("proxy_url", &self.proxy_url)
.finish_non_exhaustive()
}
}
impl Drop for ProxyHandle {
fn drop(&mut self) {
if self.succeeded.load(Ordering::Acquire) {
self.circuit_breaker.record_success();
} else {
self.circuit_breaker.record_failure();
if let (Some(key), Some(sessions)) = (&self.session_key, &self.sessions) {
sessions.unbind(key);
}
}
}
}
pub struct ProxyManager {
storage: Arc<dyn ProxyStoragePort>,
strategy: BoxedRotationStrategy,
health_checker: HealthChecker,
circuit_breakers: Arc<RwLock<HashMap<Uuid, Arc<CircuitBreaker>>>>,
config: ProxyConfig,
sessions: SessionMap,
}
impl ProxyManager {
pub fn builder() -> ProxyManagerBuilder {
ProxyManagerBuilder::default()
}
pub fn with_round_robin(
storage: Arc<dyn ProxyStoragePort>,
config: ProxyConfig,
) -> ProxyResult<Self> {
Self::builder()
.storage(storage)
.strategy(Arc::new(RoundRobinStrategy::default()))
.config(config)
.build()
}
pub fn with_random(
storage: Arc<dyn ProxyStoragePort>,
config: ProxyConfig,
) -> ProxyResult<Self> {
Self::builder()
.storage(storage)
.strategy(Arc::new(RandomStrategy))
.config(config)
.build()
}
pub fn with_weighted(
storage: Arc<dyn ProxyStoragePort>,
config: ProxyConfig,
) -> ProxyResult<Self> {
Self::builder()
.storage(storage)
.strategy(Arc::new(WeightedStrategy))
.config(config)
.build()
}
pub fn with_least_used(
storage: Arc<dyn ProxyStoragePort>,
config: ProxyConfig,
) -> ProxyResult<Self> {
Self::builder()
.storage(storage)
.strategy(Arc::new(LeastUsedStrategy))
.config(config)
.build()
}
#[allow(clippy::significant_drop_tightening)]
pub async fn add_proxy(&self, proxy: Proxy) -> ProxyResult<Uuid> {
let mut cb_map = self.circuit_breakers.write().await;
let record = self.storage.add(proxy).await?;
cb_map.insert(
record.id,
Arc::new(CircuitBreaker::new(
self.config.circuit_open_threshold,
u64::try_from(self.config.circuit_half_open_after.as_millis()).unwrap_or(u64::MAX),
)),
);
Ok(record.id)
}
pub async fn remove_proxy(&self, id: Uuid) -> ProxyResult<()> {
self.storage.remove(id).await?;
self.circuit_breakers.write().await.remove(&id);
Ok(())
}
pub fn start(&self) -> (CancellationToken, JoinHandle<()>) {
let token = CancellationToken::new();
let health_handle = self.health_checker.clone().spawn(token.clone());
let sessions = self.sessions.clone();
let purge_token = token.clone();
let purge_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_mins(1));
loop {
tokio::select! {
_ = interval.tick() => { sessions.purge_expired(); }
() = purge_token.cancelled() => break,
}
}
});
let combined = tokio::spawn(async move {
let _ = tokio::join!(health_handle, purge_handle);
});
(token, combined)
}
#[allow(clippy::significant_drop_tightening)]
async fn select_proxy_inner(&self) -> ProxyResult<(String, Arc<CircuitBreaker>, Uuid)> {
let with_metrics = self.storage.list_with_metrics().await?;
if with_metrics.is_empty() {
return Err(ProxyError::PoolExhausted);
}
let candidates = {
let health_map_ref = Arc::clone(self.health_checker.health_map());
let health_map = health_map_ref.read().await;
let cb_map_ref = Arc::clone(&self.circuit_breakers);
let cb_map = cb_map_ref.read().await;
let candidates: Vec<ProxyCandidate> = with_metrics
.iter()
.map(|(record, metrics)| {
let healthy = health_map.get(&record.id).copied().unwrap_or(true);
let available = cb_map.get(&record.id).is_none_or(|cb| cb.is_available());
ProxyCandidate {
id: record.id,
weight: record.proxy.weight,
metrics: Arc::clone(metrics),
healthy: healthy && available,
capabilities: record.proxy.capabilities.clone(),
}
})
.collect();
candidates
};
let selected = self.strategy.select(&candidates).await?;
let id = selected.id;
let cb = self
.circuit_breakers
.read()
.await
.get(&id)
.cloned()
.ok_or(ProxyError::PoolExhausted)?;
let url = with_metrics
.iter()
.find(|(r, _)| r.id == id)
.map(|(r, _)| r.proxy.url.clone())
.unwrap_or_default();
Ok((url, cb, id))
}
pub async fn acquire_proxy(&self) -> ProxyResult<ProxyHandle> {
let (url, cb, _id) = self.select_proxy_inner().await?;
Ok(ProxyHandle::new(url, cb))
}
pub async fn acquire_with_capabilities(
&self,
req: &CapabilityRequirement,
) -> ProxyResult<ProxyHandle> {
let with_metrics = self.storage.list_with_metrics().await?;
if with_metrics.is_empty() {
return Err(ProxyError::PoolExhausted);
}
let candidates = {
let health_map_ref = Arc::clone(self.health_checker.health_map());
let health_map = health_map_ref.read().await;
let cb_map_ref = Arc::clone(&self.circuit_breakers);
let cb_map = cb_map_ref.read().await;
let candidates: Vec<ProxyCandidate> = with_metrics
.iter()
.map(|(record, metrics)| {
let healthy = health_map.get(&record.id).copied().unwrap_or(true);
let available = cb_map.get(&record.id).is_none_or(|cb| cb.is_available());
ProxyCandidate {
id: record.id,
weight: record.proxy.weight,
metrics: Arc::clone(metrics),
healthy: healthy && available,
capabilities: record.proxy.capabilities.clone(),
}
})
.collect();
candidates
};
let compatible: Vec<ProxyCandidate> = capable_healthy_candidates(&candidates, req)
.into_iter()
.cloned()
.collect();
if compatible.is_empty() {
return Err(ProxyError::NoCompatibleProxy);
}
let selected = self.strategy.select(&compatible).await?;
let id = selected.id;
let cb = self
.circuit_breakers
.read()
.await
.get(&id)
.cloned()
.ok_or(ProxyError::PoolExhausted)?;
let url = with_metrics
.iter()
.find(|(r, _)| r.id == id)
.map(|(r, _)| r.proxy.url.clone())
.unwrap_or_default();
Ok(ProxyHandle::new(url, cb))
}
pub async fn acquire_for_domain(&self, domain: &str) -> ProxyResult<ProxyHandle> {
let ttl = match &self.config.sticky_policy {
StickyPolicy::Disabled => return self.acquire_proxy().await,
StickyPolicy::Domain { ttl } => *ttl,
};
if let Some(proxy_id) = self.sessions.lookup(domain) {
let cb_map = self.circuit_breakers.read().await;
if let Some(cb) = cb_map.get(&proxy_id).cloned()
&& cb.is_available()
{
let with_metrics = self.storage.list_with_metrics().await?;
if let Some((record, _)) = with_metrics.iter().find(|(r, _)| r.id == proxy_id) {
let url = record.proxy.url.clone();
drop(cb_map);
return Ok(ProxyHandle::new_sticky(
url,
cb,
domain.to_string(),
self.sessions.clone(),
));
}
}
drop(cb_map);
self.sessions.unbind(domain);
}
let (url, cb, proxy_id) = self.select_proxy_inner().await?;
self.sessions.bind(domain, proxy_id, ttl);
Ok(ProxyHandle::new_sticky(
url,
cb,
domain.to_string(),
self.sessions.clone(),
))
}
pub async fn pool_stats(&self) -> ProxyResult<PoolStats> {
let records = self.storage.list().await?;
let total = records.len();
let health_map = self.health_checker.health_map().read().await;
let cb_map = self.circuit_breakers.read().await;
let mut healthy = 0usize;
let mut open = 0usize;
for r in &records {
if health_map.get(&r.id).copied().unwrap_or(true) {
healthy += 1;
}
if cb_map.get(&r.id).is_some_and(|cb| !cb.is_available()) {
open += 1;
}
}
drop(health_map);
drop(cb_map);
Ok(PoolStats {
total,
healthy,
open,
active_sessions: self.sessions.active_count(),
})
}
}
#[derive(Default)]
pub struct ProxyManagerBuilder {
storage: Option<Arc<dyn ProxyStoragePort>>,
strategy: Option<BoxedRotationStrategy>,
config: Option<ProxyConfig>,
}
impl ProxyManagerBuilder {
#[must_use]
pub fn storage(mut self, s: Arc<dyn ProxyStoragePort>) -> Self {
self.storage = Some(s);
self
}
#[must_use]
pub fn strategy(mut self, s: BoxedRotationStrategy) -> Self {
self.strategy = Some(s);
self
}
#[must_use]
pub fn config(mut self, c: ProxyConfig) -> Self {
self.config = Some(c);
self
}
pub fn build(self) -> ProxyResult<ProxyManager> {
let storage = self.storage.ok_or_else(|| {
ProxyError::ConfigError("ProxyManagerBuilder: storage is required".into())
})?;
let strategy = self
.strategy
.unwrap_or_else(|| Arc::new(RoundRobinStrategy::default()));
let config = self.config.unwrap_or_default();
let health_map: HealthMap = Arc::new(RwLock::new(HashMap::new()));
let checker = HealthChecker::new(
config.clone(),
Arc::clone(&storage),
Arc::clone(&health_map),
);
#[cfg(feature = "tls-profiled")]
let health_checker = if let Some(mode) = config.profiled_request_mode {
checker.with_profiled_mode(mode)?
} else {
checker
};
#[cfg(not(feature = "tls-profiled"))]
let health_checker = checker;
Ok(ProxyManager {
storage,
strategy,
health_checker,
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
config,
sessions: SessionMap::new(),
})
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::significant_drop_tightening,
clippy::manual_let_else,
clippy::panic
)]
mod tests {
use std::collections::HashSet;
use std::time::Duration;
use super::*;
use crate::circuit_breaker::{STATE_CLOSED, STATE_OPEN};
use crate::storage::MemoryProxyStore;
use crate::types::ProxyType;
fn make_proxy(url: &str) -> Proxy {
Proxy {
url: url.into(),
proxy_type: ProxyType::Http,
username: None,
password: None,
weight: 1,
tags: vec![],
capabilities: crate::types::ProxyCapabilities::default(),
}
}
fn storage() -> Arc<MemoryProxyStore> {
Arc::new(MemoryProxyStore::default())
}
#[tokio::test]
async fn round_robin_distribution() {
let store = storage();
let mgr = ProxyManager::with_round_robin(store.clone(), ProxyConfig::default()).unwrap();
mgr.add_proxy(make_proxy("http://a.test:8080"))
.await
.unwrap();
mgr.add_proxy(make_proxy("http://b.test:8080"))
.await
.unwrap();
mgr.add_proxy(make_proxy("http://c.test:8080"))
.await
.unwrap();
let mut seen = HashSet::new();
for _ in 0..10 {
let h = mgr.acquire_proxy().await.unwrap();
h.mark_success();
seen.insert(h.proxy_url.clone());
}
assert_eq!(seen.len(), 3, "all three proxies should have been selected");
}
#[tokio::test]
async fn all_open_returns_error() {
let store = storage();
let mgr = ProxyManager::with_round_robin(
store.clone(),
ProxyConfig {
circuit_open_threshold: 1,
..ProxyConfig::default()
},
)
.unwrap();
let id = mgr
.add_proxy(make_proxy("http://x.test:8080"))
.await
.unwrap();
{
let map = mgr.circuit_breakers.read().await;
let cb = map.get(&id).unwrap();
cb.record_failure();
}
let err = mgr.acquire_proxy().await.unwrap_err();
assert!(
matches!(err, ProxyError::AllProxiesUnhealthy),
"expected AllProxiesUnhealthy, got {err:?}"
);
}
#[tokio::test]
async fn handle_drop_records_failure() {
let store = storage();
let mgr = ProxyManager::with_round_robin(
store.clone(),
ProxyConfig {
circuit_open_threshold: 1,
..ProxyConfig::default()
},
)
.unwrap();
let id = mgr
.add_proxy(make_proxy("http://y.test:8080"))
.await
.unwrap();
{
let _h = mgr.acquire_proxy().await.unwrap();
}
let cb_map = mgr.circuit_breakers.read().await;
let cb = cb_map.get(&id).unwrap();
assert_eq!(cb.state(), STATE_OPEN);
}
#[tokio::test]
async fn handle_success_keeps_closed() {
let store = storage();
let mgr = ProxyManager::with_round_robin(store.clone(), ProxyConfig::default()).unwrap();
let id = mgr
.add_proxy(make_proxy("http://z.test:8080"))
.await
.unwrap();
let h = mgr.acquire_proxy().await.unwrap();
h.mark_success();
drop(h);
let cb_map = mgr.circuit_breakers.read().await;
let cb = cb_map.get(&id).unwrap();
assert_eq!(cb.state(), STATE_CLOSED);
}
#[tokio::test]
async fn start_and_graceful_shutdown() {
let store = storage();
let mgr = ProxyManager::with_round_robin(
store,
ProxyConfig {
health_check_interval: Duration::from_hours(1),
..ProxyConfig::default()
},
)
.unwrap();
let (token, handle) = mgr.start();
token.cancel();
let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
assert!(result.is_ok(), "health checker task should exit within 1s");
}
#[cfg(feature = "tls-profiled")]
#[tokio::test]
async fn builder_accepts_profiled_request_mode_preset() {
let store = storage();
let cfg = ProxyConfig {
profiled_request_mode: Some(crate::types::ProfiledRequestMode::Preset),
..ProxyConfig::default()
};
let result = ProxyManager::builder()
.storage(store)
.strategy(Arc::new(RoundRobinStrategy::default()))
.config(cfg)
.build();
assert!(
result.is_ok(),
"builder should accept profiled preset mode: {:?}",
result.err()
);
}
#[cfg(feature = "tls-profiled")]
#[tokio::test]
async fn builder_rejects_profiled_request_mode_strict_all_for_chrome() {
let store = storage();
let cfg = ProxyConfig {
profiled_request_mode: Some(crate::types::ProfiledRequestMode::StrictAll),
..ProxyConfig::default()
};
let result = ProxyManager::builder()
.storage(store)
.strategy(Arc::new(RoundRobinStrategy::default()))
.config(cfg)
.build();
let Err(err) = result else {
panic!("strict_all should fail for default Chrome baseline profile")
};
assert!(
matches!(err, ProxyError::ConfigError(_)),
"expected ConfigError, got {err:?}"
);
}
fn sticky_config() -> ProxyConfig {
use crate::session::StickyPolicy;
ProxyConfig {
sticky_policy: StickyPolicy::domain_default(),
..ProxyConfig::default()
}
}
#[tokio::test]
async fn sticky_same_domain_returns_same_proxy() {
let store = storage();
let mgr = ProxyManager::with_round_robin(store, sticky_config()).unwrap();
mgr.add_proxy(make_proxy("http://p1.test:8080"))
.await
.unwrap();
mgr.add_proxy(make_proxy("http://p2.test:8080"))
.await
.unwrap();
let h1 = mgr.acquire_for_domain("example.com").await.unwrap();
let url1 = h1.proxy_url.clone();
h1.mark_success();
let h2 = mgr.acquire_for_domain("example.com").await.unwrap();
let url2 = h2.proxy_url.clone();
h2.mark_success();
assert_eq!(url1, url2, "same domain should return the same proxy");
}
#[tokio::test]
async fn sticky_different_domains_may_differ() {
let store = storage();
let mgr = ProxyManager::with_round_robin(store, sticky_config()).unwrap();
mgr.add_proxy(make_proxy("http://pa.test:8080"))
.await
.unwrap();
mgr.add_proxy(make_proxy("http://pb.test:8080"))
.await
.unwrap();
let ha = mgr.acquire_for_domain("a.com").await.unwrap();
let url_a = ha.proxy_url.clone();
ha.mark_success();
let hb = mgr.acquire_for_domain("b.com").await.unwrap();
let url_b = hb.proxy_url.clone();
hb.mark_success();
assert_ne!(
url_a, url_b,
"different domains should get different proxies"
);
}
#[tokio::test]
async fn sticky_expired_session_re_acquires() {
use crate::session::StickyPolicy;
let store = storage();
let mgr = ProxyManager::with_round_robin(
store,
ProxyConfig {
sticky_policy: StickyPolicy::domain(Duration::from_millis(1)),
..ProxyConfig::default()
},
)
.unwrap();
mgr.add_proxy(make_proxy("http://x.test:8080"))
.await
.unwrap();
let h1 = mgr.acquire_for_domain("expired.com").await.unwrap();
h1.mark_success();
tokio::time::sleep(Duration::from_millis(5)).await;
let h2 = mgr.acquire_for_domain("expired.com").await.unwrap();
h2.mark_success();
}
#[tokio::test]
async fn sticky_cb_trip_invalidates_session() {
let store = storage();
let mgr = ProxyManager::with_round_robin(
store,
ProxyConfig {
circuit_open_threshold: 1,
sticky_policy: sticky_config().sticky_policy,
..ProxyConfig::default()
},
)
.unwrap();
mgr.add_proxy(make_proxy("http://q1.test:8080"))
.await
.unwrap();
mgr.add_proxy(make_proxy("http://q2.test:8080"))
.await
.unwrap();
let h1 = mgr.acquire_for_domain("cb.com").await.unwrap();
let url1 = h1.proxy_url.clone();
drop(h1);
tokio::task::yield_now().await;
let _h2 = mgr.acquire_for_domain("cb.com").await;
let _ = url1;
}
#[tokio::test]
async fn sticky_purge_expired() {
use crate::session::StickyPolicy;
let store = storage();
let mgr = ProxyManager::with_round_robin(
store,
ProxyConfig {
sticky_policy: StickyPolicy::domain(Duration::from_millis(1)),
..ProxyConfig::default()
},
)
.unwrap();
mgr.add_proxy(make_proxy("http://r.test:8080"))
.await
.unwrap();
let h = mgr.acquire_for_domain("purge.com").await.unwrap();
h.mark_success();
assert_eq!(mgr.sessions.active_count(), 1);
tokio::time::sleep(Duration::from_millis(5)).await;
mgr.sessions.purge_expired();
assert_eq!(mgr.sessions.active_count(), 0);
}
#[tokio::test]
async fn pool_stats_includes_sessions() {
let store = storage();
let mgr = ProxyManager::with_round_robin(store, sticky_config()).unwrap();
mgr.add_proxy(make_proxy("http://s.test:8080"))
.await
.unwrap();
let stats = mgr.pool_stats().await.unwrap();
assert_eq!(stats.active_sessions, 0);
let h = mgr.acquire_for_domain("stats.com").await.unwrap();
h.mark_success();
let stats = mgr.pool_stats().await.unwrap();
assert_eq!(stats.active_sessions, 1);
}
}