1use std::collections::HashMap;
7use std::sync::{
8 Arc, LazyLock, Mutex, RwLock,
9 atomic::{AtomicBool, Ordering},
10};
11use tracing::warn;
12
13use crate::{Client, ClientBuilder};
14
15static SHARED_FRONTING_POLICY: LazyLock<Arc<RwLock<FrontPolicy>>> =
16 LazyLock::new(|| Arc::new(RwLock::new(FrontPolicy::Off)));
17
18#[derive(Debug)]
19pub(crate) struct Front {
20 pub(crate) policy: Arc<RwLock<FrontPolicy>>,
21 enabled: AtomicBool,
22 domain_failures: Mutex<HashMap<String, usize>>,
25}
26
27impl Clone for Front {
28 fn clone(&self) -> Self {
29 Self {
30 policy: self.policy.clone(),
31 enabled: AtomicBool::new(false),
32 domain_failures: Mutex::new(HashMap::new()),
33 }
34 }
35}
36
37impl Front {
38 pub(crate) fn new(policy: FrontPolicy) -> Self {
39 Self {
40 enabled: AtomicBool::new(false),
41 policy: Arc::new(RwLock::new(policy)),
42 domain_failures: Mutex::new(HashMap::new()),
43 }
44 }
45
46 pub(crate) fn off() -> Self {
47 Self::new(FrontPolicy::Off)
48 }
49
50 pub(crate) fn shared() -> Self {
51 let policy = SHARED_FRONTING_POLICY.clone();
52 Self {
53 enabled: AtomicBool::new(false),
54 policy,
55 domain_failures: Mutex::new(HashMap::new()),
56 }
57 }
58
59 pub(crate) fn set_policy(&self, policy: FrontPolicy) {
60 *self.policy.write().unwrap() = policy;
61 self.enabled.store(false, Ordering::Relaxed);
62 self.domain_failures.lock().unwrap().clear();
63 }
64
65 pub(crate) fn is_enabled(&self) -> bool {
66 match *self.policy.read().unwrap() {
67 FrontPolicy::Off => false,
68 FrontPolicy::OnRetry | FrontPolicy::ConfiguredRetry(_) => {
69 self.enabled.load(Ordering::Relaxed)
70 }
71 FrontPolicy::Always => true,
72 }
73 }
74
75 pub(crate) fn retry_enable(&self, domain: Option<&str>) {
78 if self.is_enabled() {
79 return;
80 }
81
82 match &*self.policy.read().unwrap() {
83 FrontPolicy::OnRetry => self.enabled.store(true, Ordering::Relaxed),
84 FrontPolicy::ConfiguredRetry(cfg) => {
85 let Some(domain) = domain else {
86 return;
87 };
88
89 let mut failures = self.domain_failures.lock().unwrap();
90 let count = failures.entry(domain.to_string()).or_insert(0);
91 *count += 1;
92
93 let failed_domains = failures
94 .values()
95 .filter(|&&count| count >= cfg.failures_per_domain)
96 .count();
97
98 if failed_domains >= cfg.num_domains_failed {
101 self.enabled.store(true, Ordering::Relaxed);
102 }
103 }
104 FrontPolicy::Off | FrontPolicy::Always => {}
105 }
106 }
107
108 pub(crate) fn include_non_fronted_in_rotation(&self) -> bool {
112 match &*self.policy.read().unwrap() {
113 FrontPolicy::ConfiguredRetry(cfg) => cfg.include_non_fronted_in_rotation,
114 FrontPolicy::Off | FrontPolicy::OnRetry | FrontPolicy::Always => false,
115 }
116 }
117
118 pub(crate) fn should_recover_on_non_fronted_success(&self) -> bool {
122 match &*self.policy.read().unwrap() {
123 FrontPolicy::ConfiguredRetry(cfg) => {
124 cfg.include_non_fronted_in_rotation && cfg.disable_fronting_on_non_fronting_success
127 }
128 FrontPolicy::Off | FrontPolicy::OnRetry | FrontPolicy::Always => false,
129 }
130 }
131
132 pub(crate) fn recover(&self) {
136 self.enabled.store(false, Ordering::Relaxed);
137 self.reset_failures();
138 }
139
140 pub(crate) fn reset_failures(&self) {
142 self.domain_failures.lock().unwrap().clear();
143 }
144}
145
146#[derive(Debug, Default, PartialEq, Clone)]
147pub enum FrontPolicy {
149 Always,
151
152 OnRetry,
154
155 ConfiguredRetry(FrontingConfig),
158
159 #[default]
161 Off,
162}
163
164#[derive(Debug, Default, PartialEq, Clone)]
179pub struct FrontingConfig {
180 failures_per_domain: usize,
183
184 num_domains_failed: usize,
188
189 include_non_fronted_in_rotation: bool,
192
193 disable_fronting_on_non_fronting_success: bool,
197}
198
199impl FrontingConfig {
200 pub fn new(failures_per_domain: usize, num_domains_failed: usize) -> Self {
202 Self {
203 failures_per_domain,
204 num_domains_failed,
205 include_non_fronted_in_rotation: false,
206 disable_fronting_on_non_fronting_success: false,
207 }
208 }
209
210 pub fn with_failures_per_domain(mut self, failures_per_domain: usize) -> Self {
212 self.failures_per_domain = failures_per_domain;
213 self
214 }
215
216 pub fn with_num_domains_failed(mut self, num_domains_failed: usize) -> Self {
218 self.num_domains_failed = num_domains_failed;
219 self
220 }
221
222 pub fn with_include_non_fronted_in_rotation(mut self, include: bool) -> Self {
225 self.include_non_fronted_in_rotation = include;
226 self
227 }
228
229 pub fn with_disable_fronting_on_non_fronting_success(mut self, disable: bool) -> Self {
234 self.disable_fronting_on_non_fronting_success = disable;
235 self
236 }
237}
238
239impl ClientBuilder {
240 pub fn with_fronting(mut self, policy: Option<FrontPolicy>) -> Self {
243 if let Some(FrontPolicy::ConfiguredRetry(cfg)) = &policy {
247 if cfg.num_domains_failed > self.urls.len() {
248 warn!(
249 "fronting policy requires {} distinct domain(s) to fail before enabling, but only {} base url(s) are configured: fronting can never engage",
250 cfg.num_domains_failed,
251 self.urls.len()
252 );
253 }
254 }
255
256 let front = if let Some(p) = policy {
257 Front::new(p)
258 } else {
259 Front::shared()
260 };
261
262 if !self.urls.iter().any(|url| url.has_front()) {
264 warn!(
265 "fronting is enabled, but none of the supplied urls have configured fronting domains: {:?}",
266 self.urls
267 );
268 }
269
270 self.front = front;
271
272 self
273 }
274}
275
276impl Client {
277 pub fn set_front_policy(&mut self, policy: FrontPolicy) {
284 self.front.set_policy(policy)
285 }
286
287 pub fn use_shared_front_policy(&mut self) {
289 self.front = Front::shared();
290 }
291
292 pub fn set_shared_front_policy(policy: FrontPolicy) {
300 *SHARED_FRONTING_POLICY.write().unwrap() = policy;
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::{ApiClientCore, NO_PARAMS, Url};
308 use serial_test::serial;
309
310 impl Front {
311 pub(crate) fn policy(&self) -> FrontPolicy {
312 self.policy.read().unwrap().clone()
313 }
314 }
315
316 #[test]
318 fn set_policy_independent_client() {
319 let url1 = Url::new(
320 "https://validator.global.ssl.fastly.net",
321 Some(vec!["https://yelp.global.ssl.fastly.net"]),
322 )
323 .unwrap();
324
325 let mut client1 = ClientBuilder::new(url1.clone())
326 .unwrap()
327 .with_fronting(Some(FrontPolicy::Off))
328 .build()
329 .unwrap();
330 assert!(client1.front.policy() == FrontPolicy::Off);
331
332 let client2 = ClientBuilder::new(url1.clone())
333 .unwrap()
334 .with_fronting(Some(FrontPolicy::OnRetry))
335 .build()
336 .unwrap();
337
338 client1.set_front_policy(FrontPolicy::Always);
340 assert!(client1.front.policy() == FrontPolicy::Always);
341
342 assert!(client2.front.policy() == FrontPolicy::OnRetry);
345
346 let req = client1
349 .create_request(reqwest::Method::GET, &["/"], NO_PARAMS, None::<&()>)
350 .unwrap()
351 .build()
352 .unwrap();
353
354 let expected_host = url1.host_str().unwrap();
355 assert!(
356 req.headers()
357 .get(reqwest::header::HOST)
358 .is_some_and(|h| h.to_str().unwrap() == expected_host),
359 "{:?} != {:?}",
360 expected_host,
361 req,
362 );
363
364 let expected_front = url1.first_front_str().unwrap();
365 assert!(
366 req.url()
367 .host()
368 .is_some_and(|url| url.to_string() == expected_front),
369 "{:?} != {:?}",
370 expected_front,
371 req,
372 );
373 }
374
375 #[test]
378 fn configured_retry_enables_after_thresholds_met() {
379 let cfg = FrontingConfig::new(2, 2);
380 let front = Front::new(FrontPolicy::ConfiguredRetry(cfg));
381 assert!(!front.is_enabled());
382
383 front.retry_enable(Some("domain-a"));
385 assert!(!front.is_enabled());
386
387 front.retry_enable(Some("domain-a"));
390 assert!(!front.is_enabled());
391
392 front.retry_enable(Some("domain-a"));
394 assert!(!front.is_enabled());
395
396 front.retry_enable(Some("domain-b"));
398 assert!(!front.is_enabled());
399
400 front.retry_enable(Some("domain-b"));
402 assert!(front.is_enabled());
403 }
404
405 #[test]
408 fn configured_retry_ignores_failures_without_a_domain() {
409 let cfg = FrontingConfig::new(1, 1);
410 let front = Front::new(FrontPolicy::ConfiguredRetry(cfg));
411
412 front.retry_enable(None);
413 assert!(!front.is_enabled());
414 }
415
416 #[test]
419 fn configured_retry_zero_failures_per_domain_counts_first_failure() {
420 let cfg = FrontingConfig::new(0, 2);
421 let front = Front::new(FrontPolicy::ConfiguredRetry(cfg));
422
423 front.retry_enable(Some("domain-a"));
426 assert!(!front.is_enabled());
427
428 front.retry_enable(Some("domain-b"));
429 assert!(front.is_enabled());
430 }
431
432 #[test]
436 fn configured_retry_zero_num_domains_failed_enables_immediately() {
437 let cfg = FrontingConfig::new(1000, 0);
438 let front = Front::new(FrontPolicy::ConfiguredRetry(cfg));
439
440 front.retry_enable(Some("domain-a"));
441 assert!(front.is_enabled());
442 }
443
444 #[test]
448 fn configured_retry_default_config_enables_on_first_failure() {
449 let front = Front::new(FrontPolicy::ConfiguredRetry(FrontingConfig::default()));
450
451 front.retry_enable(Some("domain-a"));
452 assert!(front.is_enabled());
453 }
454
455 #[test]
457 fn configured_retry_resets_failures_on_base_url_change() {
458 let cfg = FrontingConfig::new(1, 2);
459 let mut client = ClientBuilder::new_with_urls(vec![
460 Url::new("https://old.test", Some(vec!["https://front.test"])).unwrap(),
461 ])
462 .unwrap()
463 .with_fronting(Some(FrontPolicy::ConfiguredRetry(cfg)))
464 .build()
465 .unwrap();
466
467 client.front.retry_enable(Some("old.test"));
468 client.change_base_urls(vec![
469 Url::new("https://new.test", Some(vec!["https://front.test"])).unwrap(),
470 ]);
471
472 client.front.retry_enable(Some("new.test"));
474 assert!(!client.front.is_enabled());
475 }
476
477 #[test]
480 fn configured_retry_resets_on_policy_change() {
481 let cfg = FrontingConfig::new(1, 1);
482 let front = Front::new(FrontPolicy::ConfiguredRetry(cfg.clone()));
483
484 front.retry_enable(Some("domain-a"));
485 assert!(front.is_enabled());
486
487 front.set_policy(FrontPolicy::ConfiguredRetry(cfg));
489 assert!(!front.is_enabled());
490 }
491
492 #[test]
495 fn rotation_skips_non_fronted_domains_by_default() {
496 let fronted_url =
497 Url::new("https://has-front.test", Some(vec!["https://front.test"])).unwrap();
498 let plain_url = Url::new("https://no-front.test", None).unwrap();
499
500 let cfg = FrontingConfig::new(1, 1);
501 let client = ClientBuilder::new_with_urls(vec![fronted_url, plain_url])
502 .unwrap()
503 .with_fronting(Some(FrontPolicy::ConfiguredRetry(cfg)))
504 .build()
505 .unwrap();
506
507 assert_eq!(client.current_url_str(), "https://has-front.test/");
508
509 client.front.retry_enable(Some("has-front.test"));
510 assert!(client.front.is_enabled());
511
512 client.update_host(None);
515 assert_eq!(client.current_url_str(), "https://front.test/");
516 }
517
518 #[test]
521 fn rotation_includes_non_fronted_domains_when_configured() {
522 let fronted_url =
523 Url::new("https://has-front.test", Some(vec!["https://front.test"])).unwrap();
524 let plain_url = Url::new("https://no-front.test", None).unwrap();
525
526 let cfg = FrontingConfig::new(1, 1).with_include_non_fronted_in_rotation(true);
527 let client = ClientBuilder::new_with_urls(vec![fronted_url, plain_url])
528 .unwrap()
529 .with_fronting(Some(FrontPolicy::ConfiguredRetry(cfg)))
530 .build()
531 .unwrap();
532
533 client.front.retry_enable(Some("has-front.test"));
534 assert!(client.front.is_enabled());
535
536 client.update_host(None);
540 assert_eq!(client.current_url_str(), "https://front.test/");
541
542 client.update_host(None);
543 assert_eq!(client.current_url_str(), "https://no-front.test/");
544
545 client.update_host(None);
546 assert_eq!(client.current_url_str(), "https://has-front.test/");
547 }
548
549 #[test]
552 fn recover_flag_requires_include_non_fronted_in_rotation() {
553 let cfg = FrontingConfig::new(1, 1).with_disable_fronting_on_non_fronting_success(true);
554 let front = Front::new(FrontPolicy::ConfiguredRetry(cfg));
555
556 assert!(!front.should_recover_on_non_fronted_success());
557 }
558
559 #[test]
562 fn recover_disables_fronting_and_clears_failure_counts() {
563 let cfg = FrontingConfig::new(2, 1)
564 .with_include_non_fronted_in_rotation(true)
565 .with_disable_fronting_on_non_fronting_success(true);
566 let front = Front::new(FrontPolicy::ConfiguredRetry(cfg));
567
568 front.retry_enable(Some("domain-a"));
569 front.retry_enable(Some("domain-a"));
570 assert!(front.is_enabled());
571 assert!(front.should_recover_on_non_fronted_success());
572
573 front.recover();
574 assert!(!front.is_enabled());
575
576 front.retry_enable(Some("domain-a"));
579 assert!(!front.is_enabled());
580 }
581
582 #[test]
587 #[serial]
588 fn set_policy_shared_client() {
589 struct RestoreFrontPolicy(FrontPolicy);
593 impl Drop for RestoreFrontPolicy {
594 fn drop(&mut self) {
595 *SHARED_FRONTING_POLICY.write().unwrap() = self.0.clone();
596 }
597 }
598 let _restore_policy = RestoreFrontPolicy(SHARED_FRONTING_POLICY.read().unwrap().clone());
599
600 let url1 = Url::new(
601 "https://validator.global.ssl.fastly.net",
602 Some(vec!["https://yelp.global.ssl.fastly.net"]),
603 )
604 .unwrap();
605
606 Client::set_shared_front_policy(FrontPolicy::Off);
607 assert!(*SHARED_FRONTING_POLICY.read().unwrap() == FrontPolicy::Off);
608
609 let client1 = ClientBuilder::new(url1.clone())
610 .unwrap()
611 .with_fronting(None)
612 .build()
613 .unwrap();
614 assert!(client1.front.policy() == FrontPolicy::Off);
615
616 let mut client2 = ClientBuilder::new(url1.clone())
617 .unwrap()
618 .with_fronting(Some(FrontPolicy::Off))
619 .build()
620 .unwrap();
621
622 Client::set_shared_front_policy(FrontPolicy::Always);
624 assert!(client1.front.policy() == FrontPolicy::Always);
625
626 assert!(client2.front.policy() == FrontPolicy::Off);
628
629 let req = client1
632 .create_request(reqwest::Method::GET, &["/"], NO_PARAMS, None::<&()>)
633 .unwrap()
634 .build()
635 .unwrap();
636
637 let expected_host = url1.host_str().unwrap();
638 assert!(
639 req.headers()
640 .get(reqwest::header::HOST)
641 .is_some_and(|h| h.to_str().unwrap() == expected_host),
642 "{:?} != {:?}",
643 expected_host,
644 req,
645 );
646
647 let expected_front = url1.first_front_str().unwrap();
648 assert!(
649 req.url()
650 .host()
651 .is_some_and(|url| url.to_string() == expected_front),
652 "{:?} != {:?}",
653 expected_front,
654 req,
655 );
656
657 client2.use_shared_front_policy();
659 assert!(client2.front.policy() == FrontPolicy::Always);
660
661 Client::set_shared_front_policy(FrontPolicy::OnRetry);
664 assert!(client1.front.policy() == FrontPolicy::OnRetry);
665 assert!(client2.front.policy() == FrontPolicy::OnRetry);
666
667 assert!(!client1.front.is_enabled());
668 assert!(!client2.front.is_enabled());
669
670 client1.front.retry_enable(None);
671 assert!(client1.front.is_enabled());
672 assert!(!client2.front.is_enabled());
673
674 Client::set_shared_front_policy(FrontPolicy::Off);
676 }
677
678 #[tokio::test]
681 #[serial]
682 async fn nym_api_works() {
683 let url1 = Url::new(
684 "https://validator.global.ssl.fastly.net",
685 Some(vec!["https://yelp.global.ssl.fastly.net"]),
686 )
687 .unwrap(); let client = ClientBuilder::new(url1)
695 .expect("bad url")
696 .with_fronting(Some(FrontPolicy::Always))
697 .build()
698 .expect("failed to build client");
699
700 let response = client
701 .send_request::<_, (), &str, &str>(
702 reqwest::Method::GET,
703 &["api", "v1", "network", "details"],
704 NO_PARAMS,
705 None,
706 )
707 .await
708 .expect("failed get request");
709
710 assert_eq!(response.status(), 200);
712 }
713}
714
715#[cfg(test)]
716mod mocked_tests {
717 use super::*;
718 use crate::{ApiClientCore, HickoryDnsResolver, NO_PARAMS, Url};
719 use hickory_resolver::{
720 net::{DnsError, NetError, NoRecords},
721 proto::{
722 op::{Query, ResponseCode},
723 rr::{Name as HickoryName, RecordType},
724 },
725 };
726 use reqwest::dns::{Addrs, Name, Resolve, Resolving};
727 use serial_test::serial;
728 use std::{
729 collections::HashMap,
730 net::{IpAddr, SocketAddr},
731 str::FromStr,
732 };
733
734 #[tokio::test]
737 #[serial]
738 async fn fallback_on_failure() {
739 let url1 = Url::new(
744 "https://fake-domain.invalid",
745 Some(vec![
746 "https://fake-front-1.invalid",
747 "https://fake-front-2.invalid",
748 ]),
749 )
750 .unwrap();
751 let url2 = Url::new(
752 "https://validator.global.ssl.fastly.net",
753 Some(vec!["https://yelp.global.ssl.fastly.net"]),
754 )
755 .unwrap(); let mock_resolver = MockResolver::new()
758 .with_nxdomain("fake-front-1.invalid")
759 .with_servfail("fake-front-2.invalid");
760
761 let client = ClientBuilder::new_with_urls(vec![url1, url2])
762 .expect("bad url")
763 .with_fronting(Some(FrontPolicy::Always))
764 .dns_resolver(std::sync::Arc::new(mock_resolver))
765 .build()
766 .expect("failed to build client");
767
768 assert_eq!(client.current_url().host_str(), Some("fake-domain.invalid"));
770 assert_eq!(client.current_url_str(), "https://fake-front-1.invalid/",);
771 assert_eq!(client.current_front_host(), Some("fake-front-1.invalid"),);
772
773 let result = client
774 .send_request::<_, (), &str, &str>(
775 reqwest::Method::GET,
776 &["api", "v1", "network", "details"],
777 NO_PARAMS,
778 None,
779 )
780 .await;
781 assert!(result.is_err());
782
783 assert_eq!(client.current_url().host_str(), Some("fake-domain.invalid"));
786 assert_eq!(client.current_url_str(), "https://fake-front-2.invalid/",);
787 assert_eq!(client.current_front_host(), Some("fake-front-2.invalid"),);
788
789 let result = client
790 .send_request::<_, (), &str, &str>(
791 reqwest::Method::GET,
792 &["api", "v1", "network", "details"],
793 NO_PARAMS,
794 None,
795 )
796 .await;
797 assert!(result.is_err());
798
799 assert_eq!(
801 client.current_url().host_str(),
802 Some("validator.global.ssl.fastly.net")
803 );
804 assert_eq!(
805 client.current_url_str(),
806 "https://yelp.global.ssl.fastly.net/",
807 );
808 assert_eq!(
809 client.current_front_host(),
810 Some("yelp.global.ssl.fastly.net"),
811 );
812 }
813
814 #[derive(Clone)]
816 enum MockOutcome {
817 #[allow(dead_code)]
819 Addrs(Vec<IpAddr>),
820 NxDomain,
822 ServFail,
824 }
825
826 struct MockResolver {
834 outcomes: HashMap<String, MockOutcome>,
835 fallback: HickoryDnsResolver,
836 }
837
838 impl MockResolver {
839 fn new() -> Self {
840 Self {
841 outcomes: HashMap::new(),
842 fallback: HickoryDnsResolver::thread_resolver(),
843 }
844 }
845
846 fn with_nxdomain(mut self, host: &str) -> Self {
847 self.outcomes
848 .insert(host.to_string(), MockOutcome::NxDomain);
849 self
850 }
851
852 fn with_servfail(mut self, host: &str) -> Self {
853 self.outcomes
854 .insert(host.to_string(), MockOutcome::ServFail);
855 self
856 }
857
858 #[allow(dead_code)]
859 fn with_addrs(mut self, host: &str, addrs: Vec<IpAddr>) -> Self {
860 self.outcomes
861 .insert(host.to_string(), MockOutcome::Addrs(addrs));
862 self
863 }
864 }
865
866 fn mock_dns_error(net_err: NetError) -> Box<dyn std::error::Error + Send + Sync> {
871 Box::new(crate::ResolveError::ResolveError(net_err))
872 }
873
874 impl Resolve for MockResolver {
875 fn resolve(&self, name: Name) -> Resolving {
876 let host = name.as_str().to_string();
877 match self.outcomes.get(&host) {
878 Some(MockOutcome::Addrs(addrs)) => {
879 let addrs = addrs.clone();
880 Box::pin(async move {
881 let addrs: Addrs =
882 Box::new(addrs.into_iter().map(|ip| SocketAddr::new(ip, 0)));
883 Ok(addrs)
884 })
885 }
886 Some(MockOutcome::NxDomain) => {
887 let query = HickoryName::from_str(&host)
890 .map(|name| Query::query(name, RecordType::A))
891 .unwrap_or_default();
892 let err = mock_dns_error(NetError::Dns(DnsError::NoRecordsFound(
893 NoRecords::new(Box::new(query), ResponseCode::NXDomain),
894 )));
895 Box::pin(async move { Err(err) })
896 }
897 Some(MockOutcome::ServFail) => {
898 let err = mock_dns_error(NetError::Dns(DnsError::ResponseCode(
899 ResponseCode::ServFail,
900 )));
901 Box::pin(async move { Err(err) })
902 }
903 None => self.fallback.resolve(name),
904 }
905 }
906 }
907}