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