use std::time::Duration;
use broadcast_auth::{Authenticator, Credentials, RequestContext};
use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
use reqwest::{Client, StatusCode};
use super::{Action, HlsClient, Output, ResourceId};
#[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),
}
#[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>,
}
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,
}
}
}
#[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 struct TokioClient {
core: HlsClient,
http: Client,
config: TokioClientConfig,
playlist_url: String,
stats: TokioClientStats,
last_preload_hint_url: Option<String>,
ended: bool,
digest_authenticator: Option<Authenticator>,
}
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()
.build()
.map_err(|source| TokioError::Http {
url: playlist_url.clone(),
source,
})?;
Ok(Self {
core: HlsClient::new(playlist_url.clone()),
http,
config,
playlist_url,
stats: TokioClientStats::default(),
last_preload_hint_url: None,
ended: false,
digest_authenticator: None,
})
}
pub fn stats(&self) -> TokioClientStats {
self.stats
}
pub async fn next_output(&mut self) -> Result<Option<Output>, TokioError> {
loop {
if let Some(out) = self.core.next_output() {
if matches!(out, Output::EndOfStream) {
self.ended = true;
}
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 = self.fetch_playlist_resilient(&request_url, timeout).await;
self.stats.playlist_fetches += 1;
if is_blocking {
self.stats.blocking_reloads += 1;
}
self.note_preload_hint(&bytes);
self.core.on_playlist(&bytes)?;
}
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;
}
self.core.on_resource(id, &bytes)?;
}
Err(_source) => {
self.core.on_error(Some(id));
}
},
Some(Action::WaitMs(ms)) => {
tokio::time::sleep(Duration::from_millis(ms)).await;
}
None => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
}
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 {
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() {
if 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> {
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,
})
}
async fn fetch_bytes(
&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 bytes = resp.bytes().await.map_err(|source| TokioError::Http {
url: url.to_string(),
source,
})?;
Ok(bytes.to_vec())
}
async fn fetch_playlist_resilient(&mut self, url: &str, timeout: Duration) -> Vec<u8> {
let mut backoff = self.config.retry_backoff;
loop {
match self.fetch_bytes(url, None, timeout).await {
Ok(bytes) => return bytes,
Err(_source) => {
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(self.config.max_retry_backoff);
}
}
}
}
async fn fetch_resource_bounded(
&mut self,
url: &str,
byte_range: Option<(u64, u64)>,
) -> Result<Vec<u8>, TokioError> {
let mut backoff = self.config.retry_backoff;
let mut last_err = None;
for _ in 0..self.config.max_resource_retries.max(1) {
match self
.fetch_bytes(url, byte_range, self.config.request_timeout)
.await
{
Ok(bytes) => return Ok(bytes),
Err(source) => {
last_err = Some(source);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(self.config.max_retry_backoff);
}
}
}
Err(last_err.expect("loop runs at least once (max_resource_retries.max(1))"))
}
}
fn build_request(
client: &Client,
url: &str,
byte_range: Option<(u64, u64)>,
timeout: Duration,
) -> reqwest::RequestBuilder {
let mut req = client.get(url).timeout(timeout);
if let Some((offset, length)) = byte_range {
let end = offset + length.saturating_sub(1);
req = req.header(reqwest::header::RANGE, format!("bytes={offset}-{end}"));
}
req
}
#[cfg(test)]
mod tests {
use super::*;
#[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}");
}
}