#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SequenceKind {
Media,
Discontinuity,
}
impl std::fmt::Display for SequenceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Media => write!(f, "media"),
Self::Discontinuity => write!(f, "discontinuity"),
}
}
}
pub(crate) fn status_retryable(status: u16) -> bool {
matches!(status, 408 | 429 | 502 | 503 | 504)
}
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("moq: {0}")]
Moq(#[from] moq_net::Error),
#[error("mux: {0}")]
Mux(#[from] moq_mux::Error),
#[error("resource is outside the playlist's origin")]
ResourceRefused,
#[error("invalid file URL")]
InvalidFileUrl,
#[error("failed to parse HLS playlist: {0}")]
ParsePlaylist(String),
#[error("no usable variants found in master playlist")]
NoVariants,
#[error("playlist missing EXT-X-MAP")]
MissingMap,
#[error("encountered segment with empty URI")]
EmptySegmentUri,
#[error("implicit byte range for {url} has no preceding range for the same resource")]
MissingByteRangeOffset {
url: url::Url,
},
#[error("invalid byte range {start}+{length} for {url}")]
InvalidByteRange {
url: url::Url,
start: u64,
length: u64,
},
#[error("byte range for {url} returned {actual} bytes, expected {expected}")]
ByteRangeLengthMismatch {
url: url::Url,
expected: u64,
actual: usize,
},
#[error("byte range for {url} returned a mismatched Content-Range, expected bytes {start}-{end}")]
ByteRangeResponseMismatch {
url: url::Url,
start: u64,
end: u64,
},
#[error("HLS {kind} sequence {value} is too large to encode")]
SequenceOverflow {
kind: SequenceKind,
value: u64,
},
#[error("invalid generation {0:?}: expected ASCII letters, digits, '-', or '_'")]
InvalidGeneration(String),
#[error("url parse: {0}")]
UrlParse(#[from] url::ParseError),
#[error("reqwest: {0}")]
Reqwest(std::sync::Arc<reqwest::Error>),
#[error("io: {0}")]
Io(std::sync::Arc<std::io::Error>),
}
impl Error {
pub fn status(&self) -> Option<u16> {
match self {
Self::Reqwest(err) => err.status().map(|status| status.as_u16()),
_ => None,
}
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Error::Reqwest(std::sync::Arc::new(err.without_url()))
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Error::Io(std::sync::Arc::new(err))
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_http_failure_reports_its_status() {
assert_eq!(Error::NoVariants.status(), None);
assert_eq!(Error::ParsePlaylist("not a playlist".to_string()).status(), None);
assert_eq!(Error::Moq(moq_net::Error::Transport("lost".to_string())).status(), None);
}
#[tokio::test]
async fn http_error_redacts_url() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let address = listener.local_addr().expect("local address");
drop(listener);
let err: Error = reqwest::get(format!("http://user:pass@{address}/media.m3u8?jwt=secret"))
.await
.expect_err("dial a closed port")
.into();
let printed = format!("{err} {err:?}");
for secret in ["jwt", "secret", "user:pass"] {
assert!(!printed.contains(secret), "error leaked {secret}: {printed}");
}
}
}