Skip to main content

nym_http_api_client/
fronted.rs

1// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4//! Utilities for and implementation of request tunneling
5
6use 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    // Per-domain failure counts, used by `FrontPolicy::ConfiguredRetry` to decide when enough
23    // domains have failed often enough to justify enabling fronting.
24    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    // Used to indicate that the client hit an error for the given domain that should trigger the
76    // retry policy to enable fronting. `domain` should be the host of the URL that failed.
77    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                // both comparisons are inclusive, so a threshold of 0 is trivially satisfied -
99                // see the `FrontingConfig` docs for the resulting edge-case behavior.
100                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    /// Whether domains without a front configured should still be considered when rotating
109    /// hosts while fronting is enabled. Only meaningful for [`FrontPolicy::ConfiguredRetry`] -
110    /// other policies keep the existing behavior of only rotating between fronted domains.
111    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    /// Whether a successful request against a non-fronted domain, while fronting was enabled,
119    /// should reset accumulated failure state and disable fronting again. See
120    /// [`FrontingConfig::disable_fronting_on_non_fronting_success`].
121    pub(crate) fn should_recover_on_non_fronted_success(&self) -> bool {
122        match &*self.policy.read().unwrap() {
123            FrontPolicy::ConfiguredRetry(cfg) => {
124                // this recovery path only makes sense if non-fronted domains can actually be
125                // selected during rotation in the first place.
126                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    /// Disable fronting and clear any accumulated per-domain failure counts, letting the retry
133    /// policy start fresh. Used to recover once a non-fronted domain succeeds, per
134    /// [`FrontingConfig::disable_fronting_on_non_fronting_success`].
135    pub(crate) fn recover(&self) {
136        self.enabled.store(false, Ordering::Relaxed);
137        self.reset_failures();
138    }
139
140    /// Clear accumulated per-domain failure counts without changing whether fronting is enabled.
141    pub(crate) fn reset_failures(&self) {
142        self.domain_failures.lock().unwrap().clear();
143    }
144}
145
146#[derive(Debug, Default, PartialEq, Clone)]
147/// Policy for when to use domain fronting for HTTP requests.
148pub enum FrontPolicy {
149    /// Always use domain fronting for all requests.
150    Always,
151
152    /// Only use domain fronting when retrying failed requests.
153    OnRetry,
154
155    /// Only enable domain fronting once enough distinct domains have each failed enough times,
156    /// as configured by [`FrontingConfig`].
157    ConfiguredRetry(FrontingConfig),
158
159    /// Never use domain fronting.
160    #[default]
161    Off,
162}
163
164/// Configuration for [`FrontPolicy::ConfiguredRetry`]. A domain is considered "failed" once it
165/// has failed `failures_per_domain` times; fronting is enabled once `num_domains_failed` distinct
166/// domains have failed.
167///
168/// Both thresholds are inclusive (`>=`), so `0` is a valid, well-defined value for either field
169/// rather than an error - it simply means the threshold is already satisfied:
170/// - `failures_per_domain: 0` - a domain counts as "failed" on its very first failure.
171/// - `num_domains_failed: 0` - fronting is enabled as soon as `retry_enable` is called with a
172///   domain, even before any domain has reached `failures_per_domain`. This makes
173///   `failures_per_domain` irrelevant, so setting `num_domains_failed` to `0` alongside a
174///   non-zero `failures_per_domain` is almost certainly a configuration mistake - use
175///   [`FrontPolicy::OnRetry`] instead if "enable on the first failure" is actually what's wanted.
176/// - `FrontingConfig::default()` (both `0`) therefore enables fronting on the very first failure
177///   seen, same as `num_domains_failed: 0` above.
178#[derive(Debug, Default, PartialEq, Clone)]
179pub struct FrontingConfig {
180    /// Allow N failures per domain before fronting enables. `0` means a single failure is
181    /// enough for a domain to count as "failed" - see the type-level docs for details.
182    failures_per_domain: usize,
183
184    /// Allow N domains to fail before fronting is enabled. `0` means fronting enables as soon as
185    /// any failure is recorded, bypassing `failures_per_domain` entirely - see the type-level
186    /// docs for details.
187    num_domains_failed: usize,
188
189    /// Once Fronting is enabled allow non-fronted domains to be included in the
190    /// rotation of domains that are used as the external facing SNI.
191    include_non_fronted_in_rotation: bool,
192
193    /// Requires `include_non_fronted_in_rotation()` to be set and on the success of a non-fronted
194    /// domain for an API request resets the counters and disables domain fronting. This is meant to
195    /// allow the http client to recover and use domain fronting less if it doesn't need to do so.
196    disable_fronting_on_non_fronting_success: bool,
197}
198
199impl FrontingConfig {
200    /// Create a new configuration for [`FrontPolicy::ConfiguredRetry`].
201    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    /// Set the number of failures allowed for a single domain before it is considered failed.
211    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    /// Set the number of distinct domains that must fail before fronting is enabled.
217    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    /// Once fronting is enabled, allow domains without a configured front to still be selected
223    /// when rotating hosts (rather than only rotating between fronted domains).
224    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    /// Requires [`Self::with_include_non_fronted_in_rotation`] to also be set. When a request
230    /// against a non-fronted domain succeeds while fronting is enabled, reset the accumulated
231    /// failure counts and disable fronting again, letting the client rely less on fronting once
232    /// it is no longer needed.
233    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    /// Enable and configure request tunneling for API requests. If no front policy is
241    /// provided the shared fronting policy will be used.
242    pub fn with_fronting(mut self, policy: Option<FrontPolicy>) -> Self {
243        // Check if the configured threshold is even reachable: `num_domains_failed` counts
244        // distinct base urls that have failed, so requiring more than are configured means
245        // fronting can never engage.
246        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        // Check if any of the supplied urls even support fronting
263        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    /// Set the policy for enabling fronting. If fronting was previously unset this will set it, and
278    /// make it possible to enable (i.e [`FrontPolicy::Off`] will not enable it).
279    ///
280    /// Calling this function sets a custom policy for this client, disconnecting it from the shared
281    /// fronting policy -- i.e. changes applied through [`Client::set_shared_front_policy`] will not
282    /// be impact this client.
283    pub fn set_front_policy(&mut self, policy: FrontPolicy) {
284        self.front.set_policy(policy)
285    }
286
287    /// Set the fronting policy for this client to follow the shared policy.
288    pub fn use_shared_front_policy(&mut self) {
289        self.front = Front::shared();
290    }
291
292    /// Set the fronting policy for all clients using the shared policy.
293    //
294    // NOTE: this does not reset the per-instance enabled flag like it will when using
295    // [`Front::set_front_policy`]. So if a client is using shared policy with the `OnRetry` policy
296    // and this function is used to swap that policy away from and then back to `OnRetry` the
297    // fronting will still be enabled. Noting this here just in case this triggers any corner cases
298    // down the road.
299    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    /// Policy can be set for an independent client and the update is applied properly
317    #[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        // Ensure that setting the policy for a client it gets properly applied.
339        client1.set_front_policy(FrontPolicy::Always);
340        assert!(client1.front.policy() == FrontPolicy::Always);
341
342        // ensure that setting the policy in a client NOT using the shared policy does NOT update
343        // the policy used by another client.
344        assert!(client2.front.policy() == FrontPolicy::OnRetry);
345
346        // Ensure that the policy takes effect and is applied when setting host headers on outgoing
347        // requests
348        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    /// `ConfiguredRetry` should only enable fronting once enough distinct domains have each
376    /// failed at least `failures_per_domain` times.
377    #[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        // one failure on domain-a is not enough to count it as "failed" (needs 2)
384        front.retry_enable(Some("domain-a"));
385        assert!(!front.is_enabled());
386
387        // domain-a fails again, reaching its threshold, but only 1 domain has failed so far
388        // (needs 2 domains)
389        front.retry_enable(Some("domain-a"));
390        assert!(!front.is_enabled());
391
392        // repeated failures on the SAME domain don't count as additional failed domains
393        front.retry_enable(Some("domain-a"));
394        assert!(!front.is_enabled());
395
396        // domain-b fails once - not enough on its own
397        front.retry_enable(Some("domain-b"));
398        assert!(!front.is_enabled());
399
400        // domain-b reaches its own threshold - now 2 distinct domains have failed enough times
401        front.retry_enable(Some("domain-b"));
402        assert!(front.is_enabled());
403    }
404
405    /// `ConfiguredRetry` should ignore failures with no associated domain rather than panicking
406    /// or enabling fronting.
407    #[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    /// `failures_per_domain: 0` means a domain counts as "failed" on its very first failure,
417    /// rather than being an invalid or ignored configuration.
418    #[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        // domain-a's single failure immediately counts it as "failed" (threshold of 0), but a
424        // second distinct domain is still required to reach num_domains_failed.
425        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    /// `num_domains_failed: 0` means the threshold is already satisfied, so fronting enables on
433    /// the very first tracked failure regardless of `failures_per_domain` - even a very high
434    /// `failures_per_domain` does not delay this.
435    #[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    /// The default `FrontingConfig` (all fields zeroed) enables fronting on the very first
445    /// tracked failure - documenting this explicitly since a naive reading of "default" might
446    /// otherwise suggest fronting never turns on.
447    #[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    /// Replacing the base urls clears failure counts recorded against the old hosts.
456    #[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        // only one domain of the new set has failed, so the 2-domain threshold is not met
473        client.front.retry_enable(Some("new.test"));
474        assert!(!client.front.is_enabled());
475    }
476
477    /// Setting a new policy resets both the enabled flag and any accumulated per-domain failure
478    /// counts.
479    #[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        // re-applying the same policy should reset accumulated failure state
488        front.set_policy(FrontPolicy::ConfiguredRetry(cfg));
489        assert!(!front.is_enabled());
490    }
491
492    /// By default, host rotation should skip domains that have no front configured once fronting
493    /// is enabled - only `include_non_fronted_in_rotation` opts into visiting them.
494    #[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        // the only other base url has no front - rotation should skip it and stay put, still
513        // fronted.
514        client.update_host(None);
515        assert_eq!(client.current_url_str(), "https://front.test/");
516    }
517
518    /// `include_non_fronted_in_rotation` allows a non-fronted domain to be selected during host
519    /// rotation even while fronting is enabled.
520    #[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        // the fronted host gets its own front turn before rotation moves to the next base url;
537        // that url has no front, so its "turn" is just itself; rotation then comes back around
538        // to the fronted host's direct turn before repeating.
539        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    /// `disable_fronting_on_non_fronting_success` requires `include_non_fronted_in_rotation` to
550    /// also be set - it has no effect on its own.
551    #[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    /// When both flags are set, [`Front::recover`] disables fronting and clears accumulated
560    /// per-domain failure counts, so it takes a fresh run of failures to re-enable fronting.
561    #[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        // a single failure is not enough on its own (needs 2) - proves the counters were cleared,
577        // not just the enabled flag.
578        front.retry_enable(Some("domain-a"));
579        assert!(!front.is_enabled());
580    }
581
582    /// Policy can be set for the shared client and the update is applied properly.
583    ///
584    /// This mutates the process-wide `SHARED_FRONTING_POLICY`, so it holds the shared test-state
585    /// lock to prevent interference with other tests (previously disabled for this reason).
586    #[test]
587    #[serial]
588    fn set_policy_shared_client() {
589        // restores the shared policy to whatever it was before this test on scope exit (including
590        // on panic/assertion failure), so a leaked mutation can't leak into whichever test the
591        // shared-state lock is handed to next.
592        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        // Ensure that setting the shared policy gets properly applied
623        Client::set_shared_front_policy(FrontPolicy::Always);
624        assert!(client1.front.policy() == FrontPolicy::Always);
625
626        // Setting the shared policy should NOT update clients NOT using the shared policy.
627        assert!(client2.front.policy() == FrontPolicy::Off);
628
629        // Ensure that the policy takes effect and is applied when setting host headers on outgoing
630        // requests
631        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        // ensure that setting to the shared policy works
658        client2.use_shared_front_policy();
659        assert!(client2.front.policy() == FrontPolicy::Always);
660
661        // ensure that if the policy is OnRetry then the `enabled` fields are still independent,
662        // despite the policy being shared.
663        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        // leave the shared policy as we found it for whichever test runs next
675        Client::set_shared_front_policy(FrontPolicy::Off);
676    }
677
678    // sends a real request, which reads the process-wide SHARED_NETWORK_RECONFIGURATION
679    // marker - must not run concurrently with tests that mutate it.
680    #[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(); // fastly
688
689        // let url2 = FrontedUrl::new(
690        //     "https://validator.nymtech.net",
691        //     Some(vec!["https://cdn77.com"]),
692        // ).unwrap(); // cdn77
693
694        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        // println!("{response:?}");
711        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    // sends requests, so `Client::send` reads the process-wide SHARED_NETWORK_RECONFIGURATION
735    // marker - must not run concurrently with tests that mutate it.
736    #[tokio::test]
737    #[serial]
738    async fn fallback_on_failure() {
739        // `fake-front-1`/`fake-front-2` are pinned to deterministic DNS failures via `MockResolver`
740        // below (one NXDOMAIN, one SERVFAIL - exercising both branches of
741        // `might_be_network_interference`'s DNS classification) rather than relying on any live DNS
742        // infrastructure to keep answering consistently for hostnames that don't exist.
743        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(); // fastly
756
757        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        // Check that the initial configuration has the broken domain and front.
769        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        // Check that the host configuration updated the front on error, staying on the same
784        // (still-broken) domain - both fronts belong to it.
785        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        // Check that the host configuration updated the domain and front on error.
800        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    /// Deterministic outcome a [`MockResolver`] returns for one hostname.
815    #[derive(Clone)]
816    enum MockOutcome {
817        /// Resolve to these addresses, as if the lookup succeeded.
818        #[allow(dead_code)]
819        Addrs(Vec<IpAddr>),
820        /// Fail as a clean NXDOMAIN (the domain does not exist).
821        NxDomain,
822        /// Fail as a SERVFAIL (the server could not process the query).
823        ServFail,
824    }
825
826    /// A [`Resolve`] implementation for tests that need a specific, deterministic DNS outcome
827    /// for one or more hostnames without depending on any live DNS infrastructure for them.
828    ///
829    /// Any hostname not explicitly configured falls through to a real (independent, non-shared)
830    /// resolver, so a test can mock only the hosts it cares about (e.g. hosts standing in for a
831    /// network failure) while still reaching the network for the rest (e.g. a host the test
832    /// expects to actually succeed against).
833    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    /// Wraps a [`NetError`] the same way the real resolver does (see `dns::ResolveError`), so it
867    /// reaches [`crate::might_be_network_interference`] in exactly the shape a genuine DNS
868    /// failure takes: a `crate::ResolveError::ResolveError(NetError)`, one `.source()` hop below
869    /// the `reqwest::Error` the client actually sees.
870    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                    // the exact query contents don't matter to `NetError::is_nx_domain()`, only
888                    // the response code does, but a real `Query` is required to build one.
889                    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}