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