use std::time::Duration;
use backon::{BackoffBuilder, ExponentialBuilder};
use broadcast_auth::{Authenticator, Credentials, RequestContext};
use headers::HeaderMapExt;
use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
use reqwest::{Client, StatusCode};
use tokio_util::sync::CancellationToken;
use super::{Action, HlsClient, Output, ResourceId};
pub const DEFAULT_MAX_BODY_BYTES: usize = 256 * 1024 * 1024;
const MAX_ROUNDS_WITHOUT_OUTPUT: u32 = 16;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TokioError {
#[error("HTTP request to {url} failed: {source}")]
Http {
url: String,
#[source]
source: reqwest::Error,
},
#[error("HTTP {status} fetching {url}")]
Status {
url: String,
status: reqwest::StatusCode,
},
#[error(transparent)]
Client(#[from] super::Error),
#[error("auth challenge/response failed: {0}")]
Auth(#[from] broadcast_auth::Error),
#[error("byte range for {url} overflows u64: offset {offset} + length {length}")]
ByteRangeOverflow {
url: String,
offset: u64,
length: u64,
},
#[error("the HLS core queued no action and the stream has not ended")]
Stalled,
#[error("response body of {url} exceeds {limit} bytes")]
BodyTooLarge {
url: String,
limit: usize,
},
#[doc(hidden)]
#[error("cancelled")]
Cancelled,
}
#[derive(Debug, Clone)]
pub struct TokioClientConfig {
pub request_timeout: Duration,
pub blocking_timeout: Duration,
pub max_resource_retries: u32,
pub retry_backoff: Duration,
pub max_retry_backoff: Duration,
pub auth: Option<Credentials>,
pub connect_timeout: Duration,
pub jitter: bool,
pub cancel: CancellationToken,
pub max_body_bytes: usize,
}
impl TokioClientConfig {
#[must_use]
pub fn with_auth(mut self, auth: Credentials) -> Self {
self.auth = Some(auth);
self
}
#[must_use]
pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
self.cancel = cancel;
self
}
#[must_use]
pub fn with_jitter(mut self, on: bool) -> Self {
self.jitter = on;
self
}
}
impl Default for TokioClientConfig {
fn default() -> Self {
Self {
request_timeout: Duration::from_secs(5),
blocking_timeout: Duration::from_secs(10),
max_resource_retries: 3,
retry_backoff: Duration::from_millis(200),
max_retry_backoff: Duration::from_secs(2),
auth: None,
connect_timeout: Duration::from_secs(10),
jitter: true,
cancel: CancellationToken::new(),
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TokioClientStats {
pub playlist_fetches: u64,
pub blocking_reloads: u64,
pub resource_fetches: u64,
pub preload_hint_resource_fetches: u64,
pub rejected_responses: u64,
}
pub struct TokioClient {
core: HlsClient,
http: Client,
config: TokioClientConfig,
playlist_url: String,
playlist_origin_url: Option<reqwest::Url>,
stats: TokioClientStats,
last_preload_hint_url: Option<String>,
ended: bool,
digest_authenticator: Option<Authenticator>,
last_rejection: Option<super::Error>,
rounds_without_output: u32,
}
impl TokioClient {
pub fn new(playlist_url: impl Into<String>) -> Result<Self, TokioError> {
Self::with_config(playlist_url, TokioClientConfig::default())
}
pub fn with_config(
playlist_url: impl Into<String>,
config: TokioClientConfig,
) -> Result<Self, TokioError> {
let playlist_url = playlist_url.into();
let http = Client::builder()
.connect_timeout(config.connect_timeout)
.build()
.map_err(|source| TokioError::Http {
url: playlist_url.clone(),
source,
})?;
Ok(Self {
core: HlsClient::new(playlist_url.clone()),
http,
config,
playlist_origin_url: reqwest::Url::parse(&playlist_url).ok(),
playlist_url,
stats: TokioClientStats::default(),
last_preload_hint_url: None,
ended: false,
digest_authenticator: None,
last_rejection: None,
rounds_without_output: 0,
})
}
pub fn stats(&self) -> TokioClientStats {
self.stats
}
pub fn last_rejection(&self) -> Option<&super::Error> {
self.last_rejection.as_ref()
}
pub async fn next_output(&mut self) -> Result<Option<Output>, TokioError> {
loop {
if self.config.cancel.is_cancelled() {
return Ok(None);
}
if let Some(out) = self.core.next_output() {
if matches!(out, Output::EndOfStream) {
self.ended = true;
}
self.rounds_without_output = 0;
return Ok(Some(out));
}
if self.ended {
return Ok(None);
}
match self.core.poll() {
Some(action @ Action::FetchPlaylist { .. }) => {
let request_url = action
.playlist_request_url()
.expect("Action::FetchPlaylist always has a playlist_request_url");
let is_blocking = matches!(
action,
Action::FetchPlaylist {
blocking: Some(_),
..
}
);
let timeout = if is_blocking {
self.config.blocking_timeout
} else {
self.config.request_timeout
};
let bytes = match self.fetch_playlist_resilient(&request_url, timeout).await {
Ok(bytes) => bytes,
Err(TokioError::Cancelled) => return Ok(None),
Err(other) => return Err(other),
};
self.stats.playlist_fetches += 1;
if is_blocking {
self.stats.blocking_reloads += 1;
}
self.note_preload_hint(&bytes);
if let Err(rejected) = self.core.on_playlist(&bytes) {
self.core.ensure_playlist_reload();
if self.reject(rejected).await? {
return Ok(None);
}
}
}
Some(Action::FetchResource {
id,
url,
byte_range,
}) => match self.fetch_resource_bounded(&url, byte_range).await {
Ok(bytes) => {
self.stats.resource_fetches += 1;
if matches!(id, ResourceId::Part { .. })
&& self.last_preload_hint_url.as_deref() == Some(url.as_str())
{
self.stats.preload_hint_resource_fetches += 1;
}
if let Err(rejected) = self.core.on_resource(id, &bytes)
&& self.reject(rejected).await?
{
return Ok(None);
}
}
Err(TokioError::Cancelled) => return Ok(None),
Err(_source) => {
self.core.on_error(Some(id));
}
},
Some(Action::FetchKey { id, url }) => {
match self.fetch_resource_bounded(&url, None).await {
Ok(bytes) => {
if let Err(rejected) = self.core.on_key(id, &bytes)
&& self.reject(rejected).await?
{
return Ok(None);
}
}
Err(TokioError::Cancelled) => return Ok(None),
Err(_source) => self.core.on_key_error(id),
}
}
Some(Action::WaitMs(ms)) => {
if self.sleep_or_cancelled(Duration::from_millis(ms)).await {
return Ok(None);
}
}
None => {
if self.core.retry_init() || self.core.ensure_playlist_reload() {
if self.idle_round().await? {
return Ok(None);
}
} else {
return Err(TokioError::Stalled);
}
}
}
}
}
async fn reject(&mut self, rejected: super::Error) -> Result<bool, TokioError> {
self.stats.rejected_responses += 1;
if self.rounds_without_output >= MAX_ROUNDS_WITHOUT_OUTPUT {
return Err(TokioError::Client(rejected));
}
self.last_rejection = Some(rejected);
self.back_off().await
}
async fn idle_round(&mut self) -> Result<bool, TokioError> {
if self.rounds_without_output >= MAX_ROUNDS_WITHOUT_OUTPUT {
return Err(TokioError::Stalled);
}
self.back_off().await
}
async fn back_off(&mut self) -> Result<bool, TokioError> {
let round = usize::try_from(self.rounds_without_output).unwrap_or(usize::MAX);
self.rounds_without_output = self.rounds_without_output.saturating_add(1);
let delay = schedule(&self.config, None)
.nth(round)
.unwrap_or(self.config.max_retry_backoff);
Ok(self.sleep_or_cancelled(delay).await)
}
async fn sleep_or_cancelled(&self, d: Duration) -> bool {
tokio::select! {
() = tokio::time::sleep(d) => false,
() = self.config.cancel.cancelled() => true,
}
}
fn note_preload_hint(&mut self, playlist_bytes: &[u8]) {
self.last_preload_hint_url = core::str::from_utf8(playlist_bytes)
.ok()
.and_then(|text| broadcast_hls::MediaPlaylist::parse(text).ok())
.and_then(|pl| pl.low_latency)
.and_then(|ll| ll.preload_hint_part)
.map(|hint| super::url::resolve(&self.playlist_url, &hint));
}
fn apply_auth_preemptive(
&mut self,
req: reqwest::RequestBuilder,
method: &str,
uri: &str,
) -> reqwest::RequestBuilder {
if !self.is_playlist_origin(uri) {
return req;
}
match &self.config.auth {
Some(Credentials::Basic { username, password }) => {
req.basic_auth(username, Some(password))
}
Some(Credentials::Bearer { token }) => req.bearer_auth(token),
Some(_) => {
if let Some(auth) = self.digest_authenticator.as_mut()
&& let Ok(value) = auth.authorization(&RequestContext::new(method, uri))
{
return req.header(AUTHORIZATION, value);
}
req
}
None => req,
}
}
async fn retry_after_unauthorized(
&mut self,
method: &str,
uri: &str,
req: reqwest::RequestBuilder,
response: reqwest::Response,
) -> Result<reqwest::Response, TokioError> {
if !self.is_playlist_origin(uri) {
return Ok(response);
}
let Some(creds @ Credentials::Digest { .. }) = self.config.auth.clone() else {
return Ok(response);
};
let Some(challenge) = response
.headers()
.get(WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
else {
return Ok(response);
};
let mut authenticator = Authenticator::from_challenge(&challenge, creds)?;
let value = authenticator.authorization(&RequestContext::new(method, uri))?;
self.digest_authenticator = Some(authenticator);
req.header(AUTHORIZATION, value)
.send()
.await
.map_err(|source| TokioError::Http {
url: uri.to_string(),
source,
})
}
fn is_playlist_origin(&self, url: &str) -> bool {
match (&self.playlist_origin_url, reqwest::Url::parse(url)) {
(Some(playlist), Ok(url)) => playlist.origin() == url.origin(),
_ => false,
}
}
async fn fetch_bytes(
&mut self,
url: &str,
byte_range: Option<(u64, u64)>,
timeout: Duration,
) -> Result<Vec<u8>, TokioError> {
let cancel = self.config.cancel.clone();
tokio::select! {
r = self.fetch_bytes_inner(url, byte_range, timeout) => r,
() = cancel.cancelled() => Err(TokioError::Cancelled),
}
}
async fn fetch_bytes_inner(
&mut self,
url: &str,
byte_range: Option<(u64, u64)>,
timeout: Duration,
) -> Result<Vec<u8>, TokioError> {
let req = self.apply_auth_preemptive(
build_request(&self.http, url, byte_range, timeout)?,
"GET",
url,
);
let resp = req.send().await.map_err(|source| TokioError::Http {
url: url.to_string(),
source,
})?;
let resp = if resp.status() == StatusCode::UNAUTHORIZED {
let retry_req = build_request(&self.http, url, byte_range, timeout)?;
self.retry_after_unauthorized("GET", url, retry_req, resp)
.await?
} else {
resp
};
let status = resp.status();
if !status.is_success() {
return Err(TokioError::Status {
url: url.to_string(),
status,
});
}
let limit = self.config.max_body_bytes;
let too_large = || TokioError::BodyTooLarge {
url: url.to_string(),
limit,
};
if resp
.content_length()
.is_some_and(|len| len > u64::try_from(limit).unwrap_or(u64::MAX))
{
return Err(too_large());
}
let mut resp = resp;
let mut body = Vec::new();
while let Some(chunk) = resp.chunk().await.map_err(|source| TokioError::Http {
url: url.to_string(),
source,
})? {
if body.len().saturating_add(chunk.len()) > limit {
return Err(too_large());
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
async fn fetch_playlist_resilient(
&mut self,
url: &str,
timeout: Duration,
) -> Result<Vec<u8>, TokioError> {
let mut delays = schedule(&self.config, None);
let mut current = url.to_string();
loop {
match self.fetch_bytes(¤t, None, timeout).await {
Ok(bytes) => return Ok(bytes),
Err(TokioError::Cancelled) => return Err(TokioError::Cancelled),
Err(source) if current != self.playlist_url && !is_retryable(&source) => {
current.clone_from(&self.playlist_url);
}
Err(_source) => {
let d = delays.next().unwrap_or(self.config.max_retry_backoff);
if self.sleep_or_cancelled(d).await {
return Err(TokioError::Cancelled);
}
}
}
}
}
async fn fetch_resource_bounded(
&mut self,
url: &str,
byte_range: Option<(u64, u64)>,
) -> Result<Vec<u8>, TokioError> {
let attempts = usize::try_from(self.config.max_resource_retries.max(1)).unwrap_or(1);
let mut delays = schedule(&self.config, Some(attempts - 1));
let mut last_err = None;
for _ in 0..attempts {
match self
.fetch_bytes(url, byte_range, self.config.request_timeout)
.await
{
Ok(bytes) => return Ok(bytes),
Err(source @ TokioError::Cancelled) => return Err(source),
Err(source) if !is_retryable(&source) => return Err(source),
Err(source) => {
last_err = Some(source);
let Some(d) = delays.next() else { break };
if self.sleep_or_cancelled(d).await {
return Err(TokioError::Cancelled);
}
}
}
}
Err(last_err.expect("loop runs at least once (attempts >= 1)"))
}
}
fn schedule(
config: &TokioClientConfig,
max_sleeps: Option<usize>,
) -> impl Iterator<Item = Duration> + use<> {
let mut builder = ExponentialBuilder::default()
.with_min_delay(config.retry_backoff)
.with_max_delay(config.max_retry_backoff);
if config.jitter {
builder = builder.with_jitter();
}
builder = match max_sleeps {
Some(n) => builder.with_max_times(n),
None => builder.without_max_times(),
};
let cap = config.max_retry_backoff;
builder.build().map(move |d| d.min(cap))
}
fn is_retryable(err: &TokioError) -> bool {
match err {
TokioError::BodyTooLarge { .. } => false,
TokioError::Status { status, .. } => {
!status.is_client_error()
|| *status == StatusCode::REQUEST_TIMEOUT
|| *status == StatusCode::TOO_MANY_REQUESTS
}
_ => true,
}
}
fn build_request(
client: &Client,
url: &str,
byte_range: Option<(u64, u64)>,
timeout: Duration,
) -> Result<reqwest::RequestBuilder, TokioError> {
let mut req = client.get(url).timeout(timeout);
if let Some((offset, length)) = byte_range {
let overflow = || TokioError::ByteRangeOverflow {
url: url.to_string(),
offset,
length,
};
let end = offset
.checked_add(length.saturating_sub(1))
.ok_or_else(overflow)?;
let range = headers::Range::bytes(offset..=end).map_err(|_| overflow())?;
let mut map = reqwest::header::HeaderMap::new();
map.typed_insert(range);
req = req.headers(map);
}
Ok(req)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_request_rejects_a_range_whose_end_overflows_u64() {
let client = Client::new();
let err = build_request(
&client,
"http://example.com/seg.m4s",
Some((u64::MAX - 1, 5)),
Duration::from_secs(5),
)
.expect_err("offset near u64::MAX + length must overflow and be rejected");
assert!(
matches!(
err,
TokioError::ByteRangeOverflow {
offset,
length: 5,
..
} if offset == u64::MAX - 1
),
"wrong error variant/fields: {err:?}"
);
}
#[test]
fn build_request_sets_range_header_for_a_normal_byte_range() {
let client = Client::new();
let req = build_request(
&client,
"http://example.com/seg.m4s",
Some((10, 20)),
Duration::from_secs(5),
)
.expect("a normal byte range must succeed");
let built = req.build().expect("request builds");
let header = built
.headers()
.get(reqwest::header::RANGE)
.expect("Range header must be present");
assert_eq!(header, "bytes=10-29");
}
#[test]
fn retry_schedule_without_jitter_is_exponential_and_capped() {
let cfg = TokioClientConfig::default().with_jitter(false);
let got: Vec<_> = schedule(&cfg, None).take(6).collect();
let want = [200, 400, 800, 1600, 2000, 2000].map(Duration::from_millis);
for (i, (g, w)) in got.iter().zip(want).enumerate() {
let diff = g.abs_diff(w);
assert!(
diff < Duration::from_micros(1),
"delay {i} = {g:?}, want {w:?} (min 200 ms, doubling, capped at 2 s)"
);
}
assert_eq!(got[4], Duration::from_secs(2), "capped exactly");
assert_eq!(got[5], Duration::from_secs(2), "stays at the cap");
}
#[test]
fn retry_schedule_jitter_stays_within_one_extra_delay_and_never_exceeds_the_cap() {
let cfg = TokioClientConfig::default();
let cap = cfg.max_retry_backoff;
for _ in 0..500 {
for (i, got) in schedule(&cfg, None).take(8).enumerate() {
let base = Duration::from_millis(200u64 << i.min(4)).min(cap);
assert!(got >= base, "delay {i} = {got:?} below its base {base:?}");
assert!(
got <= cap,
"delay {i} = {got:?} exceeds max_retry_backoff {cap:?}"
);
assert!(
got < base * 2 || got == cap,
"delay {i} = {got:?} outside [{base:?}, {:?})",
base * 2
);
}
}
assert_eq!(schedule(&cfg, Some(2)).count(), 2, "3 attempts = 2 sleeps");
}
#[test]
fn client_errors_other_than_408_and_429_are_not_retried() {
let status = |status| TokioError::Status {
url: "http://h/x".into(),
status,
};
assert!(!is_retryable(&status(StatusCode::NOT_FOUND)));
assert!(!is_retryable(&status(StatusCode::FORBIDDEN)));
assert!(is_retryable(&status(StatusCode::REQUEST_TIMEOUT)));
assert!(is_retryable(&status(StatusCode::TOO_MANY_REQUESTS)));
assert!(is_retryable(&status(StatusCode::SERVICE_UNAVAILABLE)));
}
#[test]
fn credentials_origin_check_compares_scheme_host_and_port() {
let client = TokioClient::new("http://origin.example:8080/live/index.m3u8").unwrap();
assert!(client.is_playlist_origin("http://origin.example:8080/seg0.ts"));
assert!(client.is_playlist_origin("HTTP://Origin.Example:8080/a/b.ts"));
assert!(!client.is_playlist_origin("http://other.example:8080/seg0.ts"));
assert!(!client.is_playlist_origin("https://origin.example:8080/seg0.ts"));
assert!(!client.is_playlist_origin("http://origin.example/seg0.ts"));
assert!(!client.is_playlist_origin("not a url"));
let client = TokioClient::new("https://origin.example/live/index.m3u8").unwrap();
assert!(client.is_playlist_origin("https://origin.example:443/seg0.ts"));
}
#[test]
fn tokio_client_config_debug_does_not_leak_embedded_credentials_secret() {
let config = TokioClientConfig {
auth: Some(Credentials::new("admin", "a-very-secret-password")),
..TokioClientConfig::default()
};
let debug = format!("{config:?}");
assert!(
!debug.contains("a-very-secret-password"),
"leaked via TokioClientConfig Debug: {debug}"
);
assert!(debug.contains("***"), "expected redaction marker: {debug}");
}
}