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(¤t, 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}