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];
11
12/// Main configuration for ScatterProxy.
13#[derive(Clone)]
14pub struct ScatterProxyConfig {
15    /// URLs of proxy sources (line-delimited `ip:port` or `socks5://ip:port`).
16    /// When empty, [`DEFAULT_PROXY_SOURCES`] are used automatically.
17    pub sources: Vec<String>,
18    /// How often to re-fetch proxy sources (default: 10 min).
19    pub source_refresh_interval: Duration,
20    /// Per-(proxy, host) rate-limiting configuration.
21    pub rate_limit: RateLimitConfig,
22    /// Timeout for a single proxy connection attempt (default: 8s).
23    pub proxy_timeout: Duration,
24    /// Number of concurrent proxy paths raced per request (default: 3).
25    pub max_concurrent_per_request: usize,
26    /// Global in-flight concurrency limit (default: 100).
27    pub max_inflight: usize,
28    /// Maximum number of pending tasks in the pool (default: 1000).
29    pub task_pool_capacity: usize,
30    /// Sliding window size for health tracking (default: 30).
31    pub health_window: usize,
32    /// Base cooldown duration after consecutive failures (default: 30s).
33    pub cooldown_base: Duration,
34    /// Maximum cooldown duration (default: 300s).
35    pub cooldown_max: Duration,
36    /// Number of consecutive failures before entering cooldown (default: 3).
37    pub cooldown_consecutive_fails: usize,
38    /// Minimum samples required before a proxy can be evicted (default: 30).
39    pub eviction_min_samples: usize,
40    /// Optional file path for persisting proxy state as JSON.
41    pub state_file: Option<PathBuf>,
42    /// How often to save state to disk (default: 5 min).
43    pub state_save_interval: Duration,
44    /// How often to log the metrics summary line (default: 30s).
45    pub metrics_log_interval: Duration,
46    /// Whether to prefer remote DNS resolution through the SOCKS5 proxy (default: true).
47    pub prefer_remote_dns: bool,
48    /// Optional label included in metrics log lines (default: None).
49    ///
50    /// When set, every periodic metrics log line is prefixed with `[name]`.
51    /// Useful when running multiple `ScatterProxy` instances (e.g. one per host
52    /// via [`ScatterProxyRouter`]) to tell them apart in logs.
53    ///
54    /// [`ScatterProxyRouter`]: crate::ScatterProxyRouter
55    pub name: Option<String>,
56}
57
58impl Default for ScatterProxyConfig {
59    fn default() -> Self {
60        Self {
61            sources: Vec::new(),
62            source_refresh_interval: Duration::from_secs(600),
63            rate_limit: RateLimitConfig::default(),
64            proxy_timeout: Duration::from_secs(8),
65            max_concurrent_per_request: 3,
66            max_inflight: 100,
67            task_pool_capacity: 1000,
68            health_window: 30,
69            cooldown_base: Duration::from_secs(30),
70            cooldown_max: Duration::from_secs(300),
71            cooldown_consecutive_fails: 3,
72            eviction_min_samples: 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            name: None,
78        }
79    }
80}
81
82/// Per-(proxy, host) rate-limiting configuration.
83#[derive(Clone)]
84pub struct RateLimitConfig {
85    /// Default minimum interval between requests through the same proxy to the same host (default: 500ms).
86    pub default_interval: Duration,
87    /// Per-host overrides for the minimum interval.
88    pub host_overrides: HashMap<String, Duration>,
89}
90
91impl Default for RateLimitConfig {
92    fn default() -> Self {
93        Self {
94            default_interval: Duration::from_millis(500),
95            host_overrides: HashMap::new(),
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_scatter_proxy_config_defaults() {
106        let cfg = ScatterProxyConfig::default();
107
108        assert!(cfg.sources.is_empty());
109        assert_eq!(cfg.source_refresh_interval, Duration::from_secs(600));
110        assert_eq!(cfg.proxy_timeout, Duration::from_secs(8));
111        assert_eq!(cfg.max_concurrent_per_request, 3);
112        assert_eq!(cfg.max_inflight, 100);
113        assert_eq!(cfg.task_pool_capacity, 1000);
114        assert_eq!(cfg.health_window, 30);
115        assert_eq!(cfg.cooldown_base, Duration::from_secs(30));
116        assert_eq!(cfg.cooldown_max, Duration::from_secs(300));
117        assert_eq!(cfg.cooldown_consecutive_fails, 3);
118        assert_eq!(cfg.eviction_min_samples, 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
196            .iter()
197            .any(|source| source.contains("codeberg.page/scatter-proxy/socks5.txt")));
198        for source in DEFAULT_PROXY_SOURCES {
199            assert!(source.starts_with("https://"));
200        }
201    }
202}