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 Codeberg Pages and popular community lists.
7pub const DEFAULT_PROXY_SOURCES: &[&str] = &[
8    // scatter-proxy's own curated list (Codeberg Pages mirror)
9    "https://letllmrun.codeberg.page/scatter-proxy/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    /// Optional file path for persisting proxy state as JSON.
43    pub state_file: Option<PathBuf>,
44    /// How often to save state to disk (default: 5 min).
45    pub state_save_interval: Duration,
46    /// How often to log the metrics summary line (default: 30s).
47    pub metrics_log_interval: Duration,
48    /// Whether to prefer remote DNS resolution through the SOCKS5 proxy (default: true).
49    pub prefer_remote_dns: bool,
50}
51
52impl Default for ScatterProxyConfig {
53    fn default() -> Self {
54        Self {
55            sources: Vec::new(),
56            source_refresh_interval: Duration::from_secs(600),
57            rate_limit: RateLimitConfig::default(),
58            proxy_timeout: Duration::from_secs(8),
59            max_concurrent_per_request: 3,
60            max_inflight: 100,
61            task_pool_capacity: 1000,
62            health_window: 30,
63            cooldown_base: Duration::from_secs(30),
64            cooldown_max: Duration::from_secs(300),
65            cooldown_consecutive_fails: 3,
66            eviction_min_samples: 30,
67            state_file: None,
68            state_save_interval: Duration::from_secs(300),
69            metrics_log_interval: Duration::from_secs(30),
70            prefer_remote_dns: true,
71        }
72    }
73}
74
75/// Per-(proxy, host) rate-limiting configuration.
76pub struct RateLimitConfig {
77    /// Default minimum interval between requests through the same proxy to the same host (default: 500ms).
78    pub default_interval: Duration,
79    /// Per-host overrides for the minimum interval.
80    pub host_overrides: HashMap<String, Duration>,
81}
82
83impl Default for RateLimitConfig {
84    fn default() -> Self {
85        Self {
86            default_interval: Duration::from_millis(500),
87            host_overrides: HashMap::new(),
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn test_scatter_proxy_config_defaults() {
98        let cfg = ScatterProxyConfig::default();
99
100        assert!(cfg.sources.is_empty());
101        assert_eq!(cfg.source_refresh_interval, Duration::from_secs(600));
102        assert_eq!(cfg.proxy_timeout, Duration::from_secs(8));
103        assert_eq!(cfg.max_concurrent_per_request, 3);
104        assert_eq!(cfg.max_inflight, 100);
105        assert_eq!(cfg.task_pool_capacity, 1000);
106        assert_eq!(cfg.health_window, 30);
107        assert_eq!(cfg.cooldown_base, Duration::from_secs(30));
108        assert_eq!(cfg.cooldown_max, Duration::from_secs(300));
109        assert_eq!(cfg.cooldown_consecutive_fails, 3);
110        assert_eq!(cfg.eviction_min_samples, 30);
111        assert!(cfg.state_file.is_none());
112        assert_eq!(cfg.state_save_interval, Duration::from_secs(300));
113        assert_eq!(cfg.metrics_log_interval, Duration::from_secs(30));
114        assert!(cfg.prefer_remote_dns);
115    }
116
117    #[test]
118    fn test_rate_limit_config_defaults() {
119        let rl = RateLimitConfig::default();
120
121        assert_eq!(rl.default_interval, Duration::from_millis(500));
122        assert!(rl.host_overrides.is_empty());
123    }
124
125    #[test]
126    fn test_rate_limit_config_nested_in_scatter_proxy_config() {
127        let cfg = ScatterProxyConfig::default();
128
129        assert_eq!(cfg.rate_limit.default_interval, Duration::from_millis(500));
130        assert!(cfg.rate_limit.host_overrides.is_empty());
131    }
132
133    #[test]
134    fn test_config_can_be_customised() {
135        let cfg = ScatterProxyConfig {
136            sources: vec!["https://example.com/proxies.txt".into()],
137            max_concurrent_per_request: 5,
138            max_inflight: 200,
139            state_file: Some(PathBuf::from("/tmp/scatter.json")),
140            prefer_remote_dns: false,
141            rate_limit: RateLimitConfig {
142                default_interval: Duration::from_millis(250),
143                host_overrides: {
144                    let mut m = HashMap::new();
145                    m.insert("slow.example.com".into(), Duration::from_secs(2));
146                    m
147                },
148            },
149            ..ScatterProxyConfig::default()
150        };
151
152        assert_eq!(cfg.sources.len(), 1);
153        assert_eq!(cfg.max_concurrent_per_request, 5);
154        assert_eq!(cfg.max_inflight, 200);
155        assert_eq!(cfg.state_file, Some(PathBuf::from("/tmp/scatter.json")));
156        assert!(!cfg.prefer_remote_dns);
157        assert_eq!(cfg.rate_limit.default_interval, Duration::from_millis(250));
158        assert_eq!(
159            cfg.rate_limit.host_overrides.get("slow.example.com"),
160            Some(&Duration::from_secs(2))
161        );
162        // fields that should still have defaults
163        assert_eq!(cfg.proxy_timeout, Duration::from_secs(8));
164        assert_eq!(cfg.health_window, 30);
165    }
166
167    #[test]
168    fn test_cooldown_max_gte_cooldown_base() {
169        let cfg = ScatterProxyConfig::default();
170        assert!(cfg.cooldown_max >= cfg.cooldown_base);
171    }
172
173    #[test]
174    fn test_source_refresh_interval_is_10_minutes() {
175        let cfg = ScatterProxyConfig::default();
176        assert_eq!(cfg.source_refresh_interval.as_secs(), 10 * 60);
177    }
178
179    #[test]
180    fn test_state_save_interval_is_5_minutes() {
181        let cfg = ScatterProxyConfig::default();
182        assert_eq!(cfg.state_save_interval.as_secs(), 5 * 60);
183    }
184
185    #[test]
186    fn test_default_proxy_sources_not_empty() {
187        assert!(DEFAULT_PROXY_SOURCES
188            .iter()
189            .any(|source| source.contains("codeberg.page/scatter-proxy/socks5.txt")));
190        for source in DEFAULT_PROXY_SOURCES {
191            assert!(source.starts_with("https://"));
192        }
193    }
194}