ll_hls_runtime/client/tokio_client.rs
1//! `TokioClient` — a tokio + reqwest IO adapter driving
2//! [`crate::client::LlHlsClient`] 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::LlHlsClient::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::LlHlsClient::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, LlHlsClient, 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::LlHlsClient`] over real HTTP.
154///
155/// ```no_run
156/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
157/// use ll_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: LlHlsClient,
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: LlHlsClient::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::LlHlsClient::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| transmux::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}