Skip to main content

kithara_net/
types.rs

1use std::{cmp::min, collections::HashMap, fmt, time::Duration};
2
3use bitflags::bitflags;
4use bon::Builder;
5
6bitflags! {
7    /// HTTP `Accept-Encoding` algorithms the client advertises and is
8    /// willing to decode. Reqwest auto-adds the corresponding
9    /// `Accept-Encoding` header for every algorithm whose flag is set;
10    /// the rest are disabled via `ClientBuilder::no_*` so the wire
11    /// header stays in lockstep with this set.
12    ///
13    /// Subset selection matters when talking to anti-bot WAFs that
14    /// fingerprint clients by their exact `Accept-Encoding` value.
15    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
16    pub struct Compression: u8 {
17        const GZIP    = 1 << 0;
18        const DEFLATE = 1 << 1;
19        const BROTLI  = 1 << 2;
20        const ZSTD    = 1 << 3;
21    }
22}
23
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
25pub struct Headers {
26    inner: HashMap<String, String>,
27}
28
29impl Headers {
30    #[must_use]
31    // ast-grep-ignore: style.prefer-default-derive
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    pub fn get(&self, key: &str) -> Option<&str> {
37        self.inner.get(key).map(String::as_str)
38    }
39
40    pub fn insert<K: Into<String>, V: Into<String>>(&mut self, key: K, value: V) {
41        self.inner.insert(key.into(), value.into());
42    }
43
44    #[must_use]
45    pub fn is_empty(&self) -> bool {
46        self.inner.is_empty()
47    }
48
49    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
50        self.inner.iter().map(|(k, v)| (k.as_str(), v.as_str()))
51    }
52}
53
54impl From<HashMap<String, String>> for Headers {
55    fn from(map: HashMap<String, String>) -> Self {
56        Self { inner: map }
57    }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct RangeSpec {
62    pub end: Option<u64>,
63    pub start: u64,
64}
65
66impl RangeSpec {
67    #[must_use]
68    pub fn new(start: u64, end: Option<u64>) -> Self {
69        Self { end, start }
70    }
71}
72
73impl fmt::Display for RangeSpec {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self.end {
76            Some(end) => write!(f, "bytes={}-{}", self.start, end),
77            None => write!(f, "bytes={}-", self.start),
78        }
79    }
80}
81
82#[derive(Clone, Debug)]
83#[non_exhaustive]
84pub struct RetryPolicy {
85    pub base_delay: Duration,
86    pub max_delay: Duration,
87    pub max_retries: u32,
88}
89
90impl Default for RetryPolicy {
91    fn default() -> Self {
92        Self {
93            base_delay: Duration::from_millis(100),
94            max_delay: Duration::from_secs(5),
95            max_retries: 3,
96        }
97    }
98}
99
100impl RetryPolicy {
101    #[must_use]
102    pub fn new(max_retries: u32, base_delay: Duration, max_delay: Duration) -> Self {
103        Self {
104            base_delay,
105            max_delay,
106            max_retries,
107        }
108    }
109
110    #[must_use]
111    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
112        const BACKOFF_BASE: u32 = 2;
113
114        if attempt == 0 {
115            return Duration::ZERO;
116        }
117        let exponential_delay = self.base_delay * BACKOFF_BASE.pow(attempt.saturating_sub(1));
118        min(exponential_delay, self.max_delay)
119    }
120}
121
122#[derive(Clone, Debug, Builder)]
123#[non_exhaustive]
124pub struct NetOptions {
125    /// `Accept-Encoding` algorithms the client offers and auto-decodes.
126    /// Defaults to all four (`gzip | deflate | brotli | zstd`); narrow it
127    /// when an upstream rejects the full set (anti-bot WAFs that
128    /// fingerprint on the exact `Accept-Encoding` string are a common
129    /// reason).
130    #[builder(default = Compression::all())]
131    pub compression: Compression,
132    /// Maximum allowed inactivity between consecutive read operations.
133    /// Maps to [`reqwest::ClientBuilder::read_timeout`] (documented as
134    /// "The timeout applies to each read operation, and resets after a
135    /// successful read") and also drives the Downloader-layer
136    /// `BodyStream` chunk-inactivity guard for the same semantics one
137    /// layer down.
138    ///
139    /// Protects against zombie connections that send headers but then
140    /// stop streaming bytes. Does **not** cap the total request
141    /// lifetime; a legitimately slow stream that keeps delivering
142    /// chunks (even one byte every few seconds) is not aborted.
143    /// Default 30s is sized to absorb realistic mobile-network stalls
144    /// (TCP retransmits, captive-portal warm-up, server-side TTFB
145    /// spikes) without aborting valid slow streams — the player's
146    /// contract is "wait for the segment, regardless of connection
147    /// speed", and a 10s cap raced real fixtures.
148    #[builder(default = Duration::from_secs(30))]
149    pub inactivity_timeout: Duration,
150    /// Hard cap on total request lifetime. Maps to
151    /// [`reqwest::RequestBuilder::timeout`]. `None` lets streaming
152    /// downloads run indefinitely as long as `inactivity_timeout` is
153    /// satisfied — required for the player to honour "wait for the
154    /// segment, regardless of connection speed". Default `Some(2 min)`
155    /// keeps a safety net against pathological cases (server stuck in
156    /// mid-body without ever closing) while not racing realistic
157    /// slow-network seeks.
158    pub total_timeout: Option<Duration>,
159    #[builder(default)]
160    pub retry_policy: RetryPolicy,
161    /// Accept invalid TLS certificates (self-signed, expired, wrong hostname).
162    /// **Security risk** — use only for local development and test servers.
163    #[builder(default)]
164    pub is_insecure: bool,
165    /// Max idle connections per host. Enables HTTP keep-alive connection
166    /// reuse, reducing `TIME_WAIT` accumulation under high request volume.
167    /// Set to 0 to disable pooling.
168    #[builder(default = 8)]
169    pub pool_max_idle_per_host: usize,
170}
171
172impl Default for NetOptions {
173    fn default() -> Self {
174        Self::builder()
175            .total_timeout(Duration::from_secs(120))
176            .build()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    mod kithara {
183        pub(crate) use kithara_test_macros::test;
184    }
185
186    use super::*;
187
188    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
189    #[case::empty_headers(Headers::new(), true)]
190    #[case::headers_with_values({
191        let mut h = Headers::new();
192        h.insert("key1", "value1");
193        h.insert("key2", "value2");
194        h
195    }, false)]
196    async fn test_headers_is_empty(#[case] headers: Headers, #[case] expected_empty: bool) {
197        assert_eq!(headers.is_empty(), expected_empty);
198    }
199
200    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
201    #[case::simple_key("key1", "value1")]
202    #[case::content_type("Content-Type", "application/json")]
203    #[case::custom_header("X-Custom-Header", "custom-value")]
204    async fn test_headers_insert_and_get(#[case] key: &str, #[case] value: &str) {
205        let mut headers = Headers::new();
206        headers.insert(key, value);
207
208        assert_eq!(headers.get(key), Some(value));
209        assert_eq!(headers.get("non-existent"), None);
210    }
211
212    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
213    async fn test_headers_iter() {
214        let mut headers = Headers::new();
215        headers.insert("key1", "value1");
216        headers.insert("key2", "value2");
217        headers.insert("key3", "value3");
218
219        let mut iterated = HashMap::new();
220        for (k, v) in headers.iter() {
221            iterated.insert(k.to_string(), v.to_string());
222        }
223
224        assert_eq!(iterated.len(), 3);
225        assert_eq!(iterated.get("key1"), Some(&"value1".to_string()));
226        assert_eq!(iterated.get("key2"), Some(&"value2".to_string()));
227        assert_eq!(iterated.get("key3"), Some(&"value3".to_string()));
228    }
229
230    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
231    async fn test_headers_from_hashmap() {
232        let mut map = HashMap::new();
233        map.insert("key1".to_string(), "value1".to_string());
234        map.insert("key2".to_string(), "value2".to_string());
235
236        let headers: Headers = map.into();
237
238        assert!(!headers.is_empty());
239        assert_eq!(headers.get("key1"), Some("value1"));
240        assert_eq!(headers.get("key2"), Some("value2"));
241    }
242
243    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
244    async fn test_headers_default() {
245        let headers = Headers::default();
246        assert!(headers.is_empty());
247    }
248
249    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
250    #[case::full_range(0, Some(100), "bytes=0-100")]
251    #[case::open_ended(50, None, "bytes=50-")]
252    #[case::single_byte(10, Some(10), "bytes=10-10")]
253    #[case::zero_length(0, Some(0), "bytes=0-0")]
254    async fn test_range_spec_to_header_value(
255        #[case] start: u64,
256        #[case] end: Option<u64>,
257        #[case] expected_header: &str,
258    ) {
259        let range = RangeSpec::new(start, end);
260        assert_eq!(range.to_string(), expected_header);
261    }
262
263    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
264    #[case::equal_ranges(RangeSpec::new(0, Some(100)), RangeSpec::new(0, Some(100)), true)]
265    #[case::different_starts(RangeSpec::new(0, Some(100)), RangeSpec::new(1, Some(100)), false)]
266    #[case::different_ends(RangeSpec::new(0, Some(100)), RangeSpec::new(0, Some(99)), false)]
267    #[case::one_open_ended(RangeSpec::new(0, None), RangeSpec::new(0, None), true)]
268    #[case::mixed_ends(RangeSpec::new(0, Some(100)), RangeSpec::new(0, None), false)]
269    async fn test_range_spec_partial_eq(
270        #[case] range1: RangeSpec,
271        #[case] range2: RangeSpec,
272        #[case] expected_equal: bool,
273    ) {
274        assert_eq!(range1 == range2, expected_equal);
275    }
276
277    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
278    async fn test_range_spec_debug() {
279        let range = RangeSpec::new(10, Some(20));
280        let debug_output = format!("{:?}", range);
281        assert!(debug_output.contains("RangeSpec"));
282        assert!(debug_output.contains("start: 10"));
283        assert!(debug_output.contains("end: Some(20)"));
284    }
285
286    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
287    async fn test_range_spec_clone() {
288        let range1 = RangeSpec::new(10, Some(20));
289        let range2 = range1.clone();
290
291        assert_eq!(range1, range2);
292        assert_eq!(range1.start, range2.start);
293        assert_eq!(range1.end, range2.end);
294    }
295
296    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
297    async fn test_retry_policy_default() {
298        let policy = RetryPolicy::default();
299
300        assert_eq!(policy.max_retries, 3);
301        assert_eq!(policy.base_delay, Duration::from_millis(100));
302        assert_eq!(policy.max_delay, Duration::from_secs(5));
303    }
304
305    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
306    #[case(1, Duration::from_millis(50), Duration::from_secs(1))]
307    #[case(5, Duration::from_millis(100), Duration::from_secs(2))]
308    #[case(10, Duration::from_millis(200), Duration::from_secs(10))]
309    async fn test_retry_policy_new(
310        #[case] max_retries: u32,
311        #[case] base_delay: Duration,
312        #[case] max_delay: Duration,
313    ) {
314        let policy = RetryPolicy::new(max_retries, base_delay, max_delay);
315
316        assert_eq!(policy.max_retries, max_retries);
317        assert_eq!(policy.base_delay, base_delay);
318        assert_eq!(policy.max_delay, max_delay);
319    }
320
321    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
322    #[case(0, Duration::ZERO)]
323    #[case(1, Duration::from_millis(100))]
324    #[case(2, Duration::from_millis(200))]
325    #[case(3, Duration::from_millis(400))]
326    #[case(4, Duration::from_millis(800))]
327    #[case(5, Duration::from_millis(1600))]
328    #[case(10, Duration::from_secs(5))]
329    #[case(20, Duration::from_secs(5))]
330    async fn test_retry_policy_delay_for_attempt_default(
331        #[case] attempt: u32,
332        #[case] expected_delay: Duration,
333    ) {
334        let policy = RetryPolicy::default();
335        let delay = policy.delay_for_attempt(attempt);
336
337        assert_eq!(delay, expected_delay);
338    }
339
340    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
341    #[case(
342        1,
343        Duration::from_millis(50),
344        Duration::from_millis(200),
345        0,
346        Duration::ZERO
347    )]
348    #[case(
349        1,
350        Duration::from_millis(50),
351        Duration::from_millis(200),
352        1,
353        Duration::from_millis(50)
354    )]
355    #[case(
356        1,
357        Duration::from_millis(50),
358        Duration::from_millis(200),
359        2,
360        Duration::from_millis(100)
361    )]
362    #[case(
363        1,
364        Duration::from_millis(50),
365        Duration::from_millis(200),
366        3,
367        Duration::from_millis(200)
368    )]
369    #[case(
370        1,
371        Duration::from_millis(50),
372        Duration::from_millis(200),
373        4,
374        Duration::from_millis(200)
375    )]
376    async fn test_retry_policy_delay_for_attempt_custom(
377        #[case] max_retries: u32,
378        #[case] base_delay: Duration,
379        #[case] max_delay: Duration,
380        #[case] attempt: u32,
381        #[case] expected_delay: Duration,
382    ) {
383        let policy = RetryPolicy::new(max_retries, base_delay, max_delay);
384        let delay = policy.delay_for_attempt(attempt);
385
386        assert_eq!(delay, expected_delay);
387    }
388
389    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
390    async fn test_retry_policy_debug() {
391        let policy = RetryPolicy::default();
392        let debug_output = format!("{:?}", policy);
393
394        assert!(debug_output.contains("RetryPolicy"));
395        assert!(debug_output.contains("max_retries: 3"));
396        assert!(debug_output.contains("base_delay"));
397        assert!(debug_output.contains("max_delay"));
398    }
399
400    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
401    async fn test_retry_policy_clone() {
402        let policy1 = RetryPolicy::new(5, Duration::from_millis(100), Duration::from_secs(2));
403        let policy2 = policy1.clone();
404
405        assert_eq!(policy1.max_retries, policy2.max_retries);
406        assert_eq!(policy1.base_delay, policy2.base_delay);
407        assert_eq!(policy1.max_delay, policy2.max_delay);
408    }
409
410    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
411    #[case::start_equals_end(10, Some(10), "bytes=10-10")]
412    #[case::start_greater_than_end(20, Some(10), "bytes=20-10")]
413    #[case::max_values(u64::MAX, Some(u64::MAX), &format!("bytes={}-{}", u64::MAX, u64::MAX))]
414    async fn test_range_spec_edge_cases(
415        #[case] start: u64,
416        #[case] end: Option<u64>,
417        #[case] expected_header: &str,
418    ) {
419        let range = RangeSpec::new(start, end);
420        assert_eq!(range.to_string(), expected_header);
421    }
422
423    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
424    #[case::zero_max_retries(0, Duration::from_millis(100), Duration::from_secs(1))]
425    #[case::large_max_retries(100, Duration::from_millis(10), Duration::from_secs(10))]
426    #[case::zero_base_delay(3, Duration::ZERO, Duration::from_secs(1))]
427    #[case::zero_max_delay(3, Duration::from_millis(100), Duration::ZERO)]
428    async fn test_retry_policy_edge_cases(
429        #[case] max_retries: u32,
430        #[case] base_delay: Duration,
431        #[case] max_delay: Duration,
432    ) {
433        let policy = RetryPolicy::new(max_retries, base_delay, max_delay);
434
435        for attempt in 0..=5 {
436            let delay = policy.delay_for_attempt(attempt);
437
438            assert!(delay >= Duration::ZERO);
439
440            assert!(delay <= max_delay);
441
442            if base_delay == Duration::ZERO {
443                assert_eq!(delay, Duration::ZERO);
444            }
445        }
446    }
447
448    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
449    #[case(10)]
450    #[case(20)]
451    async fn test_retry_policy_large_attempts(#[case] attempt: u32) {
452        let policy = RetryPolicy::default();
453
454        let delay = policy.delay_for_attempt(attempt);
455
456        assert!(delay <= policy.max_delay);
457        assert!(delay >= Duration::ZERO);
458    }
459
460    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
461    #[case::with_spaces("X-Custom Header", "value with spaces")]
462    #[case::with_unicode("X-Emoji", "🎉")]
463    #[case::with_special_chars("X-Special", "a\tb\nc")]
464    #[case::empty_value("X-Empty", "")]
465    async fn test_headers_special_characters(#[case] key: &str, #[case] value: &str) {
466        let mut headers = Headers::new();
467        headers.insert(key, value);
468
469        assert_eq!(headers.get(key), Some(value));
470    }
471
472    #[kithara::test(tokio, timeout(Duration::from_secs(5)))]
473    async fn test_headers_case_sensitive() {
474        let mut headers = Headers::new();
475        headers.insert("Content-Type", "application/json");
476        headers.insert("content-type", "text/plain");
477
478        assert_eq!(headers.get("Content-Type"), Some("application/json"));
479        assert_eq!(headers.get("content-type"), Some("text/plain"));
480        assert_ne!(headers.get("Content-Type"), headers.get("content-type"));
481    }
482}