Skip to main content

stygian_proxy/strategy/
mod.rs

1//! Proxy rotation strategy trait and built-in implementations.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use uuid::Uuid;
7
8use crate::error::ProxyResult;
9use crate::types::{CapabilityRequirement, ProxyCapabilities, ProxyMetrics};
10
11mod least_used;
12mod random;
13mod round_robin;
14mod weighted;
15
16pub use least_used::LeastUsedStrategy;
17pub use random::RandomStrategy;
18pub use round_robin::RoundRobinStrategy;
19pub use weighted::WeightedStrategy;
20
21// ─────────────────────────────────────────────────────────────────────────────
22// ProxyCandidate
23// ─────────────────────────────────────────────────────────────────────────────
24
25/// A lightweight view of a proxy considered for selection.
26///
27/// Strategies operate on slices of `ProxyCandidate` values built from the live
28/// proxy pool. The `metrics` field allows latency- or usage-aware selection
29/// without acquiring a write lock.
30#[derive(Debug, Clone)]
31pub struct ProxyCandidate {
32    /// Stable identifier matching the [`ProxyRecord`](crate::types::ProxyRecord).
33    pub id: Uuid,
34    /// Relative weight used by [`WeightedStrategy`].
35    pub weight: u32,
36    /// Shared atomics updated by every request through this proxy.
37    pub metrics: Arc<ProxyMetrics>,
38    /// Whether the proxy currently passes health checks.
39    pub healthy: bool,
40    /// Protocol-level capabilities this proxy exposes.
41    pub capabilities: ProxyCapabilities,
42}
43
44// ─────────────────────────────────────────────────────────────────────────────
45// RotationStrategy trait
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// Selects a proxy from a slice of candidates on each request.
49///
50/// Implementations receive **all** candidates (healthy and unhealthy) so they
51/// can distinguish between an empty pool and a pool where every proxy is
52/// temporarily down. Call [`healthy_candidates`] to filter the slice.
53///
54/// # Example
55/// ```rust,no_run
56/// use stygian_proxy::strategy::{ProxyCandidate, RotationStrategy, RoundRobinStrategy};
57///
58/// async fn pick(candidates: &[ProxyCandidate]) {
59///     let strategy = RoundRobinStrategy::default();
60///     let chosen = strategy.select(candidates).await.unwrap();
61///     println!("selected: {}", chosen.id);
62/// }
63/// ```
64#[async_trait]
65pub trait RotationStrategy: Send + Sync + 'static {
66    /// Select one candidate from `candidates`.
67    ///
68    /// Returns [`crate::error::ProxyError::AllProxiesUnhealthy`] when every candidate has
69    /// `healthy == false`.
70    async fn select<'a>(&self, candidates: &'a [ProxyCandidate])
71    -> ProxyResult<&'a ProxyCandidate>;
72}
73
74/// Shared-ownership type alias for a [`RotationStrategy`] implementation.
75pub type BoxedRotationStrategy = Arc<dyn RotationStrategy>;
76
77// ─────────────────────────────────────────────────────────────────────────────
78// Shared helper
79// ─────────────────────────────────────────────────────────────────────────────
80
81/// Filter `all` to only the candidates that are currently healthy.
82///
83/// Returns references into the original slice, so no allocation is needed
84/// beyond the returned `Vec`.
85pub fn healthy_candidates(all: &[ProxyCandidate]) -> Vec<&ProxyCandidate> {
86    all.iter().filter(|c| c.healthy).collect()
87}
88
89/// Filter `all` to candidates that are healthy **and** satisfy `req`.
90///
91/// An empty [`CapabilityRequirement`] (all flags `false`, no geo filter)
92/// behaves identically to [`healthy_candidates`].
93///
94/// # Example
95/// ```
96/// use std::sync::Arc;
97/// use stygian_proxy::strategy::{ProxyCandidate, capable_healthy_candidates};
98/// use stygian_proxy::types::{CapabilityRequirement, ProxyCapabilities, ProxyMetrics};
99/// use uuid::Uuid;
100///
101/// let caps = ProxyCapabilities { supports_https_connect: true, ..Default::default() };
102/// let candidate = ProxyCandidate {
103///     id: Uuid::new_v4(),
104///     weight: 1,
105///     metrics: Arc::new(ProxyMetrics::default()),
106///     healthy: true,
107///     capabilities: caps,
108/// };
109/// let req = CapabilityRequirement { require_https_connect: true, ..Default::default() };
110/// let candidates = [candidate];
111/// let result = capable_healthy_candidates(&candidates, &req);
112/// assert_eq!(result.len(), 1);
113/// ```
114pub fn capable_healthy_candidates<'a>(
115    all: &'a [ProxyCandidate],
116    req: &CapabilityRequirement,
117) -> Vec<&'a ProxyCandidate> {
118    all.iter()
119        .filter(|c| c.healthy && c.capabilities.satisfies(req))
120        .collect()
121}
122
123// ─────────────────────────────────────────────────────────────────────────────
124// Tests
125// ─────────────────────────────────────────────────────────────────────────────
126
127#[cfg(test)]
128pub(crate) mod tests {
129    use super::*;
130    use crate::error::ProxyError;
131    use crate::types::{CapabilityRequirement, ProxyCapabilities};
132    use std::sync::atomic::Ordering;
133
134    /// Build a `ProxyCandidate` with sensible test defaults.
135    pub fn candidate(id: u128, healthy: bool, weight: u32, requests: u64) -> ProxyCandidate {
136        let metrics = Arc::new(ProxyMetrics::default());
137        metrics.requests_total.store(requests, Ordering::Relaxed);
138        ProxyCandidate {
139            id: Uuid::from_u128(id),
140            weight,
141            metrics,
142            healthy,
143            capabilities: ProxyCapabilities::default(),
144        }
145    }
146
147    /// Build a `ProxyCandidate` with explicit capabilities.
148    pub fn candidate_with_caps(
149        id: u128,
150        healthy: bool,
151        weight: u32,
152        caps: ProxyCapabilities,
153    ) -> ProxyCandidate {
154        let metrics = Arc::new(ProxyMetrics::default());
155        ProxyCandidate {
156            id: Uuid::from_u128(id),
157            weight,
158            metrics,
159            healthy,
160            capabilities: caps,
161        }
162    }
163
164    #[tokio::test]
165    async fn healthy_candidates_filters() {
166        let c = vec![
167            candidate(1, true, 1, 0),
168            candidate(2, false, 1, 0),
169            candidate(3, true, 1, 0),
170        ];
171        let healthy = healthy_candidates(&c);
172        assert_eq!(healthy.len(), 2);
173        assert!(healthy.iter().all(|c| c.healthy));
174    }
175
176    #[tokio::test]
177    async fn all_unhealthy_returns_error() {
178        let c = vec![candidate(1, false, 1, 0), candidate(2, false, 1, 0)];
179        assert!(matches!(
180            RoundRobinStrategy::default().select(&c).await,
181            Err(ProxyError::AllProxiesUnhealthy)
182        ));
183    }
184
185    #[test]
186    fn capable_healthy_candidates_filters_by_capability() {
187        let c = vec![
188            candidate_with_caps(
189                1,
190                true,
191                1,
192                ProxyCapabilities {
193                    supports_https_connect: true,
194                    ..Default::default()
195                },
196            ),
197            candidate_with_caps(2, true, 1, ProxyCapabilities::default()),
198            candidate_with_caps(
199                3,
200                false,
201                1,
202                ProxyCapabilities {
203                    supports_https_connect: true,
204                    ..Default::default()
205                },
206            ),
207        ];
208        let req = CapabilityRequirement {
209            require_https_connect: true,
210            ..Default::default()
211        };
212        let result = capable_healthy_candidates(&c, &req);
213        // Only candidate 1: healthy AND supports_https_connect
214        assert_eq!(result.len(), 1);
215        assert_eq!(
216            result.first().map(|candidate| candidate.id),
217            Some(Uuid::from_u128(1))
218        );
219    }
220
221    #[test]
222    fn capable_healthy_candidates_empty_req_behaves_like_healthy() {
223        let c = vec![
224            candidate(1, true, 1, 0),
225            candidate(2, false, 1, 0),
226            candidate(3, true, 1, 0),
227        ];
228        let req = CapabilityRequirement::default();
229        let result = capable_healthy_candidates(&c, &req);
230        assert_eq!(result.len(), 2);
231    }
232
233    #[test]
234    fn capable_healthy_candidates_returns_empty_when_none_match() {
235        let c = vec![candidate(1, true, 1, 0), candidate(2, true, 1, 0)];
236        let req = CapabilityRequirement {
237            require_socks5_udp: true,
238            ..Default::default()
239        };
240        let result = capable_healthy_candidates(&c, &req);
241        assert!(result.is_empty());
242    }
243
244    #[test]
245    fn geo_country_filter_matches_exact_country() {
246        let gb_proxy_caps = ProxyCapabilities {
247            geo_country: Some("GB".into()),
248            ..Default::default()
249        };
250        let us_proxy_caps = ProxyCapabilities {
251            geo_country: Some("US".into()),
252            ..Default::default()
253        };
254        let c = vec![
255            candidate_with_caps(1, true, 1, gb_proxy_caps),
256            candidate_with_caps(2, true, 1, us_proxy_caps),
257            candidate_with_caps(3, true, 1, ProxyCapabilities::default()),
258        ];
259        let req = CapabilityRequirement {
260            require_geo_country: Some("GB".into()),
261            ..Default::default()
262        };
263        let result = capable_healthy_candidates(&c, &req);
264        assert_eq!(result.len(), 1);
265        assert_eq!(
266            result.first().map(|candidate| candidate.id),
267            Some(Uuid::from_u128(1))
268        );
269    }
270}