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//! # Cancellation
62//!
63//! [`TokioClientConfig::cancel`] is a `CancellationToken`: once cancelled,
64//! [`TokioClient::next_output`] abandons whatever request or backoff sleep is
65//! in flight and returns `Ok(None)` (the same "stop polling" signal as the end
66//! of the stream). There is no other shutdown mechanism and no polling sleep.
67
68use std::time::Duration;
69
70use backon::{BackoffBuilder, ExponentialBuilder};
71use broadcast_auth::{Authenticator, Credentials, RequestContext};
72use headers::HeaderMapExt;
73use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
74use reqwest::{Client, StatusCode};
75use tokio_util::sync::CancellationToken;
76
77use super::{Action, HlsClient, Output, ResourceId};
78
79/// Errors from the tokio IO adapter itself — distinct from
80/// [`crate::client::Error`], the sans-IO core's own parse/demux error type
81/// (wrapped here via
82/// [`TokioError::Client`]).
83#[derive(Debug, thiserror::Error)]
84#[non_exhaustive]
85pub enum TokioError {
86 /// The underlying HTTP request failed outright (connect/timeout/TLS/
87 /// transport error) — never a non-2xx response, see [`Self::Status`].
88 #[error("HTTP request to {url} failed: {source}")]
89 Http {
90 /// The request URL.
91 url: String,
92 /// The underlying reqwest error.
93 #[source]
94 source: reqwest::Error,
95 },
96 /// The server returned a non-success HTTP status.
97 #[error("HTTP {status} fetching {url}")]
98 Status {
99 /// The request URL.
100 url: String,
101 /// The response status.
102 status: reqwest::StatusCode,
103 },
104 /// The sans-IO core rejected the fetched playlist/resource — see
105 /// [`crate::client::Error`] for the underlying reason.
106 #[error(transparent)]
107 Client(#[from] super::Error),
108 /// A `401`'s `WWW-Authenticate` challenge could not be parsed, or no
109 /// `Authorization` response could be computed from it (e.g. an
110 /// unsupported Digest `algorithm`/`qop`) — see [`broadcast_auth::Error`].
111 #[error("auth challenge/response failed: {0}")]
112 Auth(#[from] broadcast_auth::Error),
113 /// Building the `Range` header for `url` would overflow `u64`
114 /// (`offset + (length - 1)`, RFC 8216bis §4.4.4.9). In normal operation
115 /// this never fires: [`crate::client::HlsClient`]'s internal
116 /// `resolve_byte_range` already rejects any byte range whose
117 /// `offset + length` overflows before it ever reaches this adapter (see
118 /// `crate::client::Error::ByteRangeOverflow`) — this variant is
119 /// defense-in-depth against a future/foreign caller constructing a
120 /// `(u64, u64)` byte range directly, bypassing the sans-IO core.
121 #[error("byte range for {url} overflows u64: offset {offset} + length {length}")]
122 ByteRangeOverflow {
123 /// The request URL the range applies to.
124 url: String,
125 /// The range's starting offset.
126 offset: u64,
127 /// The range's length.
128 length: u64,
129 },
130 /// The sans-IO core had nothing queued to do and the stream had not ended.
131 /// In normal operation `on_playlist` always queues a reload (or a wait)
132 /// before returning, so this signals a core bug rather than a network
133 /// condition; it replaces a 10 ms defensive sleep that used to hide it.
134 #[error("the HLS core queued no action and the stream has not ended")]
135 Stalled,
136 /// [`TokioClientConfig::cancel`] fired while a request was in flight.
137 /// Internal: [`TokioClient::next_output`] converts it to `Ok(None)`.
138 #[doc(hidden)]
139 #[error("cancelled")]
140 Cancelled,
141}
142
143/// Tunables for [`TokioClient`]. [`Default`] gives sane values for a
144/// well-behaved LL-HLS origin reachable over a real (or loopback) network.
145#[derive(Debug, Clone)]
146pub struct TokioClientConfig {
147 /// Per-request timeout for a plain (non-blocking) playlist GET, or a
148 /// resource (init/part/segment) GET.
149 pub request_timeout: Duration,
150 /// Per-request timeout for a **blocking** Playlist Reload
151 /// (`_HLS_msn`/`_HLS_part`, RFC 8216bis §6.2.5.2) — must exceed the
152 /// origin's own blocking hold time (e.g. `multimux`'s `LlHlsOutput` caps
153 /// at 5s) with headroom, since the origin is expected to hold the
154 /// response open until new content exists or its own cap elapses.
155 pub blocking_timeout: Duration,
156 /// How many times a **resource** fetch is retried (capped exponential
157 /// backoff) before the adapter gives up on that specific fetch and moves
158 /// on (see the module docs' "Error recovery" section). A playlist reload
159 /// is never subject to this cap — it retries indefinitely.
160 pub max_resource_retries: u32,
161 /// Initial backoff between retry attempts (`backon`'s minimum delay);
162 /// doubles per attempt up to [`Self::max_retry_backoff`].
163 pub retry_backoff: Duration,
164 /// Ceiling the doubled [`Self::retry_backoff`] is capped at.
165 pub max_retry_backoff: Duration,
166 /// Optional auth attached to every request to the playlist URL's origin
167 /// (see the module docs).
168 pub auth: Option<Credentials>,
169 /// TCP connect timeout (reqwest's `connect_timeout`). Default 10 s.
170 pub connect_timeout: Duration,
171 /// Add `backon` jitter to every retry delay (each delay `d` becomes a
172 /// random value in `[d, 2d)`, clamped to [`Self::max_retry_backoff`], which
173 /// stays a hard maximum). Default `true`.
174 pub jitter: bool,
175 /// Cancelling this token makes [`TokioClient::next_output`] abandon its
176 /// in-flight request or backoff sleep and return `Ok(None)`.
177 pub cancel: CancellationToken,
178}
179
180impl TokioClientConfig {
181 /// Set [`Self::auth`].
182 #[must_use]
183 pub fn with_auth(mut self, auth: Credentials) -> Self {
184 self.auth = Some(auth);
185 self
186 }
187
188 /// Set [`Self::cancel`].
189 #[must_use]
190 pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
191 self.cancel = cancel;
192 self
193 }
194
195 /// Set [`Self::connect_timeout`].
196 #[must_use]
197 pub fn with_connect_timeout(mut self, d: Duration) -> Self {
198 self.connect_timeout = d;
199 self
200 }
201
202 /// Set [`Self::jitter`].
203 #[must_use]
204 pub fn with_jitter(mut self, on: bool) -> Self {
205 self.jitter = on;
206 self
207 }
208}
209
210impl Default for TokioClientConfig {
211 fn default() -> Self {
212 Self {
213 request_timeout: Duration::from_secs(5),
214 blocking_timeout: Duration::from_secs(10),
215 max_resource_retries: 3,
216 retry_backoff: Duration::from_millis(200),
217 max_retry_backoff: Duration::from_secs(2),
218 auth: None,
219 connect_timeout: Duration::from_secs(10),
220 jitter: true,
221 cancel: CancellationToken::new(),
222 }
223 }
224}
225
226/// Diagnostic counters for what [`TokioClient`] has actually done —
227/// distinguishing "parsed an LL-HLS tag" from "acted on it", the same bar
228/// this crate's own acceptance tests hold the adapter to (issue #717's
229/// "blocking-reload + preload-hint prefetch actually exercised" acceptance
230/// item). Also useful to a real caller for observability.
231#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
232pub struct TokioClientStats {
233 /// Playlist GETs performed (blocking + non-blocking).
234 pub playlist_fetches: u64,
235 /// Of those, how many carried `_HLS_msn`/`_HLS_part` — a Blocking
236 /// Playlist Reload (RFC 8216bis §6.2.5.2).
237 pub blocking_reloads: u64,
238 /// Resource (init/part/segment) GETs performed.
239 pub resource_fetches: u64,
240 /// Of those, how many were for the exact URL most recently named by the
241 /// playlist's `#EXT-X-PRELOAD-HINT` (RFC 8216bis §4.4.5.3) — i.e.
242 /// fetched ahead of that resource's own numbered (`#EXT-X-PART`)
243 /// appearance, not merely alongside it.
244 pub preload_hint_resource_fetches: u64,
245}
246
247/// An async shell driving [`crate::client::HlsClient`] over real HTTP.
248///
249/// ```no_run
250/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
251/// use hls_runtime::client::tokio_client::TokioClient;
252///
253/// let mut client = TokioClient::new("http://127.0.0.1:8080/live/media.m3u8")?;
254/// while let Some(output) = client.next_output().await? {
255/// // Hand `output` (Init / Samples / Discontinuity / EndOfStream) to a decoder.
256/// let _ = output;
257/// }
258/// # Ok(())
259/// # }
260/// ```
261pub struct TokioClient {
262 core: HlsClient,
263 http: Client,
264 config: TokioClientConfig,
265 playlist_url: String,
266 /// `playlist_url` parsed, for the same-origin check on credentials;
267 /// `None` if it does not parse, in which case no credentials are sent.
268 playlist_origin_url: Option<reqwest::Url>,
269 stats: TokioClientStats,
270 last_preload_hint_url: Option<String>,
271 ended: bool,
272 /// Cached from the most recent Digest `401` challenge/response (see the
273 /// module docs' "Auth" section) — `None` until the first Digest
274 /// challenge is answered, or when [`TokioClientConfig::auth`] isn't
275 /// [`Credentials::Digest`]. Applied preemptively to every subsequent
276 /// request once set, advancing `nc` (RFC 7616 §3.3) each time.
277 digest_authenticator: Option<Authenticator>,
278}
279
280impl TokioClient {
281 /// Build a client for the Media Playlist at `playlist_url`, with
282 /// [`TokioClientConfig::default`] tunables.
283 ///
284 /// # Errors
285 /// Only if the underlying `reqwest::Client` fails to build (e.g. TLS
286 /// backend initialisation failure) — not a network error, since no
287 /// request has been made yet.
288 pub fn new(playlist_url: impl Into<String>) -> Result<Self, TokioError> {
289 Self::with_config(playlist_url, TokioClientConfig::default())
290 }
291
292 /// Build a client with explicit [`TokioClientConfig`] tunables.
293 ///
294 /// # Errors
295 /// See [`Self::new`].
296 pub fn with_config(
297 playlist_url: impl Into<String>,
298 config: TokioClientConfig,
299 ) -> Result<Self, TokioError> {
300 let playlist_url = playlist_url.into();
301 let http = Client::builder()
302 .connect_timeout(config.connect_timeout)
303 .build()
304 .map_err(|source| TokioError::Http {
305 url: playlist_url.clone(),
306 source,
307 })?;
308 Ok(Self {
309 core: HlsClient::new(playlist_url.clone()),
310 http,
311 config,
312 playlist_origin_url: reqwest::Url::parse(&playlist_url).ok(),
313 playlist_url,
314 stats: TokioClientStats::default(),
315 last_preload_hint_url: None,
316 ended: false,
317 digest_authenticator: None,
318 })
319 }
320
321 /// Diagnostic counters for requests actually made so far — see
322 /// [`TokioClientStats`].
323 pub fn stats(&self) -> TokioClientStats {
324 self.stats
325 }
326
327 /// Drive the sans-IO core — performing whatever HTTP its next
328 /// [`Action`] needs, retrying transient failures per
329 /// [`TokioClientConfig`] — until at least one [`Output`] is available.
330 ///
331 /// Returns `Ok(None)` once, right after [`Output::EndOfStream`] has
332 /// already been yielded by a previous call, signalling the caller to
333 /// stop polling. A live stream that never sends `#EXT-X-ENDLIST` simply
334 /// never returns `Ok(None)` — the caller drives this in a loop (or a
335 /// `tokio::select!` alongside its own shutdown signal) for as long as it
336 /// wants to keep playing.
337 ///
338 /// # Errors
339 /// [`TokioError::Client`] if the sans-IO core rejects a fetched
340 /// playlist/resource (malformed playlist, demux failure). Resource fetch
341 /// failures are retried/recovered internally (see the module docs) and
342 /// never surface here; a playlist fetch failure retries indefinitely
343 /// rather than ever returning an [`TokioError::Http`]/[`TokioError::Status`]
344 /// — see the module docs' "Error recovery" section.
345 pub async fn next_output(&mut self) -> Result<Option<Output>, TokioError> {
346 loop {
347 if self.config.cancel.is_cancelled() {
348 return Ok(None);
349 }
350 if let Some(out) = self.core.next_output() {
351 if matches!(out, Output::EndOfStream) {
352 self.ended = true;
353 }
354 return Ok(Some(out));
355 }
356 if self.ended {
357 return Ok(None);
358 }
359
360 match self.core.poll() {
361 Some(action @ Action::FetchPlaylist { .. }) => {
362 let request_url = action
363 .playlist_request_url()
364 .expect("Action::FetchPlaylist always has a playlist_request_url");
365 let is_blocking = matches!(
366 action,
367 Action::FetchPlaylist {
368 blocking: Some(_),
369 ..
370 }
371 );
372 let timeout = if is_blocking {
373 self.config.blocking_timeout
374 } else {
375 self.config.request_timeout
376 };
377 let bytes = match self.fetch_playlist_resilient(&request_url, timeout).await {
378 Ok(bytes) => bytes,
379 Err(TokioError::Cancelled) => return Ok(None),
380 Err(other) => return Err(other),
381 };
382 self.stats.playlist_fetches += 1;
383 if is_blocking {
384 self.stats.blocking_reloads += 1;
385 }
386 self.note_preload_hint(&bytes);
387 self.core.on_playlist(&bytes)?;
388 }
389 Some(Action::FetchResource {
390 id,
391 url,
392 byte_range,
393 }) => match self.fetch_resource_bounded(&url, byte_range).await {
394 Ok(bytes) => {
395 self.stats.resource_fetches += 1;
396 if matches!(id, ResourceId::Part { .. })
397 && self.last_preload_hint_url.as_deref() == Some(url.as_str())
398 {
399 self.stats.preload_hint_resource_fetches += 1;
400 }
401 self.core.on_resource(id, &bytes)?;
402 }
403 Err(TokioError::Cancelled) => return Ok(None),
404 Err(_source) => {
405 // Retries exhausted: un-mark as requested so the
406 // next playlist reload naturally re-requests it,
407 // rather than stalling the whole client on one bad
408 // fetch.
409 self.core.on_error(Some(id));
410 }
411 },
412 Some(Action::WaitMs(ms)) => {
413 if self.sleep_or_cancelled(Duration::from_millis(ms)).await {
414 return Ok(None);
415 }
416 }
417 // In normal operation `on_playlist` always re-queues a reload
418 // (or `WaitMs`) before returning for as long as the stream has
419 // not ended, so this is a core bug, not something to sleep on.
420 None => return Err(TokioError::Stalled),
421 }
422 }
423 }
424
425 /// Sleeps `d` or until [`TokioClientConfig::cancel`] fires; `true` means
426 /// cancelled.
427 async fn sleep_or_cancelled(&self, d: Duration) -> bool {
428 tokio::select! {
429 () = tokio::time::sleep(d) => false,
430 () = self.config.cancel.cancelled() => true,
431 }
432 }
433
434 /// Re-parse a just-fetched playlist purely to note its
435 /// `#EXT-X-PRELOAD-HINT` URL for [`TokioClientStats`] bookkeeping — the
436 /// sans-IO core does its own, authoritative parse independently inside
437 /// [`crate::client::HlsClient::on_playlist`]; this is a deliberate, small,
438 /// side-channel duplication (stats only, never fed back into scheduling)
439 /// rather than growing an API on the core to expose its parsed state.
440 fn note_preload_hint(&mut self, playlist_bytes: &[u8]) {
441 self.last_preload_hint_url = core::str::from_utf8(playlist_bytes)
442 .ok()
443 .and_then(|text| broadcast_hls::MediaPlaylist::parse(text).ok())
444 .and_then(|pl| pl.low_latency)
445 .and_then(|ll| ll.preload_hint_part)
446 .map(|hint| super::url::resolve(&self.playlist_url, &hint));
447 }
448
449 /// Applies whatever auth can be attached *before* sending — Basic/Bearer
450 /// always (they never need a challenge), plus a cached Digest
451 /// [`Authenticator`] once one exists (advancing `nc`). A `Digest`
452 /// config with no cached authenticator yet is left unauthenticated for
453 /// this attempt; [`Self::fetch_bytes`] answers the resulting `401`.
454 fn apply_auth_preemptive(
455 &mut self,
456 req: reqwest::RequestBuilder,
457 method: &str,
458 uri: &str,
459 ) -> reqwest::RequestBuilder {
460 if !self.is_playlist_origin(uri) {
461 return req;
462 }
463 match &self.config.auth {
464 Some(Credentials::Basic { username, password }) => {
465 req.basic_auth(username, Some(password))
466 }
467 Some(Credentials::Bearer { token }) => req.bearer_auth(token),
468 Some(_) => {
469 // `Credentials::Digest` (or any future non_exhaustive
470 // variant): no preemptive header without a cached
471 // authenticator from a prior challenge.
472 if let Some(auth) = self.digest_authenticator.as_mut()
473 && let Ok(value) = auth.authorization(&RequestContext::new(method, uri))
474 {
475 return req.header(AUTHORIZATION, value);
476 }
477 req
478 }
479 None => req,
480 }
481 }
482
483 /// Answers a `401` response by computing the `Authorization` value from
484 /// its `WWW-Authenticate` challenge (via [`broadcast_auth`]) and
485 /// resending once — only when [`TokioClientConfig::auth`] is
486 /// [`Credentials::Digest`] (Basic/Bearer are already pre-applied and a
487 /// `401` for those means wrong credentials, not a missing challenge
488 /// round-trip). The freshly built [`Authenticator`] is cached on
489 /// success so later requests apply it preemptively.
490 async fn retry_after_unauthorized(
491 &mut self,
492 method: &str,
493 uri: &str,
494 req: reqwest::RequestBuilder,
495 response: reqwest::Response,
496 ) -> Result<reqwest::Response, TokioError> {
497 if !self.is_playlist_origin(uri) {
498 return Ok(response);
499 }
500 let Some(creds @ Credentials::Digest { .. }) = self.config.auth.clone() else {
501 return Ok(response);
502 };
503 let Some(challenge) = response
504 .headers()
505 .get(WWW_AUTHENTICATE)
506 .and_then(|v| v.to_str().ok())
507 .map(str::to_string)
508 else {
509 return Ok(response);
510 };
511 let mut authenticator = Authenticator::from_challenge(&challenge, creds)?;
512 let value = authenticator.authorization(&RequestContext::new(method, uri))?;
513 self.digest_authenticator = Some(authenticator);
514 req.header(AUTHORIZATION, value)
515 .send()
516 .await
517 .map_err(|source| TokioError::Http {
518 url: uri.to_string(),
519 source,
520 })
521 }
522
523 /// Whether `url` has the same origin (scheme, host, port — RFC 6454 §4)
524 /// as the configured playlist URL; only such requests carry credentials.
525 fn is_playlist_origin(&self, url: &str) -> bool {
526 match (&self.playlist_origin_url, reqwest::Url::parse(url)) {
527 (Some(playlist), Ok(url)) => playlist.origin() == url.origin(),
528 _ => false,
529 }
530 }
531
532 /// [`Self::fetch_bytes_inner`], abandoned the moment
533 /// [`TokioClientConfig::cancel`] fires.
534 async fn fetch_bytes(
535 &mut self,
536 url: &str,
537 byte_range: Option<(u64, u64)>,
538 timeout: Duration,
539 ) -> Result<Vec<u8>, TokioError> {
540 let cancel = self.config.cancel.clone();
541 tokio::select! {
542 r = self.fetch_bytes_inner(url, byte_range, timeout) => r,
543 () = cancel.cancelled() => Err(TokioError::Cancelled),
544 }
545 }
546
547 async fn fetch_bytes_inner(
548 &mut self,
549 url: &str,
550 byte_range: Option<(u64, u64)>,
551 timeout: Duration,
552 ) -> Result<Vec<u8>, TokioError> {
553 let req = self.apply_auth_preemptive(
554 build_request(&self.http, url, byte_range, timeout)?,
555 "GET",
556 url,
557 );
558 let resp = req.send().await.map_err(|source| TokioError::Http {
559 url: url.to_string(),
560 source,
561 })?;
562
563 let resp = if resp.status() == StatusCode::UNAUTHORIZED {
564 let retry_req = build_request(&self.http, url, byte_range, timeout)?;
565 self.retry_after_unauthorized("GET", url, retry_req, resp)
566 .await?
567 } else {
568 resp
569 };
570
571 let status = resp.status();
572 if !status.is_success() {
573 return Err(TokioError::Status {
574 url: url.to_string(),
575 status,
576 });
577 }
578 let bytes = resp.bytes().await.map_err(|source| TokioError::Http {
579 url: url.to_string(),
580 source,
581 })?;
582 Ok(bytes.to_vec())
583 }
584
585 /// Retry a playlist fetch indefinitely (capped exponential backoff) —
586 /// see the module docs' "Error recovery" section for why a playlist
587 /// reload, unlike a resource fetch, has no bounded-retry fallback.
588 ///
589 /// A blocking or delta reload (`url` carries `_HLS_msn`/`_HLS_part`/
590 /// `_HLS_skip`) answered with a `4xx` that repeating cannot change — an
591 /// origin that restarted below the requested Media Sequence Number
592 /// answers `400` (RFC 8216bis §6.2.5.2) — is retried **once** at once as
593 /// a plain GET of the playlist URL instead of being repeated forever
594 /// (audit r09-C3, issue #1031); the plain URL then keeps the usual
595 /// backoff.
596 async fn fetch_playlist_resilient(
597 &mut self,
598 url: &str,
599 timeout: Duration,
600 ) -> Result<Vec<u8>, TokioError> {
601 let mut delays = schedule(&self.config, None);
602 let mut current = url.to_string();
603 loop {
604 match self.fetch_bytes(¤t, None, timeout).await {
605 Ok(bytes) => return Ok(bytes),
606 Err(TokioError::Cancelled) => return Err(TokioError::Cancelled),
607 Err(source) if current != self.playlist_url && !is_retryable(&source) => {
608 current.clone_from(&self.playlist_url);
609 }
610 Err(_source) => {
611 // An unbounded schedule never ends; the fallback only
612 // keeps this total.
613 let d = delays.next().unwrap_or(self.config.max_retry_backoff);
614 if self.sleep_or_cancelled(d).await {
615 return Err(TokioError::Cancelled);
616 }
617 }
618 }
619 }
620 }
621
622 /// Retry a resource fetch up to [`TokioClientConfig::max_resource_retries`]
623 /// times (capped exponential backoff), then give up.
624 async fn fetch_resource_bounded(
625 &mut self,
626 url: &str,
627 byte_range: Option<(u64, u64)>,
628 ) -> Result<Vec<u8>, TokioError> {
629 let attempts = usize::try_from(self.config.max_resource_retries.max(1)).unwrap_or(1);
630 // `attempts` total attempts means `attempts - 1` sleeps between them.
631 let mut delays = schedule(&self.config, Some(attempts - 1));
632 let mut last_err = None;
633 for _ in 0..attempts {
634 match self
635 .fetch_bytes(url, byte_range, self.config.request_timeout)
636 .await
637 {
638 Ok(bytes) => return Ok(bytes),
639 Err(source @ TokioError::Cancelled) => return Err(source),
640 Err(source) if !is_retryable(&source) => return Err(source),
641 Err(source) => {
642 last_err = Some(source);
643 // No sleep after the last attempt.
644 let Some(d) = delays.next() else { break };
645 if self.sleep_or_cancelled(d).await {
646 return Err(TokioError::Cancelled);
647 }
648 }
649 }
650 }
651 Err(last_err.expect("loop runs at least once (attempts >= 1)"))
652 }
653}
654
655/// The retry delays for `config`: `backon` exponential (minimum
656/// `retry_backoff`, doubling, capped at `max_retry_backoff`), optionally
657/// jittered; `max_sleeps = None` never ends (playlist reloads).
658fn schedule(
659 config: &TokioClientConfig,
660 max_sleeps: Option<usize>,
661) -> impl Iterator<Item = Duration> + use<> {
662 let mut builder = ExponentialBuilder::default()
663 .with_min_delay(config.retry_backoff)
664 .with_max_delay(config.max_retry_backoff);
665 if config.jitter {
666 builder = builder.with_jitter();
667 }
668 builder = match max_sleeps {
669 Some(n) => builder.with_max_times(n),
670 None => builder.without_max_times(),
671 };
672 // `backon` adds jitter AFTER its own max-delay clamp, so an unclamped
673 // delay can reach ~2x the cap: clamp again, making
674 // `max_retry_backoff` a hard maximum for every delay.
675 let cap = config.max_retry_backoff;
676 builder.build().map(move |d| d.min(cap))
677}
678
679/// Whether a failed resource fetch is worth retrying: everything except a
680/// `4xx` that repeating the same request cannot change (`408 Request Timeout`
681/// and `429 Too Many Requests` are transient, RFC 9110 §15.5.9 / RFC 6585 §4).
682fn is_retryable(err: &TokioError) -> bool {
683 match err {
684 TokioError::Status { status, .. } => {
685 !status.is_client_error()
686 || *status == StatusCode::REQUEST_TIMEOUT
687 || *status == StatusCode::TOO_MANY_REQUESTS
688 }
689 _ => true,
690 }
691}
692
693/// Builds a plain (unauthenticated) GET request for `url`, with an optional
694/// `Range` header (RFC 8216bis §4.4.4.9 partial-part byte ranges) — factored
695/// out so [`TokioClient::fetch_bytes`] can build a fresh, independent request
696/// both for the first attempt and for the post-`401` Digest retry (a
697/// `reqwest::RequestBuilder` is consumed by `.send()`, so the retry can't
698/// reuse the first one).
699fn build_request(
700 client: &Client,
701 url: &str,
702 byte_range: Option<(u64, u64)>,
703 timeout: Duration,
704) -> Result<reqwest::RequestBuilder, TokioError> {
705 let mut req = client.get(url).timeout(timeout);
706 if let Some((offset, length)) = byte_range {
707 // `saturating_sub` here only guards the `length == 0` underflow
708 // case (`0 - 1`) — it must never silently saturate the *addition*
709 // below, since a saturated `end` would produce a `Range:` header
710 // for the wrong bytes rather than failing this one request. See
711 // `TokioError::ByteRangeOverflow`.
712 let overflow = || TokioError::ByteRangeOverflow {
713 url: url.to_string(),
714 offset,
715 length,
716 };
717 let end = offset
718 .checked_add(length.saturating_sub(1))
719 .ok_or_else(overflow)?;
720 let range = headers::Range::bytes(offset..=end).map_err(|_| overflow())?;
721 let mut map = reqwest::header::HeaderMap::new();
722 map.typed_insert(range);
723 req = req.headers(map);
724 }
725 Ok(req)
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 // Biting test for the u64-overflow defect (issue's `tokio_client.rs:497`
733 // finding): `build_request`'s old `offset + length.saturating_sub(1)`
734 // guarded only the `-1` underflow, not the addition itself. A byte range
735 // whose `offset` is near `u64::MAX` — reachable via a malicious/corrupt
736 // playlist's `BYTERANGE`, or a long-lived omitted-offset cursor walked
737 // up by `crate::client::HlsClient` — must be rejected, not panic (debug)
738 // or silently wrap to a bogus `Range:` header naming the wrong bytes
739 // (release).
740 //
741 // MUTATION VERIFIED: reverting to the original
742 // `let end = offset + length.saturating_sub(1);` (dropping
743 // `checked_add`/`Err`) makes this test fail: the debug build panics with
744 // "attempt to add with overflow" inside `build_request` before
745 // `expect_err` ever runs (confirmed by running it), rather than
746 // returning `TokioError::ByteRangeOverflow`. Recompiled and re-ran to
747 // observe that exact panic, then restored the checked_add.
748 #[test]
749 fn build_request_rejects_a_range_whose_end_overflows_u64() {
750 let client = Client::new();
751 let err = build_request(
752 &client,
753 "http://example.com/seg.m4s",
754 Some((u64::MAX - 1, 5)),
755 Duration::from_secs(5),
756 )
757 .expect_err("offset near u64::MAX + length must overflow and be rejected");
758 assert!(
759 matches!(
760 err,
761 TokioError::ByteRangeOverflow {
762 offset,
763 length: 5,
764 ..
765 } if offset == u64::MAX - 1
766 ),
767 "wrong error variant/fields: {err:?}"
768 );
769 }
770
771 // The flip side: an ordinary, non-overflowing byte range must still
772 // produce the expected `Range:` header — the overflow guard must not
773 // have broken the common case.
774 #[test]
775 fn build_request_sets_range_header_for_a_normal_byte_range() {
776 let client = Client::new();
777 let req = build_request(
778 &client,
779 "http://example.com/seg.m4s",
780 Some((10, 20)),
781 Duration::from_secs(5),
782 )
783 .expect("a normal byte range must succeed");
784 let built = req.build().expect("request builds");
785 let header = built
786 .headers()
787 .get(reqwest::header::RANGE)
788 .expect("Range header must be present");
789 assert_eq!(header, "bytes=10-29");
790 }
791
792 #[test]
793 fn retry_schedule_without_jitter_is_exponential_and_capped() {
794 let cfg = TokioClientConfig::default().with_jitter(false);
795 let got: Vec<_> = schedule(&cfg, None).take(6).collect();
796 let want = [200, 400, 800, 1600, 2000, 2000].map(Duration::from_millis);
797 // backon multiplies in `f32` (its factor is `f32`), so a doubled delay
798 // can be a few nanoseconds off the ideal; the cap is exact.
799 for (i, (g, w)) in got.iter().zip(want).enumerate() {
800 let diff = g.abs_diff(w);
801 assert!(
802 diff < Duration::from_micros(1),
803 "delay {i} = {g:?}, want {w:?} (min 200 ms, doubling, capped at 2 s)"
804 );
805 }
806 assert_eq!(got[4], Duration::from_secs(2), "capped exactly");
807 assert_eq!(got[5], Duration::from_secs(2), "stays at the cap");
808 }
809
810 #[test]
811 fn retry_schedule_jitter_stays_within_one_extra_delay_and_never_exceeds_the_cap() {
812 let cfg = TokioClientConfig::default();
813 let cap = cfg.max_retry_backoff;
814 // Many samples: jitter is random, a single draw proves little.
815 for _ in 0..500 {
816 for (i, got) in schedule(&cfg, None).take(8).enumerate() {
817 let base = Duration::from_millis(200u64 << i.min(4)).min(cap);
818 assert!(got >= base, "delay {i} = {got:?} below its base {base:?}");
819 assert!(
820 got <= cap,
821 "delay {i} = {got:?} exceeds max_retry_backoff {cap:?}"
822 );
823 assert!(
824 got < base * 2 || got == cap,
825 "delay {i} = {got:?} outside [{base:?}, {:?})",
826 base * 2
827 );
828 }
829 }
830 assert_eq!(schedule(&cfg, Some(2)).count(), 2, "3 attempts = 2 sleeps");
831 }
832
833 #[test]
834 fn client_errors_other_than_408_and_429_are_not_retried() {
835 let status = |status| TokioError::Status {
836 url: "http://h/x".into(),
837 status,
838 };
839 assert!(!is_retryable(&status(StatusCode::NOT_FOUND)));
840 assert!(!is_retryable(&status(StatusCode::FORBIDDEN)));
841 assert!(is_retryable(&status(StatusCode::REQUEST_TIMEOUT)));
842 assert!(is_retryable(&status(StatusCode::TOO_MANY_REQUESTS)));
843 assert!(is_retryable(&status(StatusCode::SERVICE_UNAVAILABLE)));
844 }
845
846 #[test]
847 fn credentials_origin_check_compares_scheme_host_and_port() {
848 let client = TokioClient::new("http://origin.example:8080/live/index.m3u8").unwrap();
849 assert!(client.is_playlist_origin("http://origin.example:8080/seg0.ts"));
850 assert!(client.is_playlist_origin("HTTP://Origin.Example:8080/a/b.ts"));
851 assert!(!client.is_playlist_origin("http://other.example:8080/seg0.ts"));
852 assert!(!client.is_playlist_origin("https://origin.example:8080/seg0.ts"));
853 assert!(!client.is_playlist_origin("http://origin.example/seg0.ts"));
854 assert!(!client.is_playlist_origin("not a url"));
855 // A default port written out is the same origin.
856 let client = TokioClient::new("https://origin.example/live/index.m3u8").unwrap();
857 assert!(client.is_playlist_origin("https://origin.example:443/seg0.ts"));
858 }
859
860 // Security-blocker regression (pre-release audit): `TokioClientConfig`
861 // derives `Debug` and embeds `Option<Credentials>` directly (`auth`) — it
862 // must inherit `Credentials`'s redacting `Debug`, never the raw secret.
863 #[test]
864 fn tokio_client_config_debug_does_not_leak_embedded_credentials_secret() {
865 let config = TokioClientConfig {
866 auth: Some(Credentials::new("admin", "a-very-secret-password")),
867 ..TokioClientConfig::default()
868 };
869 let debug = format!("{config:?}");
870 assert!(
871 !debug.contains("a-very-secret-password"),
872 "leaked via TokioClientConfig Debug: {debug}"
873 );
874 assert!(debug.contains("***"), "expected redaction marker: {debug}");
875 }
876}