Skip to main content

hls_runtime/client/
tokio_client.rs

1//! `TokioClient` — a tokio + reqwest IO adapter driving
2//! [`crate::client::HlsClient`] over real HTTP (issue #717 slice 5).
3//!
4//! Feature-gated behind `tokio` (NOT default): the sans-IO core in
5//! `engine.rs` has zero dependency on tokio, reqwest, a socket, or a clock —
6//! this module is a thin async shell that performs the actual HTTP GETs the
7//! core's [`crate::client::Action`]s describe (playlist reload, incl. blocking
8//! `_HLS_msn`/`_HLS_part`; resource fetch, incl. `Range` byte-ranges) and
9//! feeds the responses back into the core, looping until the caller stops
10//! polling or the stream ends.
11//!
12//! # Auth
13//!
14//! [`TokioClientConfig::auth`] takes a [`broadcast_auth::Credentials`] — the
15//! same shared scheme-agnostic model `rtsp-runtime` and `multimux`'s HTTP
16//! input adapters (`source::http_auth`) use (issue #663 P3b/P3c). Basic
17//! (RFC 7617) and Bearer (RFC 6750) are pre-applied on every request to the
18//! playlist URL's origin (scheme + host + port, RFC 6454) via
19//! reqwest's own request-builder helpers (`RequestBuilder::basic_auth`/
20//! `bearer_auth`) — no challenge round-trip needed. Digest (RFC 7616) is not
21//! something reqwest supports natively: the first request for a given
22//! resource is sent bare, and only if the server answers `401` with a
23//! `WWW-Authenticate` challenge does [`TokioClient`] compute the
24//! `Authorization` response via [`broadcast_auth::Authenticator`] and resend
25//! once — the resulting authenticator is then cached and applied
26//! preemptively to every subsequent request for as long as it keeps being
27//! accepted (RFC 7616 §3.3's `nc` advances across those calls), so a live
28//! pull doesn't round-trip a fresh challenge on every single fetch.
29//!
30//! Credentials of any scheme go only to that origin: a playlist naming a
31//! resource on another host (a CDN, say) gets that request sent bare, and a
32//! `401` from such a host is not answered.
33//!
34//! # Error recovery
35//!
36//! - Retry delays come from `backon`'s exponential schedule (minimum
37//!   [`TokioClientConfig::retry_backoff`], doubling, capped at
38//!   [`TokioClientConfig::max_retry_backoff`]) with optional jitter
39//!   ([`TokioClientConfig::jitter`]: each delay `d` becomes a random value in
40//!   `[d, 2d)`, clamped to `max_retry_backoff`, so many clients reloading
41//!   together do not retry in lock step but no delay ever exceeds the cap).
42//! - A **resource** (init/part/segment) fetch that keeps failing is retried
43//!   up to [`TokioClientConfig::max_resource_retries`] times (total attempts)
44//!   with that schedule, then [`crate::client::HlsClient::on_error`] is
45//!   called and the adapter moves on — the sans-IO core un-marks that
46//!   resource as "requested", so the *next* playlist reload naturally
47//!   re-requests it (see `engine.rs`'s `on_error` docs). One flaky fetch
48//!   never stalls the whole client. A `4xx` answer (other than `408`/`429`)
49//!   is not retried — e.g. RFC 8216bis §6.2.6's `404` for a stale preload
50//!   hint — and goes straight to `on_error`.
51//! - A **playlist** reload has no such fallback in the sans-IO core — unlike
52//!   a resource, [`crate::client::HlsClient::on_error`] with `None` does not
53//!   re-queue anything (there is no "next reload" to fall back to; the
54//!   *current* reload IS the mechanism that discovers what to fetch next).
55//!   So a playlist fetch is retried indefinitely with capped backoff
56//!   ([`TokioClientConfig::retry_backoff`]/[`TokioClientConfig::max_retry_backoff`])
57//!   rather than ever giving up — a caller wanting a hard ceiling on how long
58//!   [`TokioClient::next_output`] may block should wrap it in
59//!   `tokio::time::timeout` itself.
60//!
61//! # Cancellation
62//!
63//! [`TokioClientConfig::cancel`] is a `CancellationToken`: once cancelled,
64//! [`TokioClient::next_output`] abandons whatever request or backoff sleep is
65//! in flight and returns `Ok(None)` (the same "stop polling" signal as the end
66//! of the stream). There is no other shutdown mechanism and no polling sleep.
67
68use std::time::Duration;
69
70use backon::{BackoffBuilder, ExponentialBuilder};
71use broadcast_auth::{Authenticator, Credentials, RequestContext};
72use headers::HeaderMapExt;
73use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
74use reqwest::{Client, StatusCode};
75use tokio_util::sync::CancellationToken;
76
77use super::{Action, HlsClient, Output, ResourceId};
78
79/// Errors from the tokio IO adapter itself — distinct from
80/// [`crate::client::Error`], the sans-IO core's own parse/demux error type
81/// (wrapped here via
82/// [`TokioError::Client`]).
83#[derive(Debug, thiserror::Error)]
84#[non_exhaustive]
85pub enum TokioError {
86    /// The underlying HTTP request failed outright (connect/timeout/TLS/
87    /// transport error) — never a non-2xx response, see [`Self::Status`].
88    #[error("HTTP request to {url} failed: {source}")]
89    Http {
90        /// The request URL.
91        url: String,
92        /// The underlying reqwest error.
93        #[source]
94        source: reqwest::Error,
95    },
96    /// The server returned a non-success HTTP status.
97    #[error("HTTP {status} fetching {url}")]
98    Status {
99        /// The request URL.
100        url: String,
101        /// The response status.
102        status: reqwest::StatusCode,
103    },
104    /// The sans-IO core rejected the fetched playlist/resource — see
105    /// [`crate::client::Error`] for the underlying reason.
106    #[error(transparent)]
107    Client(#[from] super::Error),
108    /// A `401`'s `WWW-Authenticate` challenge could not be parsed, or no
109    /// `Authorization` response could be computed from it (e.g. an
110    /// unsupported Digest `algorithm`/`qop`) — see [`broadcast_auth::Error`].
111    #[error("auth challenge/response failed: {0}")]
112    Auth(#[from] broadcast_auth::Error),
113    /// Building the `Range` header for `url` would overflow `u64`
114    /// (`offset + (length - 1)`, RFC 8216bis §4.4.4.9). In normal operation
115    /// this never fires: [`crate::client::HlsClient`]'s internal
116    /// `resolve_byte_range` already rejects any byte range whose
117    /// `offset + length` overflows before it ever reaches this adapter (see
118    /// `crate::client::Error::ByteRangeOverflow`) — this variant is
119    /// defense-in-depth against a future/foreign caller constructing a
120    /// `(u64, u64)` byte range directly, bypassing the sans-IO core.
121    #[error("byte range for {url} overflows u64: offset {offset} + length {length}")]
122    ByteRangeOverflow {
123        /// The request URL the range applies to.
124        url: String,
125        /// The range's starting offset.
126        offset: u64,
127        /// The range's length.
128        length: u64,
129    },
130    /// The sans-IO core had nothing queued to do and the stream had not ended.
131    /// In normal operation `on_playlist` always queues a reload (or a wait)
132    /// before returning, so this signals a core bug rather than a network
133    /// condition; it replaces a 10 ms defensive sleep that used to hide it.
134    #[error("the HLS core queued no action and the stream has not ended")]
135    Stalled,
136    /// [`TokioClientConfig::cancel`] fired while a request was in flight.
137    /// Internal: [`TokioClient::next_output`] converts it to `Ok(None)`.
138    #[doc(hidden)]
139    #[error("cancelled")]
140    Cancelled,
141}
142
143/// Tunables for [`TokioClient`]. [`Default`] gives sane values for a
144/// well-behaved LL-HLS origin reachable over a real (or loopback) network.
145#[derive(Debug, Clone)]
146pub struct TokioClientConfig {
147    /// Per-request timeout for a plain (non-blocking) playlist GET, or a
148    /// resource (init/part/segment) GET.
149    pub request_timeout: Duration,
150    /// Per-request timeout for a **blocking** Playlist Reload
151    /// (`_HLS_msn`/`_HLS_part`, RFC 8216bis §6.2.5.2) — must exceed the
152    /// origin's own blocking hold time (e.g. `multimux`'s `LlHlsOutput` caps
153    /// at 5s) with headroom, since the origin is expected to hold the
154    /// response open until new content exists or its own cap elapses.
155    pub blocking_timeout: Duration,
156    /// How many times a **resource** fetch is retried (capped exponential
157    /// backoff) before the adapter gives up on that specific fetch and moves
158    /// on (see the module docs' "Error recovery" section). A playlist reload
159    /// is never subject to this cap — it retries indefinitely.
160    pub max_resource_retries: u32,
161    /// Initial backoff between retry attempts (`backon`'s minimum delay);
162    /// doubles per attempt up to [`Self::max_retry_backoff`].
163    pub retry_backoff: Duration,
164    /// Ceiling the doubled [`Self::retry_backoff`] is capped at.
165    pub max_retry_backoff: Duration,
166    /// Optional auth attached to every request to the playlist URL's origin
167    /// (see the module docs).
168    pub auth: Option<Credentials>,
169    /// TCP connect timeout (reqwest's `connect_timeout`). Default 10 s.
170    pub connect_timeout: Duration,
171    /// Add `backon` jitter to every retry delay (each delay `d` becomes a
172    /// random value in `[d, 2d)`, clamped to [`Self::max_retry_backoff`], which
173    /// stays a hard maximum). Default `true`.
174    pub jitter: bool,
175    /// Cancelling this token makes [`TokioClient::next_output`] abandon its
176    /// in-flight request or backoff sleep and return `Ok(None)`.
177    pub cancel: CancellationToken,
178}
179
180impl TokioClientConfig {
181    /// Set [`Self::auth`].
182    #[must_use]
183    pub fn with_auth(mut self, auth: Credentials) -> Self {
184        self.auth = Some(auth);
185        self
186    }
187
188    /// Set [`Self::cancel`].
189    #[must_use]
190    pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
191        self.cancel = cancel;
192        self
193    }
194
195    /// Set [`Self::connect_timeout`].
196    #[must_use]
197    pub fn with_connect_timeout(mut self, d: Duration) -> Self {
198        self.connect_timeout = d;
199        self
200    }
201
202    /// Set [`Self::jitter`].
203    #[must_use]
204    pub fn with_jitter(mut self, on: bool) -> Self {
205        self.jitter = on;
206        self
207    }
208}
209
210impl Default for TokioClientConfig {
211    fn default() -> Self {
212        Self {
213            request_timeout: Duration::from_secs(5),
214            blocking_timeout: Duration::from_secs(10),
215            max_resource_retries: 3,
216            retry_backoff: Duration::from_millis(200),
217            max_retry_backoff: Duration::from_secs(2),
218            auth: None,
219            connect_timeout: Duration::from_secs(10),
220            jitter: true,
221            cancel: CancellationToken::new(),
222        }
223    }
224}
225
226/// Diagnostic counters for what [`TokioClient`] has actually done —
227/// distinguishing "parsed an LL-HLS tag" from "acted on it", the same bar
228/// this crate's own acceptance tests hold the adapter to (issue #717's
229/// "blocking-reload + preload-hint prefetch actually exercised" acceptance
230/// item). Also useful to a real caller for observability.
231#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
232pub struct TokioClientStats {
233    /// Playlist GETs performed (blocking + non-blocking).
234    pub playlist_fetches: u64,
235    /// Of those, how many carried `_HLS_msn`/`_HLS_part` — a Blocking
236    /// Playlist Reload (RFC 8216bis §6.2.5.2).
237    pub blocking_reloads: u64,
238    /// Resource (init/part/segment) GETs performed.
239    pub resource_fetches: u64,
240    /// Of those, how many were for the exact URL most recently named by the
241    /// playlist's `#EXT-X-PRELOAD-HINT` (RFC 8216bis §4.4.5.3) — i.e.
242    /// fetched ahead of that resource's own numbered (`#EXT-X-PART`)
243    /// appearance, not merely alongside it.
244    pub preload_hint_resource_fetches: u64,
245}
246
247/// An async shell driving [`crate::client::HlsClient`] over real HTTP.
248///
249/// ```no_run
250/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
251/// use hls_runtime::client::tokio_client::TokioClient;
252///
253/// let mut client = TokioClient::new("http://127.0.0.1:8080/live/media.m3u8")?;
254/// while let Some(output) = client.next_output().await? {
255///     // Hand `output` (Init / Samples / Discontinuity / EndOfStream) to a decoder.
256///     let _ = output;
257/// }
258/// # Ok(())
259/// # }
260/// ```
261pub struct TokioClient {
262    core: HlsClient,
263    http: Client,
264    config: TokioClientConfig,
265    playlist_url: String,
266    /// `playlist_url` parsed, for the same-origin check on credentials;
267    /// `None` if it does not parse, in which case no credentials are sent.
268    playlist_origin_url: Option<reqwest::Url>,
269    stats: TokioClientStats,
270    last_preload_hint_url: Option<String>,
271    ended: bool,
272    /// Cached from the most recent Digest `401` challenge/response (see the
273    /// module docs' "Auth" section) — `None` until the first Digest
274    /// challenge is answered, or when [`TokioClientConfig::auth`] isn't
275    /// [`Credentials::Digest`]. Applied preemptively to every subsequent
276    /// request once set, advancing `nc` (RFC 7616 §3.3) each time.
277    digest_authenticator: Option<Authenticator>,
278}
279
280impl TokioClient {
281    /// Build a client for the Media Playlist at `playlist_url`, with
282    /// [`TokioClientConfig::default`] tunables.
283    ///
284    /// # Errors
285    /// Only if the underlying `reqwest::Client` fails to build (e.g. TLS
286    /// backend initialisation failure) — not a network error, since no
287    /// request has been made yet.
288    pub fn new(playlist_url: impl Into<String>) -> Result<Self, TokioError> {
289        Self::with_config(playlist_url, TokioClientConfig::default())
290    }
291
292    /// Build a client with explicit [`TokioClientConfig`] tunables.
293    ///
294    /// # Errors
295    /// See [`Self::new`].
296    pub fn with_config(
297        playlist_url: impl Into<String>,
298        config: TokioClientConfig,
299    ) -> Result<Self, TokioError> {
300        let playlist_url = playlist_url.into();
301        let http = Client::builder()
302            .connect_timeout(config.connect_timeout)
303            .build()
304            .map_err(|source| TokioError::Http {
305                url: playlist_url.clone(),
306                source,
307            })?;
308        Ok(Self {
309            core: HlsClient::new(playlist_url.clone()),
310            http,
311            config,
312            playlist_origin_url: reqwest::Url::parse(&playlist_url).ok(),
313            playlist_url,
314            stats: TokioClientStats::default(),
315            last_preload_hint_url: None,
316            ended: false,
317            digest_authenticator: None,
318        })
319    }
320
321    /// Diagnostic counters for requests actually made so far — see
322    /// [`TokioClientStats`].
323    pub fn stats(&self) -> TokioClientStats {
324        self.stats
325    }
326
327    /// Drive the sans-IO core — performing whatever HTTP its next
328    /// [`Action`] needs, retrying transient failures per
329    /// [`TokioClientConfig`] — until at least one [`Output`] is available.
330    ///
331    /// Returns `Ok(None)` once, right after [`Output::EndOfStream`] has
332    /// already been yielded by a previous call, signalling the caller to
333    /// stop polling. A live stream that never sends `#EXT-X-ENDLIST` simply
334    /// never returns `Ok(None)` — the caller drives this in a loop (or a
335    /// `tokio::select!` alongside its own shutdown signal) for as long as it
336    /// wants to keep playing.
337    ///
338    /// # Errors
339    /// [`TokioError::Client`] if the sans-IO core rejects a fetched
340    /// playlist/resource (malformed playlist, demux failure). Resource fetch
341    /// failures are retried/recovered internally (see the module docs) and
342    /// never surface here; a playlist fetch failure retries indefinitely
343    /// rather than ever returning an [`TokioError::Http`]/[`TokioError::Status`]
344    /// — see the module docs' "Error recovery" section.
345    pub async fn next_output(&mut self) -> Result<Option<Output>, TokioError> {
346        loop {
347            if self.config.cancel.is_cancelled() {
348                return Ok(None);
349            }
350            if let Some(out) = self.core.next_output() {
351                if matches!(out, Output::EndOfStream) {
352                    self.ended = true;
353                }
354                return Ok(Some(out));
355            }
356            if self.ended {
357                return Ok(None);
358            }
359
360            match self.core.poll() {
361                Some(action @ Action::FetchPlaylist { .. }) => {
362                    let request_url = action
363                        .playlist_request_url()
364                        .expect("Action::FetchPlaylist always has a playlist_request_url");
365                    let is_blocking = matches!(
366                        action,
367                        Action::FetchPlaylist {
368                            blocking: Some(_),
369                            ..
370                        }
371                    );
372                    let timeout = if is_blocking {
373                        self.config.blocking_timeout
374                    } else {
375                        self.config.request_timeout
376                    };
377                    let bytes = match self.fetch_playlist_resilient(&request_url, timeout).await {
378                        Ok(bytes) => bytes,
379                        Err(TokioError::Cancelled) => return Ok(None),
380                        Err(other) => return Err(other),
381                    };
382                    self.stats.playlist_fetches += 1;
383                    if is_blocking {
384                        self.stats.blocking_reloads += 1;
385                    }
386                    self.note_preload_hint(&bytes);
387                    self.core.on_playlist(&bytes)?;
388                }
389                Some(Action::FetchResource {
390                    id,
391                    url,
392                    byte_range,
393                }) => match self.fetch_resource_bounded(&url, byte_range).await {
394                    Ok(bytes) => {
395                        self.stats.resource_fetches += 1;
396                        if matches!(id, ResourceId::Part { .. })
397                            && self.last_preload_hint_url.as_deref() == Some(url.as_str())
398                        {
399                            self.stats.preload_hint_resource_fetches += 1;
400                        }
401                        self.core.on_resource(id, &bytes)?;
402                    }
403                    Err(TokioError::Cancelled) => return Ok(None),
404                    Err(_source) => {
405                        // Retries exhausted: un-mark as requested so the
406                        // next playlist reload naturally re-requests it,
407                        // rather than stalling the whole client on one bad
408                        // fetch.
409                        self.core.on_error(Some(id));
410                    }
411                },
412                Some(Action::WaitMs(ms)) => {
413                    if self.sleep_or_cancelled(Duration::from_millis(ms)).await {
414                        return Ok(None);
415                    }
416                }
417                // In normal operation `on_playlist` always re-queues a reload
418                // (or `WaitMs`) before returning for as long as the stream has
419                // not ended, so this is a core bug, not something to sleep on.
420                None => return Err(TokioError::Stalled),
421            }
422        }
423    }
424
425    /// Sleeps `d` or until [`TokioClientConfig::cancel`] fires; `true` means
426    /// cancelled.
427    async fn sleep_or_cancelled(&self, d: Duration) -> bool {
428        tokio::select! {
429            () = tokio::time::sleep(d) => false,
430            () = self.config.cancel.cancelled() => true,
431        }
432    }
433
434    /// Re-parse a just-fetched playlist purely to note its
435    /// `#EXT-X-PRELOAD-HINT` URL for [`TokioClientStats`] bookkeeping — the
436    /// sans-IO core does its own, authoritative parse independently inside
437    /// [`crate::client::HlsClient::on_playlist`]; this is a deliberate, small,
438    /// side-channel duplication (stats only, never fed back into scheduling)
439    /// rather than growing an API on the core to expose its parsed state.
440    fn note_preload_hint(&mut self, playlist_bytes: &[u8]) {
441        self.last_preload_hint_url = core::str::from_utf8(playlist_bytes)
442            .ok()
443            .and_then(|text| broadcast_hls::MediaPlaylist::parse(text).ok())
444            .and_then(|pl| pl.low_latency)
445            .and_then(|ll| ll.preload_hint_part)
446            .map(|hint| super::url::resolve(&self.playlist_url, &hint));
447    }
448
449    /// Applies whatever auth can be attached *before* sending — Basic/Bearer
450    /// always (they never need a challenge), plus a cached Digest
451    /// [`Authenticator`] once one exists (advancing `nc`). A `Digest`
452    /// config with no cached authenticator yet is left unauthenticated for
453    /// this attempt; [`Self::fetch_bytes`] answers the resulting `401`.
454    fn apply_auth_preemptive(
455        &mut self,
456        req: reqwest::RequestBuilder,
457        method: &str,
458        uri: &str,
459    ) -> reqwest::RequestBuilder {
460        if !self.is_playlist_origin(uri) {
461            return req;
462        }
463        match &self.config.auth {
464            Some(Credentials::Basic { username, password }) => {
465                req.basic_auth(username, Some(password))
466            }
467            Some(Credentials::Bearer { token }) => req.bearer_auth(token),
468            Some(_) => {
469                // `Credentials::Digest` (or any future non_exhaustive
470                // variant): no preemptive header without a cached
471                // authenticator from a prior challenge.
472                if let Some(auth) = self.digest_authenticator.as_mut()
473                    && let Ok(value) = auth.authorization(&RequestContext::new(method, uri))
474                {
475                    return req.header(AUTHORIZATION, value);
476                }
477                req
478            }
479            None => req,
480        }
481    }
482
483    /// Answers a `401` response by computing the `Authorization` value from
484    /// its `WWW-Authenticate` challenge (via [`broadcast_auth`]) and
485    /// resending once — only when [`TokioClientConfig::auth`] is
486    /// [`Credentials::Digest`] (Basic/Bearer are already pre-applied and a
487    /// `401` for those means wrong credentials, not a missing challenge
488    /// round-trip). The freshly built [`Authenticator`] is cached on
489    /// success so later requests apply it preemptively.
490    async fn retry_after_unauthorized(
491        &mut self,
492        method: &str,
493        uri: &str,
494        req: reqwest::RequestBuilder,
495        response: reqwest::Response,
496    ) -> Result<reqwest::Response, TokioError> {
497        if !self.is_playlist_origin(uri) {
498            return Ok(response);
499        }
500        let Some(creds @ Credentials::Digest { .. }) = self.config.auth.clone() else {
501            return Ok(response);
502        };
503        let Some(challenge) = response
504            .headers()
505            .get(WWW_AUTHENTICATE)
506            .and_then(|v| v.to_str().ok())
507            .map(str::to_string)
508        else {
509            return Ok(response);
510        };
511        let mut authenticator = Authenticator::from_challenge(&challenge, creds)?;
512        let value = authenticator.authorization(&RequestContext::new(method, uri))?;
513        self.digest_authenticator = Some(authenticator);
514        req.header(AUTHORIZATION, value)
515            .send()
516            .await
517            .map_err(|source| TokioError::Http {
518                url: uri.to_string(),
519                source,
520            })
521    }
522
523    /// Whether `url` has the same origin (scheme, host, port — RFC 6454 §4)
524    /// as the configured playlist URL; only such requests carry credentials.
525    fn is_playlist_origin(&self, url: &str) -> bool {
526        match (&self.playlist_origin_url, reqwest::Url::parse(url)) {
527            (Some(playlist), Ok(url)) => playlist.origin() == url.origin(),
528            _ => false,
529        }
530    }
531
532    /// [`Self::fetch_bytes_inner`], abandoned the moment
533    /// [`TokioClientConfig::cancel`] fires.
534    async fn fetch_bytes(
535        &mut self,
536        url: &str,
537        byte_range: Option<(u64, u64)>,
538        timeout: Duration,
539    ) -> Result<Vec<u8>, TokioError> {
540        let cancel = self.config.cancel.clone();
541        tokio::select! {
542            r = self.fetch_bytes_inner(url, byte_range, timeout) => r,
543            () = cancel.cancelled() => Err(TokioError::Cancelled),
544        }
545    }
546
547    async fn fetch_bytes_inner(
548        &mut self,
549        url: &str,
550        byte_range: Option<(u64, u64)>,
551        timeout: Duration,
552    ) -> Result<Vec<u8>, TokioError> {
553        let req = self.apply_auth_preemptive(
554            build_request(&self.http, url, byte_range, timeout)?,
555            "GET",
556            url,
557        );
558        let resp = req.send().await.map_err(|source| TokioError::Http {
559            url: url.to_string(),
560            source,
561        })?;
562
563        let resp = if resp.status() == StatusCode::UNAUTHORIZED {
564            let retry_req = build_request(&self.http, url, byte_range, timeout)?;
565            self.retry_after_unauthorized("GET", url, retry_req, resp)
566                .await?
567        } else {
568            resp
569        };
570
571        let status = resp.status();
572        if !status.is_success() {
573            return Err(TokioError::Status {
574                url: url.to_string(),
575                status,
576            });
577        }
578        let bytes = resp.bytes().await.map_err(|source| TokioError::Http {
579            url: url.to_string(),
580            source,
581        })?;
582        Ok(bytes.to_vec())
583    }
584
585    /// Retry a playlist fetch indefinitely (capped exponential backoff) —
586    /// see the module docs' "Error recovery" section for why a playlist
587    /// reload, unlike a resource fetch, has no bounded-retry fallback.
588    ///
589    /// A blocking or delta reload (`url` carries `_HLS_msn`/`_HLS_part`/
590    /// `_HLS_skip`) answered with a `4xx` that repeating cannot change — an
591    /// origin that restarted below the requested Media Sequence Number
592    /// answers `400` (RFC 8216bis §6.2.5.2) — is retried **once** at once as
593    /// a plain GET of the playlist URL instead of being repeated forever
594    /// (audit r09-C3, issue #1031); the plain URL then keeps the usual
595    /// backoff.
596    async fn fetch_playlist_resilient(
597        &mut self,
598        url: &str,
599        timeout: Duration,
600    ) -> Result<Vec<u8>, TokioError> {
601        let mut delays = schedule(&self.config, None);
602        let mut current = url.to_string();
603        loop {
604            match self.fetch_bytes(&current, None, timeout).await {
605                Ok(bytes) => return Ok(bytes),
606                Err(TokioError::Cancelled) => return Err(TokioError::Cancelled),
607                Err(source) if current != self.playlist_url && !is_retryable(&source) => {
608                    current.clone_from(&self.playlist_url);
609                }
610                Err(_source) => {
611                    // An unbounded schedule never ends; the fallback only
612                    // keeps this total.
613                    let d = delays.next().unwrap_or(self.config.max_retry_backoff);
614                    if self.sleep_or_cancelled(d).await {
615                        return Err(TokioError::Cancelled);
616                    }
617                }
618            }
619        }
620    }
621
622    /// Retry a resource fetch up to [`TokioClientConfig::max_resource_retries`]
623    /// times (capped exponential backoff), then give up.
624    async fn fetch_resource_bounded(
625        &mut self,
626        url: &str,
627        byte_range: Option<(u64, u64)>,
628    ) -> Result<Vec<u8>, TokioError> {
629        let attempts = usize::try_from(self.config.max_resource_retries.max(1)).unwrap_or(1);
630        // `attempts` total attempts means `attempts - 1` sleeps between them.
631        let mut delays = schedule(&self.config, Some(attempts - 1));
632        let mut last_err = None;
633        for _ in 0..attempts {
634            match self
635                .fetch_bytes(url, byte_range, self.config.request_timeout)
636                .await
637            {
638                Ok(bytes) => return Ok(bytes),
639                Err(source @ TokioError::Cancelled) => return Err(source),
640                Err(source) if !is_retryable(&source) => return Err(source),
641                Err(source) => {
642                    last_err = Some(source);
643                    // No sleep after the last attempt.
644                    let Some(d) = delays.next() else { break };
645                    if self.sleep_or_cancelled(d).await {
646                        return Err(TokioError::Cancelled);
647                    }
648                }
649            }
650        }
651        Err(last_err.expect("loop runs at least once (attempts >= 1)"))
652    }
653}
654
655/// The retry delays for `config`: `backon` exponential (minimum
656/// `retry_backoff`, doubling, capped at `max_retry_backoff`), optionally
657/// jittered; `max_sleeps = None` never ends (playlist reloads).
658fn schedule(
659    config: &TokioClientConfig,
660    max_sleeps: Option<usize>,
661) -> impl Iterator<Item = Duration> + use<> {
662    let mut builder = ExponentialBuilder::default()
663        .with_min_delay(config.retry_backoff)
664        .with_max_delay(config.max_retry_backoff);
665    if config.jitter {
666        builder = builder.with_jitter();
667    }
668    builder = match max_sleeps {
669        Some(n) => builder.with_max_times(n),
670        None => builder.without_max_times(),
671    };
672    // `backon` adds jitter AFTER its own max-delay clamp, so an unclamped
673    // delay can reach ~2x the cap: clamp again, making
674    // `max_retry_backoff` a hard maximum for every delay.
675    let cap = config.max_retry_backoff;
676    builder.build().map(move |d| d.min(cap))
677}
678
679/// Whether a failed resource fetch is worth retrying: everything except a
680/// `4xx` that repeating the same request cannot change (`408 Request Timeout`
681/// and `429 Too Many Requests` are transient, RFC 9110 §15.5.9 / RFC 6585 §4).
682fn is_retryable(err: &TokioError) -> bool {
683    match err {
684        TokioError::Status { status, .. } => {
685            !status.is_client_error()
686                || *status == StatusCode::REQUEST_TIMEOUT
687                || *status == StatusCode::TOO_MANY_REQUESTS
688        }
689        _ => true,
690    }
691}
692
693/// Builds a plain (unauthenticated) GET request for `url`, with an optional
694/// `Range` header (RFC 8216bis §4.4.4.9 partial-part byte ranges) — factored
695/// out so [`TokioClient::fetch_bytes`] can build a fresh, independent request
696/// both for the first attempt and for the post-`401` Digest retry (a
697/// `reqwest::RequestBuilder` is consumed by `.send()`, so the retry can't
698/// reuse the first one).
699fn build_request(
700    client: &Client,
701    url: &str,
702    byte_range: Option<(u64, u64)>,
703    timeout: Duration,
704) -> Result<reqwest::RequestBuilder, TokioError> {
705    let mut req = client.get(url).timeout(timeout);
706    if let Some((offset, length)) = byte_range {
707        // `saturating_sub` here only guards the `length == 0` underflow
708        // case (`0 - 1`) — it must never silently saturate the *addition*
709        // below, since a saturated `end` would produce a `Range:` header
710        // for the wrong bytes rather than failing this one request. See
711        // `TokioError::ByteRangeOverflow`.
712        let overflow = || TokioError::ByteRangeOverflow {
713            url: url.to_string(),
714            offset,
715            length,
716        };
717        let end = offset
718            .checked_add(length.saturating_sub(1))
719            .ok_or_else(overflow)?;
720        let range = headers::Range::bytes(offset..=end).map_err(|_| overflow())?;
721        let mut map = reqwest::header::HeaderMap::new();
722        map.typed_insert(range);
723        req = req.headers(map);
724    }
725    Ok(req)
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    // Biting test for the u64-overflow defect (issue's `tokio_client.rs:497`
733    // finding): `build_request`'s old `offset + length.saturating_sub(1)`
734    // guarded only the `-1` underflow, not the addition itself. A byte range
735    // whose `offset` is near `u64::MAX` — reachable via a malicious/corrupt
736    // playlist's `BYTERANGE`, or a long-lived omitted-offset cursor walked
737    // up by `crate::client::HlsClient` — must be rejected, not panic (debug)
738    // or silently wrap to a bogus `Range:` header naming the wrong bytes
739    // (release).
740    //
741    // MUTATION VERIFIED: reverting to the original
742    // `let end = offset + length.saturating_sub(1);` (dropping
743    // `checked_add`/`Err`) makes this test fail: the debug build panics with
744    // "attempt to add with overflow" inside `build_request` before
745    // `expect_err` ever runs (confirmed by running it), rather than
746    // returning `TokioError::ByteRangeOverflow`. Recompiled and re-ran to
747    // observe that exact panic, then restored the checked_add.
748    #[test]
749    fn build_request_rejects_a_range_whose_end_overflows_u64() {
750        let client = Client::new();
751        let err = build_request(
752            &client,
753            "http://example.com/seg.m4s",
754            Some((u64::MAX - 1, 5)),
755            Duration::from_secs(5),
756        )
757        .expect_err("offset near u64::MAX + length must overflow and be rejected");
758        assert!(
759            matches!(
760                err,
761                TokioError::ByteRangeOverflow {
762                    offset,
763                    length: 5,
764                    ..
765                } if offset == u64::MAX - 1
766            ),
767            "wrong error variant/fields: {err:?}"
768        );
769    }
770
771    // The flip side: an ordinary, non-overflowing byte range must still
772    // produce the expected `Range:` header — the overflow guard must not
773    // have broken the common case.
774    #[test]
775    fn build_request_sets_range_header_for_a_normal_byte_range() {
776        let client = Client::new();
777        let req = build_request(
778            &client,
779            "http://example.com/seg.m4s",
780            Some((10, 20)),
781            Duration::from_secs(5),
782        )
783        .expect("a normal byte range must succeed");
784        let built = req.build().expect("request builds");
785        let header = built
786            .headers()
787            .get(reqwest::header::RANGE)
788            .expect("Range header must be present");
789        assert_eq!(header, "bytes=10-29");
790    }
791
792    #[test]
793    fn retry_schedule_without_jitter_is_exponential_and_capped() {
794        let cfg = TokioClientConfig::default().with_jitter(false);
795        let got: Vec<_> = schedule(&cfg, None).take(6).collect();
796        let want = [200, 400, 800, 1600, 2000, 2000].map(Duration::from_millis);
797        // backon multiplies in `f32` (its factor is `f32`), so a doubled delay
798        // can be a few nanoseconds off the ideal; the cap is exact.
799        for (i, (g, w)) in got.iter().zip(want).enumerate() {
800            let diff = g.abs_diff(w);
801            assert!(
802                diff < Duration::from_micros(1),
803                "delay {i} = {g:?}, want {w:?} (min 200 ms, doubling, capped at 2 s)"
804            );
805        }
806        assert_eq!(got[4], Duration::from_secs(2), "capped exactly");
807        assert_eq!(got[5], Duration::from_secs(2), "stays at the cap");
808    }
809
810    #[test]
811    fn retry_schedule_jitter_stays_within_one_extra_delay_and_never_exceeds_the_cap() {
812        let cfg = TokioClientConfig::default();
813        let cap = cfg.max_retry_backoff;
814        // Many samples: jitter is random, a single draw proves little.
815        for _ in 0..500 {
816            for (i, got) in schedule(&cfg, None).take(8).enumerate() {
817                let base = Duration::from_millis(200u64 << i.min(4)).min(cap);
818                assert!(got >= base, "delay {i} = {got:?} below its base {base:?}");
819                assert!(
820                    got <= cap,
821                    "delay {i} = {got:?} exceeds max_retry_backoff {cap:?}"
822                );
823                assert!(
824                    got < base * 2 || got == cap,
825                    "delay {i} = {got:?} outside [{base:?}, {:?})",
826                    base * 2
827                );
828            }
829        }
830        assert_eq!(schedule(&cfg, Some(2)).count(), 2, "3 attempts = 2 sleeps");
831    }
832
833    #[test]
834    fn client_errors_other_than_408_and_429_are_not_retried() {
835        let status = |status| TokioError::Status {
836            url: "http://h/x".into(),
837            status,
838        };
839        assert!(!is_retryable(&status(StatusCode::NOT_FOUND)));
840        assert!(!is_retryable(&status(StatusCode::FORBIDDEN)));
841        assert!(is_retryable(&status(StatusCode::REQUEST_TIMEOUT)));
842        assert!(is_retryable(&status(StatusCode::TOO_MANY_REQUESTS)));
843        assert!(is_retryable(&status(StatusCode::SERVICE_UNAVAILABLE)));
844    }
845
846    #[test]
847    fn credentials_origin_check_compares_scheme_host_and_port() {
848        let client = TokioClient::new("http://origin.example:8080/live/index.m3u8").unwrap();
849        assert!(client.is_playlist_origin("http://origin.example:8080/seg0.ts"));
850        assert!(client.is_playlist_origin("HTTP://Origin.Example:8080/a/b.ts"));
851        assert!(!client.is_playlist_origin("http://other.example:8080/seg0.ts"));
852        assert!(!client.is_playlist_origin("https://origin.example:8080/seg0.ts"));
853        assert!(!client.is_playlist_origin("http://origin.example/seg0.ts"));
854        assert!(!client.is_playlist_origin("not a url"));
855        // A default port written out is the same origin.
856        let client = TokioClient::new("https://origin.example/live/index.m3u8").unwrap();
857        assert!(client.is_playlist_origin("https://origin.example:443/seg0.ts"));
858    }
859
860    // Security-blocker regression (pre-release audit): `TokioClientConfig`
861    // derives `Debug` and embeds `Option<Credentials>` directly (`auth`) — it
862    // must inherit `Credentials`'s redacting `Debug`, never the raw secret.
863    #[test]
864    fn tokio_client_config_debug_does_not_leak_embedded_credentials_secret() {
865        let config = TokioClientConfig {
866            auth: Some(Credentials::new("admin", "a-very-secret-password")),
867            ..TokioClientConfig::default()
868        };
869        let debug = format!("{config:?}");
870        assert!(
871            !debug.contains("a-very-secret-password"),
872            "leaked via TokioClientConfig Debug: {debug}"
873        );
874        assert!(debug.contains("***"), "expected redaction marker: {debug}");
875    }
876}