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