1use std::collections::HashSet;
5use std::fmt;
6use std::sync::{Mutex, MutexGuard, PoisonError};
7use std::time::{Duration, Instant};
8
9use crate::error::Error;
10
11pub struct ProxyList {
43 proxies: Vec<String>,
44 state: Mutex<State>,
45}
46
47#[derive(Debug)]
48struct State {
49 next_index: usize,
50 bad_until: Vec<Option<Instant>>,
52}
53
54impl ProxyList {
55 pub fn new<I, S>(proxies: I) -> Result<Self, Error>
74 where
75 I: IntoIterator<Item = S>,
76 S: AsRef<str>,
77 {
78 let mut normalized: Vec<String> = Vec::new();
79 let mut seen: HashSet<String> = HashSet::new();
80 for proxy in proxies {
81 let url = normalize_proxy_url(proxy.as_ref())?;
82 if seen.insert(url.clone()) {
86 normalized.push(url);
87 }
88 }
89 let bad_until = vec![None; normalized.len()];
90 Ok(Self {
91 proxies: normalized,
92 state: Mutex::new(State {
93 next_index: 0,
94 bad_until,
95 }),
96 })
97 }
98
99 #[must_use]
101 pub fn is_empty(&self) -> bool {
102 self.proxies.is_empty()
103 }
104
105 #[must_use]
107 pub fn len(&self) -> usize {
108 self.proxies.len()
109 }
110
111 #[must_use]
113 pub fn as_slice(&self) -> &[String] {
114 &self.proxies
115 }
116
117 fn position(&self, proxy: &str) -> Option<usize> {
120 self.proxies.iter().position(|p| p == proxy).or_else(|| {
121 let canonical = normalize_proxy_url(proxy).ok()?;
122 self.proxies.iter().position(|p| *p == canonical)
123 })
124 }
125
126 #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
130 pub(crate) fn redacted(&self, idx: usize) -> String {
131 redact_userinfo(&self.proxies[idx])
132 }
133
134 pub fn pick(&self) -> Option<&str> {
145 self.pick_index().map(|idx| self.proxies[idx].as_str())
146 }
147
148 pub(crate) fn pick_index(&self) -> Option<usize> {
152 let len = self.proxies.len();
153 if len == 0 {
154 return None;
155 }
156 let mut state = self.lock();
157 let now = Instant::now();
158 let start = state.next_index;
159
160 for offset in 0..len {
162 let idx = (start + offset) % len;
163 let healthy = state.bad_until[idx].is_none_or(|until| now >= until);
164 if healthy {
165 state.bad_until[idx] = None;
166 state.next_index = (idx + 1) % len;
167 return Some(idx);
168 }
169 }
170
171 let idx = (0..len)
174 .map(|offset| (start + offset) % len)
175 .min_by_key(|&idx| state.bad_until[idx])
176 .expect("len > 0");
177 state.next_index = (idx + 1) % len;
178 Some(idx)
179 }
180
181 pub(crate) fn any_healthy_except(&self, idx: usize) -> bool {
185 let state = self.lock();
186 let now = Instant::now();
187 state
188 .bad_until
189 .iter()
190 .enumerate()
191 .any(|(i, until)| i != idx && until.is_none_or(|until| now >= until))
192 }
193
194 pub fn mark_bad(&self, proxy: &str, cooldown: Duration) -> bool {
204 match self.position(proxy) {
205 Some(idx) => {
206 self.mark_bad_index(idx, cooldown);
207 true
208 }
209 None => false,
210 }
211 }
212
213 pub(crate) fn mark_bad_index(&self, idx: usize, cooldown: Duration) {
214 let now = Instant::now();
215 let until = now
216 .checked_add(cooldown)
217 .or_else(|| now.checked_add(crate::MAX_DURATION))
219 .unwrap_or(now);
220 self.lock().bad_until[idx] = Some(until);
221 }
222
223 pub(crate) fn mark_good_index(&self, idx: usize) {
226 self.lock().bad_until[idx] = None;
227 }
228
229 pub fn in_cooldown(&self, proxy: &str) -> bool {
232 let Some(idx) = self.position(proxy) else {
233 return false;
234 };
235 let state = self.lock();
236 state.bad_until[idx].is_some_and(|until| Instant::now() < until)
237 }
238
239 fn lock(&self) -> MutexGuard<'_, State> {
243 self.state.lock().unwrap_or_else(PoisonError::into_inner)
244 }
245}
246
247impl fmt::Debug for ProxyList {
248 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249 let redacted: Vec<String> = self.proxies.iter().map(|p| redact_userinfo(p)).collect();
250 let in_cooldown: Vec<&str> = {
251 let state = self.lock();
252 let now = Instant::now();
253 redacted
254 .iter()
255 .zip(&state.bad_until)
256 .filter(|(_, until)| until.is_some_and(|until| now < until))
257 .map(|(url, _)| url.as_str())
258 .collect()
259 };
260 f.debug_struct("ProxyList")
261 .field("proxies", &redacted)
262 .field("in_cooldown", &in_cooldown)
263 .finish()
264 }
265}
266
267pub(crate) fn redact_userinfo(url: &str) -> String {
272 let (scheme, rest) = match url.split_once("://") {
273 Some((scheme, rest)) => (Some(scheme), rest),
274 None => (None, url),
275 };
276 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
280 let (authority, tail) = rest.split_at(authority_end);
281 let Some((_, host)) = authority.rsplit_once('@') else {
282 return url.to_string();
283 };
284 match scheme {
285 Some(scheme) => format!("{scheme}://***@{host}{tail}"),
286 None => format!("***@{host}{tail}"),
287 }
288}
289
290fn redact_unparsable(raw: &str) -> String {
294 let Some(at) = raw.rfind('@') else {
295 return raw.to_string();
296 };
297 let tail = &raw[at + 1..];
298 let prefix = raw
299 .find("://")
300 .filter(|p| *p < at)
301 .map(|p| &raw[..p + 3])
302 .unwrap_or("");
303 format!("{prefix}***@{tail}")
304}
305
306fn normalize_proxy_url(raw: &str) -> Result<String, Error> {
312 let raw = raw.trim();
313 if raw.is_empty() {
314 return Err(Error::invalid_proxy(String::new(), "proxy URL is empty"));
315 }
316
317 let has_scheme = raw.contains("://");
322 let with_scheme = if has_scheme {
323 raw.to_string()
324 } else {
325 format!("http://{raw}")
326 };
327 let shown = redact_userinfo(raw);
334 let url = reqwest::Url::parse(&with_scheme)
335 .ok()
336 .filter(reqwest::Url::has_host)
337 .ok_or_else(|| {
338 let unparsable = redact_unparsable(raw);
339 Error::invalid_proxy(unparsable, "not a valid proxy URL")
340 })?;
341
342 check_scheme(url.scheme(), &shown)?;
343 Ok(url.to_string())
344}
345
346fn check_scheme(scheme: &str, shown: &str) -> Result<(), Error> {
347 match scheme {
348 "http" | "https" => Ok(()),
349 "socks4" | "socks4a" | "socks5" | "socks5h" => {
350 if cfg!(feature = "socks") {
351 Ok(())
352 } else {
353 Err(Error::invalid_proxy(
354 shown,
355 "SOCKS proxies need the `socks` feature of reqwest-rotate",
356 ))
357 }
358 }
359 other => Err(Error::invalid_proxy(
360 shown,
361 format!("unsupported proxy scheme `{other}`"),
362 )),
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 fn list(proxies: &[&str]) -> ProxyList {
371 ProxyList::new(proxies).unwrap()
372 }
373
374 #[test]
375 fn round_robin_cycles_through_all_proxies() {
376 let list = list(&["http://a", "http://b", "http://c"]);
377 assert_eq!(list.pick(), Some("http://a/"));
378 assert_eq!(list.pick(), Some("http://b/"));
379 assert_eq!(list.pick(), Some("http://c/"));
380 assert_eq!(list.pick(), Some("http://a/"));
381 }
382
383 #[test]
384 fn empty_list_has_no_pick() {
385 let list = ProxyList::new(Vec::<String>::new()).unwrap();
386 assert!(list.is_empty());
387 assert_eq!(list.len(), 0);
388 assert_eq!(list.pick(), None);
389 }
390
391 #[test]
392 fn rejects_blank_proxy_entries() {
393 let err = ProxyList::new([" "]).unwrap_err();
394 assert!(matches!(err, Error::InvalidProxy { .. }));
395 }
396
397 #[test]
398 fn rejects_unparseable_and_unknown_schemes() {
399 assert!(matches!(
400 ProxyList::new(["not a valid proxy url"]).unwrap_err(),
401 Error::InvalidProxy { .. }
402 ));
403 assert!(matches!(
404 ProxyList::new(["ftp://proxy.example:21"]).unwrap_err(),
405 Error::InvalidProxy { .. }
406 ));
407 }
408
409 #[test]
410 fn scheme_less_entries_are_treated_as_http() {
411 let list = list(&[
412 "1.2.3.4:8080",
413 "user:pass@proxy.example:3128",
414 "localhost:9",
415 ]);
416 assert_eq!(
417 list.as_slice(),
418 &[
419 "http://1.2.3.4:8080/",
420 "http://user:pass@proxy.example:3128/",
421 "http://localhost:9/",
422 ]
423 );
424 }
425
426 #[test]
427 fn urls_are_canonicalised() {
428 let list = list(&[
429 "HTTP://Proxy.Example:80",
430 "https://user:pass@proxy.example:443",
431 "http://proxy.example:8080/",
432 ]);
433 assert_eq!(
434 list.as_slice(),
435 &[
436 "http://proxy.example/",
437 "https://user:pass@proxy.example/",
438 "http://proxy.example:8080/",
439 ]
440 );
441 }
442
443 #[cfg(not(feature = "socks"))]
444 #[test]
445 fn socks_is_rejected_without_the_feature() {
446 let err = ProxyList::new(["socks5://127.0.0.1:1080"]).unwrap_err();
447 let Error::InvalidProxy { .. } = &err else {
448 panic!("expected InvalidProxy");
449 };
450 assert!(err.to_string().contains("socks"), "{err}");
451 }
452
453 #[cfg(feature = "socks")]
454 #[test]
455 fn socks_is_accepted_with_the_feature() {
456 let list = list(&["socks5://127.0.0.1:1080", "socks5h://127.0.0.1:1081"]);
457 assert_eq!(list.len(), 2);
458 }
459
460 #[test]
461 fn duplicates_are_dropped_keeping_first_position() {
462 let list = list(&["http://a", "http://b", "http://a/", "b", "HTTP://A:80"]);
463 assert_eq!(list.as_slice(), &["http://a/", "http://b/"]);
464 }
465
466 #[test]
467 fn dedup_holds_at_scale() {
468 let proxies: Vec<String> = (0..2000)
473 .map(|i| format!("http://10.0.0.{}:{}", i / 2 % 256, 9000 + i / 2))
474 .collect();
475 let list = ProxyList::new(&proxies).unwrap();
476 assert_eq!(list.len(), 1000);
477 assert_eq!(list.as_slice()[0], "http://10.0.0.0:9000/");
478 assert_eq!(list.as_slice()[999], "http://10.0.0.231:9999/");
479 }
480
481 #[test]
482 fn mark_bad_is_skipped_until_cooldown_expires() {
483 let list = list(&["http://a", "http://b"]);
484 assert_eq!(list.pick(), Some("http://a/"));
485 assert!(list.mark_bad("http://b", Duration::from_millis(50)));
487 assert!(list.in_cooldown("http://b/"));
488 assert!(list.in_cooldown("b"));
489 assert_eq!(list.pick(), Some("http://a/"));
491 std::thread::sleep(Duration::from_millis(80));
492 assert!(!list.in_cooldown("http://b/"));
493 assert_eq!(list.pick(), Some("http://b/"));
494 }
495
496 #[test]
497 fn mark_bad_on_unknown_proxy_is_a_no_op() {
498 let list = list(&["http://a"]);
499 assert!(!list.mark_bad("http://nope", Duration::from_secs(60)));
500 assert!(!list.mark_bad("not a url at all", Duration::from_secs(60)));
501 assert!(!list.in_cooldown("http://nope"));
502 assert_eq!(list.pick(), Some("http://a/"));
503 }
504
505 #[test]
506 fn mark_good_index_clears_a_cooldown() {
507 let list = list(&["http://a", "http://b"]);
508 list.mark_bad("http://a", Duration::from_secs(60));
509 assert!(list.in_cooldown("http://a"));
510 list.mark_good_index(0);
511 assert!(!list.in_cooldown("http://a"));
512 assert_eq!(list.pick(), Some("http://a/"));
513 }
514
515 #[test]
516 fn all_proxies_bad_returns_the_one_recovering_first() {
517 let list = list(&["http://a", "http://b", "http://c"]);
518 list.mark_bad("http://a", Duration::from_secs(60));
519 list.mark_bad("http://b", Duration::from_secs(10));
520 list.mark_bad("http://c", Duration::from_secs(60));
521 assert_eq!(list.pick(), Some("http://b/"));
522 assert_eq!(list.pick(), Some("http://b/"));
525 }
526
527 #[test]
528 fn any_healthy_except_ignores_the_given_index() {
529 let pool = list(&["http://a", "http://b"]);
530 assert!(pool.any_healthy_except(0));
531 pool.mark_bad("http://b", Duration::from_secs(60));
532 assert!(!pool.any_healthy_except(0));
533 assert!(pool.any_healthy_except(1));
534
535 pool.mark_bad("http://a", Duration::from_millis(50));
539 std::thread::sleep(Duration::from_millis(80));
540 assert!(pool.any_healthy_except(1));
541
542 let single = list(&["http://a"]);
543 assert!(!single.any_healthy_except(0));
544 }
545
546 #[test]
547 fn huge_cooldown_does_not_panic_or_poison() {
548 let list = list(&["http://a", "http://b"]);
549 list.mark_bad("http://a", Duration::MAX);
550 assert!(list.in_cooldown("http://a"));
551 assert_eq!(list.pick(), Some("http://b/"));
552 }
553
554 #[test]
555 fn debug_output_hides_credentials() {
556 let list = list(&["http://user:s3cret@a:8080", "http://b"]);
557 list.mark_bad("http://b", Duration::from_secs(60));
558 let debug = format!("{list:?}");
559 assert!(!debug.contains("s3cret"), "{debug}");
560 assert!(debug.contains("http://***@a:8080/"), "{debug}");
561 assert!(debug.contains("in_cooldown: [\"http://b/\"]"), "{debug}");
562 }
563
564 #[test]
565 fn redaction_handles_urls_without_credentials() {
566 assert_eq!(redact_userinfo("http://a:8080/"), "http://a:8080/");
567 assert_eq!(redact_userinfo("http://u:p@a/"), "http://***@a/");
568 assert_eq!(redact_userinfo("socks5://u@a/"), "socks5://***@a/");
569 assert_eq!(redact_userinfo("garbage"), "garbage");
570 assert_eq!(
571 redact_userinfo("user:pass@proxy.example:3128"),
572 "***@proxy.example:3128"
573 );
574 assert_eq!(
575 redact_userinfo("http://user:p@ss@proxy.example:3128"),
576 "http://***@proxy.example:3128"
577 );
578 assert_eq!(redact_userinfo("http://h/@path"), "http://h/@path");
579 assert_eq!(
580 redact_userinfo("http://u:p@h/@path?x=@y"),
581 "http://***@h/@path?x=@y"
582 );
583 }
584
585 #[test]
586 fn unparsable_redaction_assumes_the_worst() {
587 assert_eq!(
588 redact_unparsable("http://user:p?ss@host:3128"),
589 "http://***@host:3128"
590 );
591 assert_eq!(redact_unparsable("user:p#ss@host:3128"), "***@host:3128");
592 assert_eq!(redact_unparsable("a@b@c"), "***@c");
593 assert_eq!(redact_unparsable("nope"), "nope");
594 assert_eq!(redact_unparsable("http://"), "http://");
595 assert_eq!(redact_unparsable("user@host://x"), "***@host://x");
596 }
597
598 #[test]
599 fn invalid_proxy_errors_redact_credentials() {
600 let message = ProxyList::new(["ftp://user:pass@host:21"])
601 .unwrap_err()
602 .to_string();
603 assert!(!message.contains("pass"), "{message}");
604 assert!(!message.contains("user:"), "{message}");
605 assert!(message.contains("***@"), "{message}");
606
607 let schemeless = ProxyList::new(["not a valid proxy url"])
611 .unwrap_err()
612 .to_string();
613 assert!(
614 schemeless.starts_with("invalid proxy: not a valid proxy url:"),
615 "{schemeless}"
616 );
617
618 let schemeless_with_credentials = ProxyList::new(["user:s3cret@not a url"])
619 .unwrap_err()
620 .to_string();
621 assert!(
622 !schemeless_with_credentials.contains("s3cret"),
623 "{schemeless_with_credentials}"
624 );
625 assert!(
626 schemeless_with_credentials.starts_with("invalid proxy: ***@"),
627 "{schemeless_with_credentials}"
628 );
629
630 let scheme_with_at_in_password = ProxyList::new(["ftp://user:p@ss@host:21"])
631 .unwrap_err()
632 .to_string();
633 assert!(
634 !scheme_with_at_in_password.contains("ss@"),
635 "{scheme_with_at_in_password}"
636 );
637 assert!(
638 scheme_with_at_in_password.contains("***@host"),
639 "{scheme_with_at_in_password}"
640 );
641
642 let schemeless_with_at_in_password = ProxyList::new(["user:p@ss@host:99999"])
643 .unwrap_err()
644 .to_string();
645 assert!(
646 !schemeless_with_at_in_password.contains("ss@"),
647 "{schemeless_with_at_in_password}"
648 );
649 assert!(
650 schemeless_with_at_in_password.contains("***@host"),
651 "{schemeless_with_at_in_password}"
652 );
653
654 let slash_in_password = ProxyList::new(["http://user:p?ss@host:3128"])
659 .unwrap_err()
660 .to_string();
661 assert!(!slash_in_password.contains("p?ss"), "{slash_in_password}");
662 assert_eq!(
663 slash_in_password,
664 "invalid proxy: http://***@host:3128: not a valid proxy URL"
665 );
666
667 let hash_in_password = ProxyList::new(["user:p#ss@host:3128"])
668 .unwrap_err()
669 .to_string();
670 assert!(!hash_in_password.contains("p#ss"), "{hash_in_password}");
671 assert!(hash_in_password.contains("***@host:3128: not a valid proxy URL"));
672
673 let no_credentials = ProxyList::new(["http://"]).unwrap_err().to_string();
676 assert_eq!(
677 no_credentials,
678 "invalid proxy: http://: not a valid proxy URL"
679 );
680 }
681}