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 via
18//! reqwest's own request-builder helpers (`RequestBuilder::basic_auth`/
19//! `bearer_auth`) — no challenge round-trip needed. Digest (RFC 7616) is not
20//! something reqwest supports natively: the first request for a given
21//! resource is sent bare, and only if the server answers `401` with a
22//! `WWW-Authenticate` challenge does [`TokioClient`] compute the
23//! `Authorization` response via [`broadcast_auth::Authenticator`] and resend
24//! once — the resulting authenticator is then cached and applied
25//! preemptively to every subsequent request for as long as it keeps being
26//! accepted (RFC 7616 §3.3's `nc` advances across those calls), so a live
27//! pull doesn't round-trip a fresh challenge on every single fetch.
28//!
29//! # Error recovery
30//!
31//! - A **resource** (init/part/segment) fetch that keeps failing is retried
32//!   up to [`TokioClientConfig::max_resource_retries`] times with capped
33//!   exponential backoff, then [`crate::client::HlsClient::on_error`] is
34//!   called and the adapter moves on — the sans-IO core un-marks that
35//!   resource as "requested", so the *next* playlist reload naturally
36//!   re-requests it (see `engine.rs`'s `on_error` docs). One flaky fetch
37//!   never stalls the whole client.
38//! - A **playlist** reload has no such fallback in the sans-IO core — unlike
39//!   a resource, [`crate::client::HlsClient::on_error`] with `None` does not
40//!   re-queue anything (there is no "next reload" to fall back to; the
41//!   *current* reload IS the mechanism that discovers what to fetch next).
42//!   So a playlist fetch is retried indefinitely with capped backoff
43//!   ([`TokioClientConfig::retry_backoff`]/[`TokioClientConfig::max_retry_backoff`])
44//!   rather than ever giving up — a caller wanting a hard ceiling on how long
45//!   [`TokioClient::next_output`] may block should wrap it in
46//!   `tokio::time::timeout` itself.
47
48use std::time::Duration;
49
50use broadcast_auth::{Authenticator, Credentials, RequestContext};
51use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
52use reqwest::{Client, StatusCode};
53
54use super::{Action, HlsClient, Output, ResourceId};
55
56/// Errors from the tokio IO adapter itself — distinct from
57/// [`crate::client::Error`], the sans-IO core's own parse/demux error type
58/// (wrapped here via
59/// [`TokioError::Client`]).
60#[derive(Debug, thiserror::Error)]
61#[non_exhaustive]
62pub enum TokioError {
63    /// The underlying HTTP request failed outright (connect/timeout/TLS/
64    /// transport error) — never a non-2xx response, see [`Self::Status`].
65    #[error("HTTP request to {url} failed: {source}")]
66    Http {
67        /// The request URL.
68        url: String,
69        /// The underlying reqwest error.
70        #[source]
71        source: reqwest::Error,
72    },
73    /// The server returned a non-success HTTP status.
74    #[error("HTTP {status} fetching {url}")]
75    Status {
76        /// The request URL.
77        url: String,
78        /// The response status.
79        status: reqwest::StatusCode,
80    },
81    /// The sans-IO core rejected the fetched playlist/resource — see
82    /// [`crate::client::Error`] for the underlying reason.
83    #[error(transparent)]
84    Client(#[from] super::Error),
85    /// A `401`'s `WWW-Authenticate` challenge could not be parsed, or no
86    /// `Authorization` response could be computed from it (e.g. an
87    /// unsupported Digest `algorithm`/`qop`) — see [`broadcast_auth::Error`].
88    #[error("auth challenge/response failed: {0}")]
89    Auth(#[from] broadcast_auth::Error),
90}
91
92/// Tunables for [`TokioClient`]. [`Default`] gives sane values for a
93/// well-behaved LL-HLS origin reachable over a real (or loopback) network.
94#[derive(Debug, Clone)]
95pub struct TokioClientConfig {
96    /// Per-request timeout for a plain (non-blocking) playlist GET, or a
97    /// resource (init/part/segment) GET.
98    pub request_timeout: Duration,
99    /// Per-request timeout for a **blocking** Playlist Reload
100    /// (`_HLS_msn`/`_HLS_part`, RFC 8216bis §6.2.5.2) — must exceed the
101    /// origin's own blocking hold time (e.g. `multimux`'s `LlHlsOutput` caps
102    /// at 5s) with headroom, since the origin is expected to hold the
103    /// response open until new content exists or its own cap elapses.
104    pub blocking_timeout: Duration,
105    /// How many times a **resource** fetch is retried (capped exponential
106    /// backoff) before the adapter gives up on that specific fetch and moves
107    /// on (see the module docs' "Error recovery" section). A playlist reload
108    /// is never subject to this cap — it retries indefinitely.
109    pub max_resource_retries: u32,
110    /// Initial backoff between retry attempts; doubles per attempt up to
111    /// [`Self::max_retry_backoff`].
112    pub retry_backoff: Duration,
113    /// Ceiling the doubled [`Self::retry_backoff`] is capped at.
114    pub max_retry_backoff: Duration,
115    /// Optional auth attached to every request (see the module docs).
116    pub auth: Option<Credentials>,
117}
118
119impl Default for TokioClientConfig {
120    fn default() -> Self {
121        Self {
122            request_timeout: Duration::from_secs(5),
123            blocking_timeout: Duration::from_secs(10),
124            max_resource_retries: 3,
125            retry_backoff: Duration::from_millis(200),
126            max_retry_backoff: Duration::from_secs(2),
127            auth: None,
128        }
129    }
130}
131
132/// Diagnostic counters for what [`TokioClient`] has actually done —
133/// distinguishing "parsed an LL-HLS tag" from "acted on it", the same bar
134/// this crate's own acceptance tests hold the adapter to (issue #717's
135/// "blocking-reload + preload-hint prefetch actually exercised" acceptance
136/// item). Also useful to a real caller for observability.
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
138pub struct TokioClientStats {
139    /// Playlist GETs performed (blocking + non-blocking).
140    pub playlist_fetches: u64,
141    /// Of those, how many carried `_HLS_msn`/`_HLS_part` — a Blocking
142    /// Playlist Reload (RFC 8216bis §6.2.5.2).
143    pub blocking_reloads: u64,
144    /// Resource (init/part/segment) GETs performed.
145    pub resource_fetches: u64,
146    /// Of those, how many were for the exact URL most recently named by the
147    /// playlist's `#EXT-X-PRELOAD-HINT` (RFC 8216bis §4.4.5.3) — i.e.
148    /// fetched ahead of that resource's own numbered (`#EXT-X-PART`)
149    /// appearance, not merely alongside it.
150    pub preload_hint_resource_fetches: u64,
151}
152
153/// An async shell driving [`crate::client::HlsClient`] over real HTTP.
154///
155/// ```no_run
156/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
157/// use hls_runtime::client::tokio_client::TokioClient;
158///
159/// let mut client = TokioClient::new("http://127.0.0.1:8080/live/media.m3u8")?;
160/// while let Some(output) = client.next_output().await? {
161///     // Hand `output` (Init / Samples / Discontinuity / EndOfStream) to a decoder.
162///     let _ = output;
163/// }
164/// # Ok(())
165/// # }
166/// ```
167pub struct TokioClient {
168    core: HlsClient,
169    http: Client,
170    config: TokioClientConfig,
171    playlist_url: String,
172    stats: TokioClientStats,
173    last_preload_hint_url: Option<String>,
174    ended: bool,
175    /// Cached from the most recent Digest `401` challenge/response (see the
176    /// module docs' "Auth" section) — `None` until the first Digest
177    /// challenge is answered, or when [`TokioClientConfig::auth`] isn't
178    /// [`Credentials::Digest`]. Applied preemptively to every subsequent
179    /// request once set, advancing `nc` (RFC 7616 §3.3) each time.
180    digest_authenticator: Option<Authenticator>,
181}
182
183impl TokioClient {
184    /// Build a client for the Media Playlist at `playlist_url`, with
185    /// [`TokioClientConfig::default`] tunables.
186    ///
187    /// # Errors
188    /// Only if the underlying `reqwest::Client` fails to build (e.g. TLS
189    /// backend initialisation failure) — not a network error, since no
190    /// request has been made yet.
191    pub fn new(playlist_url: impl Into<String>) -> Result<Self, TokioError> {
192        Self::with_config(playlist_url, TokioClientConfig::default())
193    }
194
195    /// Build a client with explicit [`TokioClientConfig`] tunables.
196    ///
197    /// # Errors
198    /// See [`Self::new`].
199    pub fn with_config(
200        playlist_url: impl Into<String>,
201        config: TokioClientConfig,
202    ) -> Result<Self, TokioError> {
203        let playlist_url = playlist_url.into();
204        let http = Client::builder()
205            .build()
206            .map_err(|source| TokioError::Http {
207                url: playlist_url.clone(),
208                source,
209            })?;
210        Ok(Self {
211            core: HlsClient::new(playlist_url.clone()),
212            http,
213            config,
214            playlist_url,
215            stats: TokioClientStats::default(),
216            last_preload_hint_url: None,
217            ended: false,
218            digest_authenticator: None,
219        })
220    }
221
222    /// Diagnostic counters for requests actually made so far — see
223    /// [`TokioClientStats`].
224    pub fn stats(&self) -> TokioClientStats {
225        self.stats
226    }
227
228    /// Drive the sans-IO core — performing whatever HTTP its next
229    /// [`Action`] needs, retrying transient failures per
230    /// [`TokioClientConfig`] — until at least one [`Output`] is available.
231    ///
232    /// Returns `Ok(None)` once, right after [`Output::EndOfStream`] has
233    /// already been yielded by a previous call, signalling the caller to
234    /// stop polling. A live stream that never sends `#EXT-X-ENDLIST` simply
235    /// never returns `Ok(None)` — the caller drives this in a loop (or a
236    /// `tokio::select!` alongside its own shutdown signal) for as long as it
237    /// wants to keep playing.
238    ///
239    /// # Errors
240    /// [`TokioError::Client`] if the sans-IO core rejects a fetched
241    /// playlist/resource (malformed playlist, demux failure). Resource fetch
242    /// failures are retried/recovered internally (see the module docs) and
243    /// never surface here; a playlist fetch failure retries indefinitely
244    /// rather than ever returning an [`TokioError::Http`]/[`TokioError::Status`]
245    /// — see the module docs' "Error recovery" section.
246    pub async fn next_output(&mut self) -> Result<Option<Output>, TokioError> {
247        loop {
248            if let Some(out) = self.core.next_output() {
249                if matches!(out, Output::EndOfStream) {
250                    self.ended = true;
251                }
252                return Ok(Some(out));
253            }
254            if self.ended {
255                return Ok(None);
256            }
257
258            match self.core.poll() {
259                Some(action @ Action::FetchPlaylist { .. }) => {
260                    let request_url = action
261                        .playlist_request_url()
262                        .expect("Action::FetchPlaylist always has a playlist_request_url");
263                    let is_blocking = matches!(
264                        action,
265                        Action::FetchPlaylist {
266                            blocking: Some(_),
267                            ..
268                        }
269                    );
270                    let timeout = if is_blocking {
271                        self.config.blocking_timeout
272                    } else {
273                        self.config.request_timeout
274                    };
275                    let bytes = self.fetch_playlist_resilient(&request_url, timeout).await;
276                    self.stats.playlist_fetches += 1;
277                    if is_blocking {
278                        self.stats.blocking_reloads += 1;
279                    }
280                    self.note_preload_hint(&bytes);
281                    self.core.on_playlist(&bytes)?;
282                }
283                Some(Action::FetchResource {
284                    id,
285                    url,
286                    byte_range,
287                }) => match self.fetch_resource_bounded(&url, byte_range).await {
288                    Ok(bytes) => {
289                        self.stats.resource_fetches += 1;
290                        if matches!(id, ResourceId::Part { .. })
291                            && self.last_preload_hint_url.as_deref() == Some(url.as_str())
292                        {
293                            self.stats.preload_hint_resource_fetches += 1;
294                        }
295                        self.core.on_resource(id, &bytes)?;
296                    }
297                    Err(_source) => {
298                        // Retries exhausted: un-mark as requested so the
299                        // next playlist reload naturally re-requests it,
300                        // rather than stalling the whole client on one bad
301                        // fetch.
302                        self.core.on_error(Some(id));
303                    }
304                },
305                Some(Action::WaitMs(ms)) => {
306                    tokio::time::sleep(Duration::from_millis(ms)).await;
307                }
308                None => {
309                    // Defensive-only: in normal operation `on_playlist`
310                    // always re-queues a reload (or `WaitMs`) before
311                    // returning, for as long as the stream hasn't ended, so
312                    // this should never actually spin. Guard against it
313                    // anyway with a short sleep rather than a hot loop.
314                    tokio::time::sleep(Duration::from_millis(10)).await;
315                }
316            }
317        }
318    }
319
320    /// Re-parse a just-fetched playlist purely to note its
321    /// `#EXT-X-PRELOAD-HINT` URL for [`TokioClientStats`] bookkeeping — the
322    /// sans-IO core does its own, authoritative parse independently inside
323    /// [`crate::client::HlsClient::on_playlist`]; this is a deliberate, small,
324    /// side-channel duplication (stats only, never fed back into scheduling)
325    /// rather than growing an API on the core to expose its parsed state.
326    fn note_preload_hint(&mut self, playlist_bytes: &[u8]) {
327        self.last_preload_hint_url = core::str::from_utf8(playlist_bytes)
328            .ok()
329            .and_then(|text| broadcast_hls::MediaPlaylist::parse(text).ok())
330            .and_then(|pl| pl.low_latency)
331            .and_then(|ll| ll.preload_hint_part)
332            .map(|hint| super::url::resolve(&self.playlist_url, &hint));
333    }
334
335    /// Applies whatever auth can be attached *before* sending — Basic/Bearer
336    /// always (they never need a challenge), plus a cached Digest
337    /// [`Authenticator`] once one exists (advancing `nc`). A `Digest`
338    /// config with no cached authenticator yet is left unauthenticated for
339    /// this attempt; [`Self::fetch_bytes`] answers the resulting `401`.
340    fn apply_auth_preemptive(
341        &mut self,
342        req: reqwest::RequestBuilder,
343        method: &str,
344        uri: &str,
345    ) -> reqwest::RequestBuilder {
346        match &self.config.auth {
347            Some(Credentials::Basic { username, password }) => {
348                req.basic_auth(username, Some(password))
349            }
350            Some(Credentials::Bearer { token }) => req.bearer_auth(token),
351            Some(_) => {
352                // `Credentials::Digest` (or any future non_exhaustive
353                // variant): no preemptive header without a cached
354                // authenticator from a prior challenge.
355                if let Some(auth) = self.digest_authenticator.as_mut() {
356                    if let Ok(value) = auth.authorization(&RequestContext::new(method, uri)) {
357                        return req.header(AUTHORIZATION, value);
358                    }
359                }
360                req
361            }
362            None => req,
363        }
364    }
365
366    /// Answers a `401` response by computing the `Authorization` value from
367    /// its `WWW-Authenticate` challenge (via [`broadcast_auth`]) and
368    /// resending once — only when [`TokioClientConfig::auth`] is
369    /// [`Credentials::Digest`] (Basic/Bearer are already pre-applied and a
370    /// `401` for those means wrong credentials, not a missing challenge
371    /// round-trip). The freshly built [`Authenticator`] is cached on
372    /// success so later requests apply it preemptively.
373    async fn retry_after_unauthorized(
374        &mut self,
375        method: &str,
376        uri: &str,
377        req: reqwest::RequestBuilder,
378        response: reqwest::Response,
379    ) -> Result<reqwest::Response, TokioError> {
380        let Some(creds @ Credentials::Digest { .. }) = self.config.auth.clone() else {
381            return Ok(response);
382        };
383        let Some(challenge) = response
384            .headers()
385            .get(WWW_AUTHENTICATE)
386            .and_then(|v| v.to_str().ok())
387            .map(str::to_string)
388        else {
389            return Ok(response);
390        };
391        let mut authenticator = Authenticator::from_challenge(&challenge, creds)?;
392        let value = authenticator.authorization(&RequestContext::new(method, uri))?;
393        self.digest_authenticator = Some(authenticator);
394        req.header(AUTHORIZATION, value)
395            .send()
396            .await
397            .map_err(|source| TokioError::Http {
398                url: uri.to_string(),
399                source,
400            })
401    }
402
403    async fn fetch_bytes(
404        &mut self,
405        url: &str,
406        byte_range: Option<(u64, u64)>,
407        timeout: Duration,
408    ) -> Result<Vec<u8>, TokioError> {
409        let req = self.apply_auth_preemptive(
410            build_request(&self.http, url, byte_range, timeout),
411            "GET",
412            url,
413        );
414        let resp = req.send().await.map_err(|source| TokioError::Http {
415            url: url.to_string(),
416            source,
417        })?;
418
419        let resp = if resp.status() == StatusCode::UNAUTHORIZED {
420            let retry_req = build_request(&self.http, url, byte_range, timeout);
421            self.retry_after_unauthorized("GET", url, retry_req, resp)
422                .await?
423        } else {
424            resp
425        };
426
427        let status = resp.status();
428        if !status.is_success() {
429            return Err(TokioError::Status {
430                url: url.to_string(),
431                status,
432            });
433        }
434        let bytes = resp.bytes().await.map_err(|source| TokioError::Http {
435            url: url.to_string(),
436            source,
437        })?;
438        Ok(bytes.to_vec())
439    }
440
441    /// Retry a playlist fetch indefinitely (capped exponential backoff) —
442    /// see the module docs' "Error recovery" section for why a playlist
443    /// reload, unlike a resource fetch, has no bounded-retry fallback.
444    async fn fetch_playlist_resilient(&mut self, url: &str, timeout: Duration) -> Vec<u8> {
445        let mut backoff = self.config.retry_backoff;
446        loop {
447            match self.fetch_bytes(url, None, timeout).await {
448                Ok(bytes) => return bytes,
449                Err(_source) => {
450                    tokio::time::sleep(backoff).await;
451                    backoff = (backoff * 2).min(self.config.max_retry_backoff);
452                }
453            }
454        }
455    }
456
457    /// Retry a resource fetch up to [`TokioClientConfig::max_resource_retries`]
458    /// times (capped exponential backoff), then give up.
459    async fn fetch_resource_bounded(
460        &mut self,
461        url: &str,
462        byte_range: Option<(u64, u64)>,
463    ) -> Result<Vec<u8>, TokioError> {
464        let mut backoff = self.config.retry_backoff;
465        let mut last_err = None;
466        for _ in 0..self.config.max_resource_retries.max(1) {
467            match self
468                .fetch_bytes(url, byte_range, self.config.request_timeout)
469                .await
470            {
471                Ok(bytes) => return Ok(bytes),
472                Err(source) => {
473                    last_err = Some(source);
474                    tokio::time::sleep(backoff).await;
475                    backoff = (backoff * 2).min(self.config.max_retry_backoff);
476                }
477            }
478        }
479        Err(last_err.expect("loop runs at least once (max_resource_retries.max(1))"))
480    }
481}
482
483/// Builds a plain (unauthenticated) GET request for `url`, with an optional
484/// `Range` header (RFC 8216bis §4.4.4.9 partial-part byte ranges) — factored
485/// out so [`TokioClient::fetch_bytes`] can build a fresh, independent request
486/// both for the first attempt and for the post-`401` Digest retry (a
487/// `reqwest::RequestBuilder` is consumed by `.send()`, so the retry can't
488/// reuse the first one).
489fn build_request(
490    client: &Client,
491    url: &str,
492    byte_range: Option<(u64, u64)>,
493    timeout: Duration,
494) -> reqwest::RequestBuilder {
495    let mut req = client.get(url).timeout(timeout);
496    if let Some((offset, length)) = byte_range {
497        let end = offset + length.saturating_sub(1);
498        req = req.header(reqwest::header::RANGE, format!("bytes={offset}-{end}"));
499    }
500    req
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    // Security-blocker regression (pre-release audit): `TokioClientConfig`
508    // derives `Debug` and embeds `Option<Credentials>` directly (`auth`) — it
509    // must inherit `Credentials`'s redacting `Debug`, never the raw secret.
510    #[test]
511    fn tokio_client_config_debug_does_not_leak_embedded_credentials_secret() {
512        let config = TokioClientConfig {
513            auth: Some(Credentials::new("admin", "a-very-secret-password")),
514            ..TokioClientConfig::default()
515        };
516        let debug = format!("{config:?}");
517        assert!(
518            !debug.contains("a-very-secret-password"),
519            "leaked via TokioClientConfig Debug: {debug}"
520        );
521        assert!(debug.contains("***"), "expected redaction marker: {debug}");
522    }
523}