Skip to main content

autumn_web/sim/
net.rs

1//! The simulated network (issue #2967).
2//!
3//! A [`SimNet`] replaces the real network for outbound calls made through
4//! [`crate::http_client::Client`]. It does three things:
5//!
6//! - **Hosts.** [`SimNet::host`] maps a host name to an in-process
7//!   [`axum::Router`]. A call to `http://payments/charge` is served by the
8//!   router named `payments`. A host with no router falls back to the app's
9//!   http mocks ([`crate::test::TestApp::http_mock`]). A host with neither is
10//!   an error. No call reaches the real network.
11//! - **Seeded faults.** [`SimNet::latency`] delays each attempt by a seeded
12//!   amount of virtual time. [`SimNet::drop_rate`] drops a seeded share of
13//!   attempts. The client's retry policy applies: attempts and backoff, 429
14//!   `Retry-After`, 502-504 retries and the per-attempt `request_timeout`. A
15//!   drop is retried like a real connect or timeout error.
16//! - **Partitions.** [`SimNet::partition`] cuts a host off until
17//!   [`SimNet::heal`]. Call them while the test runs.
18//!
19//! Every decision comes from a stream seeded from the sim seed, and
20//! [`SimNet::events`] records each attempt. Two runs with the same seed record
21//! equal event logs.
22//!
23//! ```rust,ignore
24//! let net = SimNet::new()
25//!     .host("payments", payments_router())
26//!     .latency(Duration::from_millis(5), Duration::from_millis(80))
27//!     .drop_rate(0.1);
28//! sim.net(net.clone());
29//! sim.build(TestApp::new().routes(routes![checkout]));
30//! net.partition("payments");
31//! ```
32//!
33//! # Scope
34//!
35//! Only calls through `http_client::Client` built from the app state (the
36//! `Client` extractor or `Client::from_state`) see the network. A client made
37//! with `Client::new()` does not. Needs the `http-client` feature.
38//!
39//! As with http mocks, a sim call skips the process-global circuit breaker,
40//! the SSRF address checks, `pin_to` and redirect following. A host router's
41//! `3xx` comes back as the response.
42
43use std::collections::{BTreeMap, BTreeSet};
44use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
45use std::time::Duration;
46
47use crate::entropy::{Entropy, SeededEntropy};
48
49/// Salt `XOR`ed into the mount seed for the network stream, so it is
50/// independent of the other seeded streams.
51pub(crate) const NET_STREAM_SALT: u64 = 0x4E37_5EED_4E37_5EED;
52
53/// What the network did to one attempt.
54#[non_exhaustive]
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum NetFault {
57    /// The attempt was delivered.
58    None,
59    /// The attempt was lost after its latency. The client retries it like a
60    /// connect or timeout error.
61    Dropped,
62    /// The host was partitioned, so the attempt failed at once.
63    Partitioned,
64    /// The latency passed the client's `request_timeout`. The event's latency
65    /// is the timeout, the time that actually passed. The client retries it.
66    TimedOut,
67}
68
69impl std::fmt::Display for NetFault {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(match self {
72            Self::None => "delivered",
73            Self::Dropped => "dropped",
74            Self::Partitioned => "partitioned",
75            Self::TimedOut => "timed out",
76        })
77    }
78}
79
80/// One attempt through the network, in order.
81#[non_exhaustive]
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct NetEvent {
84    /// The attempt number, starting at 0.
85    pub seq: u64,
86    /// The host of the attempt.
87    pub host: String,
88    /// The virtual time the attempt took.
89    pub latency: Duration,
90    /// What the network did.
91    pub fault: NetFault,
92}
93
94/// A seeded virtual network for outbound HTTP. See the [module docs](self).
95///
96/// Cheap to clone: clones share one network, so a test keeps a clone to
97/// partition hosts and read events after it hands one to
98/// [`Sim::net`](crate::sim::Sim::net).
99#[derive(Clone, Default)]
100pub struct SimNet {
101    inner: Arc<Mutex<NetState>>,
102}
103
104struct NetState {
105    hosts: BTreeMap<String, axum::Router>,
106    partitioned: BTreeSet<String>,
107    drop_rate: f64,
108    latency: (Duration, Duration),
109    stream: Arc<dyn Entropy>,
110    events: Vec<NetEvent>,
111}
112
113impl Default for NetState {
114    fn default() -> Self {
115        Self {
116            hosts: BTreeMap::new(),
117            partitioned: BTreeSet::new(),
118            drop_rate: 0.0,
119            latency: (Duration::ZERO, Duration::ZERO),
120            stream: SeededEntropy::shared(NET_STREAM_SALT),
121            events: Vec::new(),
122        }
123    }
124}
125
126impl std::fmt::Debug for SimNet {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        let state = self.lock();
129        f.debug_struct("SimNet")
130            .field("hosts", &state.hosts.keys().collect::<Vec<_>>())
131            .field("partitioned", &state.partitioned)
132            .field("drop_rate", &state.drop_rate)
133            .field("latency", &state.latency)
134            .finish_non_exhaustive()
135    }
136}
137
138impl SimNet {
139    /// An empty network: no hosts, no latency, no drops.
140    #[must_use]
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Serve calls to `host` with `router`. The name matches as a URL host
146    /// does: `Payments` serves `http://payments/`.
147    #[must_use]
148    pub fn host(self, host: impl Into<String>, router: axum::Router) -> Self {
149        self.lock()
150            .hosts
151            .insert(canonical_host(&host.into()), router);
152        self
153    }
154
155    /// Delay each attempt by a seeded time in `[min, max]`, to the
156    /// nanosecond. A `max` below `min` is raised to `min`.
157    #[must_use]
158    pub fn latency(self, min: Duration, max: Duration) -> Self {
159        self.lock().latency = (min, max.max(min));
160        self
161    }
162
163    /// Drop this share of attempts, from `0.0` (none) to `1.0` (all). The value
164    /// is clamped, and `NaN` is `0.0`.
165    #[must_use]
166    pub fn drop_rate(self, rate: f64) -> Self {
167        self.lock().drop_rate = super::chaos::clamp_prob(rate);
168        self
169    }
170
171    /// Cut `host` off. Each attempt to reach it fails at once until
172    /// [`heal`](Self::heal).
173    pub fn partition(&self, host: impl Into<String>) {
174        self.lock().partitioned.insert(canonical_host(&host.into()));
175    }
176
177    /// Reconnect a partitioned `host`.
178    pub fn heal(&self, host: &str) {
179        self.lock().partitioned.remove(&canonical_host(host));
180    }
181
182    /// Every attempt that finished its latency, in the order they finished. An
183    /// attempt dropped during its latency is not recorded.
184    #[must_use]
185    pub fn events(&self) -> Vec<NetEvent> {
186        self.lock().events.clone()
187    }
188
189    /// Restart the decision stream from `seed`. Called at each mount.
190    pub(crate) fn reseed(&self, seed: u64) {
191        self.lock().stream = SeededEntropy::shared(seed ^ NET_STREAM_SALT);
192    }
193
194    /// Serve `host` with `router` from now on (a mounted replica, #3067).
195    pub(crate) fn insert_host(&self, host: &str, router: axum::Router) {
196        self.lock().hosts.insert(canonical_host(host), router);
197    }
198
199    /// Stop serving `host` (a killed replica). Calls to it then fail.
200    pub(crate) fn remove_host(&self, host: &str) {
201        self.lock().hosts.remove(&canonical_host(host));
202    }
203
204    /// The router that serves `host`, if any.
205    pub(crate) fn service(&self, host: &str) -> Option<axum::Router> {
206        self.lock().hosts.get(&canonical_host(host)).cloned()
207    }
208
209    /// Send one attempt to `host`: record it, wait its latency, and return the
210    /// fault, if any. A latency above `timeout` becomes a
211    /// [`NetFault::TimedOut`] that waits only the timeout.
212    pub(crate) async fn transmit(
213        &self,
214        host: &str,
215        timeout: Option<Duration>,
216    ) -> Result<(), NetFault> {
217        let (latency, fault) = {
218            let state = self.lock();
219            // Two draws per attempt, always, so the stream maps one-to-one
220            // onto attempts whatever the configuration.
221            let latency_draw = state.stream.next_u64();
222            let drop_draw = state.stream.next_u64();
223            let (latency, fault) = if state.partitioned.contains(&canonical_host(host)) {
224                (Duration::ZERO, NetFault::Partitioned)
225            } else if super::chaos::unit_from_draw(drop_draw) < state.drop_rate {
226                (
227                    sample_latency(state.latency, latency_draw),
228                    NetFault::Dropped,
229                )
230            } else {
231                (sample_latency(state.latency, latency_draw), NetFault::None)
232            };
233            let (latency, fault) = match timeout {
234                Some(limit) if fault != NetFault::Partitioned && latency > limit => {
235                    (limit, NetFault::TimedOut)
236                }
237                _ => (latency, fault),
238            };
239            drop(state);
240            (latency, fault)
241        };
242        if !latency.is_zero() {
243            tokio::time::sleep(latency).await;
244        }
245        // Record the attempt only once its latency has passed. An attempt
246        // dropped before then (a crash, a cancelled request) never reached the
247        // host, so it leaves no event. Its draws are still spent, so later
248        // attempts replay the same way.
249        {
250            let mut state = self.lock();
251            let seq = state.events.len() as u64;
252            state.events.push(NetEvent {
253                seq,
254                host: host.to_owned(),
255                latency,
256                fault,
257            });
258        }
259        match fault {
260            NetFault::None => Ok(()),
261            fault => Err(fault),
262        }
263    }
264
265    fn lock(&self) -> MutexGuard<'_, NetState> {
266        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
267    }
268}
269
270/// A latency in `[min, max]`, to the nanosecond, from `draw`. Bounds past
271/// `u64::MAX` nanoseconds (about 584 years) are capped there.
272fn sample_latency((min, max): (Duration, Duration), draw: u64) -> Duration {
273    let min_ns = u64::try_from(min.as_nanos()).unwrap_or(u64::MAX);
274    let max_ns = u64::try_from(max.as_nanos()).unwrap_or(u64::MAX);
275    let span = max_ns.saturating_sub(min_ns).saturating_add(1);
276    Duration::from_nanos(min_ns.saturating_add(draw % span))
277}
278
279/// `host` as a URL writes it: lower case, with an international name in
280/// punycode and an IPv6 address in brackets. A request's host is always in
281/// this form, so a name registered any other way would never match.
282pub(crate) fn canonical_host(host: &str) -> String {
283    url::Host::parse(host).map_or_else(|_| host.to_ascii_lowercase(), |parsed| parsed.to_string())
284}
285
286#[cfg(test)]
287mod tests {
288    #[test]
289    fn a_host_name_matches_as_a_url_host_does() {
290        use super::canonical_host;
291        assert_eq!(canonical_host("Payments"), "payments");
292        assert_eq!(
293            canonical_host("PAYMENTS.Example.COM"),
294            "payments.example.com"
295        );
296        assert_eq!(canonical_host("[::1]"), "[::1]");
297        assert_eq!(canonical_host("10.0.0.1"), "10.0.0.1");
298        let url = url::Url::parse("http://Bücher.Example/").unwrap();
299        assert_eq!(canonical_host("Bücher.Example"), url.host_str().unwrap());
300    }
301
302    use super::{Duration, NetFault, SimNet, sample_latency};
303
304    #[test]
305    fn latency_stays_in_range() {
306        let range = (Duration::from_millis(5), Duration::from_millis(9));
307        for draw in 0..100 {
308            let latency = sample_latency(range, draw);
309            assert!(latency >= range.0 && latency <= range.1, "{latency:?}");
310        }
311        let fixed = (Duration::from_millis(7), Duration::from_millis(7));
312        assert_eq!(sample_latency(fixed, u64::MAX), Duration::from_millis(7));
313    }
314
315    #[test]
316    fn sub_millisecond_latency_is_kept() {
317        let fixed = (Duration::from_micros(500), Duration::from_micros(500));
318        assert_eq!(sample_latency(fixed, 12_345), Duration::from_micros(500));
319        let range = (Duration::from_micros(1500), Duration::from_micros(2500));
320        for draw in [0, 1, 999, 1000, 1_000_000, u64::MAX] {
321            let latency = sample_latency(range, draw);
322            assert!(latency >= range.0 && latency <= range.1, "{latency:?}");
323        }
324    }
325
326    #[tokio::test(start_paused = true)]
327    async fn a_latency_past_the_timeout_is_recorded_as_a_timeout() {
328        let net = SimNet::new().latency(Duration::from_secs(60), Duration::from_secs(60));
329        let limit = Duration::from_secs(30);
330        let start = tokio::time::Instant::now();
331        assert_eq!(
332            net.transmit("h", Some(limit)).await,
333            Err(NetFault::TimedOut)
334        );
335        assert_eq!(start.elapsed(), limit, "only the timeout passes");
336        let events = net.events();
337        assert_eq!(events[0].fault, NetFault::TimedOut);
338        assert_eq!(events[0].latency, limit);
339        assert_eq!(
340            net.transmit("h", None).await,
341            Ok(()),
342            "no timeout, no fault"
343        );
344    }
345
346    #[test]
347    fn max_below_min_is_raised() {
348        let net = SimNet::new().latency(Duration::from_millis(9), Duration::from_millis(1));
349        assert_eq!(
350            net.lock().latency,
351            (Duration::from_millis(9), Duration::from_millis(9))
352        );
353    }
354
355    #[tokio::test(start_paused = true)]
356    async fn partition_fails_at_once_and_heal_restores() {
357        let net = SimNet::new();
358        net.partition("a");
359        assert_eq!(net.transmit("a", None).await, Err(NetFault::Partitioned));
360        net.heal("a");
361        assert_eq!(net.transmit("a", None).await, Ok(()));
362        let faults: Vec<_> = net.events().iter().map(|event| event.fault).collect();
363        assert_eq!(faults, vec![NetFault::Partitioned, NetFault::None]);
364    }
365
366    #[tokio::test(start_paused = true)]
367    async fn an_attempt_dropped_during_its_latency_leaves_no_event() {
368        let net = SimNet::new().latency(Duration::from_millis(10), Duration::from_millis(10));
369        let outcome = crate::sim::crash_at(0, net.transmit("h", None)).await;
370        assert!(outcome.is_crashed());
371        assert!(net.events().is_empty(), "the host was never reached");
372        assert_eq!(net.transmit("h", None).await, Ok(()));
373        assert_eq!(net.events().len(), 1);
374        assert_eq!(net.events()[0].seq, 0);
375    }
376
377    #[tokio::test(start_paused = true)]
378    async fn same_seed_same_decisions() {
379        let run = |seed| async move {
380            let net = SimNet::new()
381                .drop_rate(0.5)
382                .latency(Duration::ZERO, Duration::from_millis(30));
383            net.reseed(seed);
384            for _ in 0..32 {
385                let _ = net.transmit("h", None).await;
386            }
387            net.events()
388        };
389        assert_eq!(run(1).await, run(1).await);
390        assert_ne!(run(1).await, run(2).await);
391    }
392}