Skip to main content

scatter_proxy/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5/// Default free SOCKS5 proxy sources used when no custom sources are configured.
6/// These are fetched from the scatter-proxy GitHub Pages and popular community lists.
7pub const DEFAULT_PROXY_SOURCES: &[&str] = &[
8    // scatter-proxy's own curated list (GitHub Pages + jsDelivr CDN)
9    "https://cdn.jsdelivr.net/gh/letllmrun/scatter-proxy@main/docs/socks5.txt",
10    // Community-maintained lists
11    "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt",
12    "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks5.txt",
13];
14
15/// Main configuration for ScatterProxy.
16pub struct ScatterProxyConfig {
17    /// URLs of proxy sources (line-delimited `ip:port` or `socks5://ip:port`).
18    /// When empty, [`DEFAULT_PROXY_SOURCES`] are used automatically.
19    pub sources: Vec<String>,
20    /// How often to re-fetch proxy sources (default: 10 min).
21    pub source_refresh_interval: Duration,
22    /// Per-(proxy, host) rate-limiting configuration.
23    pub rate_limit: RateLimitConfig,
24    /// Timeout for a single proxy connection attempt (default: 8s).
25    pub proxy_timeout: Duration,
26    /// Number of concurrent proxy paths raced per request (default: 3).
27    pub max_concurrent_per_request: usize,
28    /// Global in-flight concurrency limit (default: 100).
29    pub max_inflight: usize,
30    /// Maximum number of pending tasks in the pool (default: 1000).
31    pub task_pool_capacity: usize,
32    /// Sliding window size for health tracking (default: 30).
33    pub health_window: usize,
34    /// Base cooldown duration after consecutive failures (default: 30s).
35    pub cooldown_base: Duration,
36    /// Maximum cooldown duration (default: 300s).
37    pub cooldown_max: Duration,
38    /// Number of consecutive failures before entering cooldown (default: 3).
39    pub cooldown_consecutive_fails: usize,
40    /// Minimum samples required before a proxy can be evicted (default: 30).
41    pub eviction_min_samples: usize,
42    /// Number of target errors before tripping the host circuit breaker (default: 10).
43    pub circuit_breaker_threshold: usize,
44    /// Interval between probe requests when a circuit breaker is open (default: 30s).
45    pub circuit_breaker_probe_interval: Duration,
46    /// Optional file path for persisting proxy state as JSON.
47    pub state_file: Option<PathBuf>,
48    /// How often to save state to disk (default: 5 min).
49    pub state_save_interval: Duration,
50    /// How often to log the metrics summary line (default: 30s).
51    pub metrics_log_interval: Duration,
52    /// Whether to prefer remote DNS resolution through the SOCKS5 proxy (default: true).
53    pub prefer_remote_dns: bool,
54}
55
56impl Default for ScatterProxyConfig {
57    fn default() -> Self {
58        Self {
59            sources: Vec::new(),
60            source_refresh_interval: Duration::from_secs(600),
61            rate_limit: RateLimitConfig::default(),
62            proxy_timeout: Duration::from_secs(8),
63            max_concurrent_per_request: 3,
64            max_inflight: 100,
65            task_pool_capacity: 1000,
66            health_window: 30,
67            cooldown_base: Duration::from_secs(30),
68            cooldown_max: Duration::from_secs(300),
69            cooldown_consecutive_fails: 3,
70            eviction_min_samples: 30,
71            circuit_breaker_threshold: 10,
72            circuit_breaker_probe_interval: Duration::from_secs(30),
73            state_file: None,
74            state_save_interval: Duration::from_secs(300),
75            metrics_log_interval: Duration::from_secs(30),
76            prefer_remote_dns: true,
77        }
78    }
79}
80
81/// Per-(proxy, host) rate-limiting configuration.
82pub struct RateLimitConfig {
83    /// Default minimum interval between requests through the same proxy to the same host (default: 500ms).
84    pub default_interval: Duration,
85    /// Per-host overrides for the minimum interval.
86    pub host_overrides: HashMap<String, Duration>,
87}
88
89impl Default for RateLimitConfig {
90    fn default() -> Self {
91        Self {
92            default_interval: Duration::from_millis(500),
93            host_overrides: HashMap::new(),
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_scatter_proxy_config_defaults() {
104        let cfg = ScatterProxyConfig::default();
105
106        assert!(cfg.sources.is_empty());
107        assert_eq!(cfg.source_refresh_interval, Duration::from_secs(600));
108        assert_eq!(cfg.proxy_timeout, Duration::from_secs(8));
109        assert_eq!(cfg.max_concurrent_per_request, 3);
110        assert_eq!(cfg.max_inflight, 100);
111        assert_eq!(cfg.task_pool_capacity, 1000);
112        assert_eq!(cfg.health_window, 30);
113        assert_eq!(cfg.cooldown_base, Duration::from_secs(30));
114        assert_eq!(cfg.cooldown_max, Duration::from_secs(300));
115        assert_eq!(cfg.cooldown_consecutive_fails, 3);
116        assert_eq!(cfg.eviction_min_samples, 30);
117        assert_eq!(cfg.circuit_breaker_threshold, 10);
118        assert_eq!(cfg.circuit_breaker_probe_interval, Duration::from_secs(30));
119        assert!(cfg.state_file.is_none());
120        assert_eq!(cfg.state_save_interval, Duration::from_secs(300));
121        assert_eq!(cfg.metrics_log_interval, Duration::from_secs(30));
122        assert!(cfg.prefer_remote_dns);
123    }
124
125    #[test]
126    fn test_rate_limit_config_defaults() {
127        let rl = RateLimitConfig::default();
128
129        assert_eq!(rl.default_interval, Duration::from_millis(500));
130        assert!(rl.host_overrides.is_empty());
131    }
132
133    #[test]
134    fn test_rate_limit_config_nested_in_scatter_proxy_config() {
135        let cfg = ScatterProxyConfig::default();
136
137        assert_eq!(cfg.rate_limit.default_interval, Duration::from_millis(500));
138        assert!(cfg.rate_limit.host_overrides.is_empty());
139    }
140
141    #[test]
142    fn test_config_can_be_customised() {
143        let cfg = ScatterProxyConfig {
144            sources: vec!["https://example.com/proxies.txt".into()],
145            max_concurrent_per_request: 5,
146            max_inflight: 200,
147            state_file: Some(PathBuf::from("/tmp/scatter.json")),
148            prefer_remote_dns: false,
149            rate_limit: RateLimitConfig {
150                default_interval: Duration::from_millis(250),
151                host_overrides: {
152                    let mut m = HashMap::new();
153                    m.insert("slow.example.com".into(), Duration::from_secs(2));
154                    m
155                },
156            },
157            ..ScatterProxyConfig::default()
158        };
159
160        assert_eq!(cfg.sources.len(), 1);
161        assert_eq!(cfg.max_concurrent_per_request, 5);
162        assert_eq!(cfg.max_inflight, 200);
163        assert_eq!(cfg.state_file, Some(PathBuf::from("/tmp/scatter.json")));
164        assert!(!cfg.prefer_remote_dns);
165        assert_eq!(cfg.rate_limit.default_interval, Duration::from_millis(250));
166        assert_eq!(
167            cfg.rate_limit.host_overrides.get("slow.example.com"),
168            Some(&Duration::from_secs(2))
169        );
170        // fields that should still have defaults
171        assert_eq!(cfg.proxy_timeout, Duration::from_secs(8));
172        assert_eq!(cfg.health_window, 30);
173    }
174
175    #[test]
176    fn test_cooldown_max_gte_cooldown_base() {
177        let cfg = ScatterProxyConfig::default();
178        assert!(cfg.cooldown_max >= cfg.cooldown_base);
179    }
180
181    #[test]
182    fn test_source_refresh_interval_is_10_minutes() {
183        let cfg = ScatterProxyConfig::default();
184        assert_eq!(cfg.source_refresh_interval.as_secs(), 10 * 60);
185    }
186
187    #[test]
188    fn test_state_save_interval_is_5_minutes() {
189        let cfg = ScatterProxyConfig::default();
190        assert_eq!(cfg.state_save_interval.as_secs(), 5 * 60);
191    }
192
193    #[test]
194    fn test_default_proxy_sources_not_empty() {
195        assert!(!DEFAULT_PROXY_SOURCES.is_empty());
196        for source in DEFAULT_PROXY_SOURCES {
197            assert!(source.starts_with("https://"));
198        }
199    }
200}