Skip to main content

moq_hls/
error.rs

1//! Errors for the HLS / LL-HLS gateway.
2
3/// Which HLS sequence counter a value belongs to.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum SequenceKind {
6	/// `EXT-X-MEDIA-SEQUENCE`: names a segment within the live window.
7	Media,
8	/// `EXT-X-DISCONTINUITY-SEQUENCE`: names a media timeline.
9	Discontinuity,
10}
11
12impl std::fmt::Display for SequenceKind {
13	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14		match self {
15			Self::Media => write!(f, "media"),
16			Self::Discontinuity => write!(f, "discontinuity"),
17		}
18	}
19}
20
21/// Whether an HTTP response status means "ask again later".
22///
23/// A response that arrived is the server's answer, and only this narrow set invites another
24/// attempt: request timeout, rate limit, and the gateway/overload statuses. Every other status,
25/// `404` and `403` included, is settled.
26pub(crate) fn status_retryable(status: u16) -> bool {
27	matches!(status, 408 | 429 | 502 | 503 | 504)
28}
29
30/// Errors produced by the HLS <-> MoQ gateway (import and export).
31#[derive(Debug, Clone, thiserror::Error)]
32#[non_exhaustive]
33pub enum Error {
34	/// Error from the underlying moq-net transport.
35	#[error("moq: {0}")]
36	Moq(#[from] moq_net::Error),
37
38	/// Error from the moq-mux CMAF import/export layer.
39	#[error("mux: {0}")]
40	Mux(#[from] moq_mux::Error),
41
42	/// An import resource or redirect hop left the playlist's origin, or the playlist uses an unsupported scheme.
43	#[error("resource is outside the playlist's origin")]
44	ResourceRefused,
45
46	/// A `file://` URL could not be turned back into a filesystem path.
47	#[error("invalid file URL")]
48	InvalidFileUrl,
49
50	/// The fetched HLS playlist could not be parsed.
51	#[error("failed to parse HLS playlist: {0}")]
52	ParsePlaylist(String),
53
54	/// The master playlist contained no variant this gateway can import.
55	#[error("no usable variants found in master playlist")]
56	NoVariants,
57
58	/// A media playlist had no `EXT-X-MAP`, so there is no CMAF init segment.
59	#[error("playlist missing EXT-X-MAP")]
60	MissingMap,
61
62	/// A media segment had an empty URI.
63	#[error("encountered segment with empty URI")]
64	EmptySegmentUri,
65
66	/// An implicit HLS byte range had no preceding range for the same resource.
67	#[error("implicit byte range for {url} has no preceding range for the same resource")]
68	MissingByteRangeOffset {
69		/// The resource whose range omitted an offset.
70		url: url::Url,
71	},
72
73	/// An HLS byte range was empty or overflowed its integer representation.
74	#[error("invalid byte range {start}+{length} for {url}")]
75	InvalidByteRange {
76		/// The ranged resource.
77		url: url::Url,
78		/// The first requested byte.
79		start: u64,
80		/// The requested byte count.
81		length: u64,
82	},
83
84	/// A ranged resource response had a different length than requested.
85	#[error("byte range for {url} returned {actual} bytes, expected {expected}")]
86	ByteRangeLengthMismatch {
87		/// The ranged resource.
88		url: url::Url,
89		/// The requested byte count.
90		expected: u64,
91		/// The returned byte count.
92		actual: usize,
93	},
94
95	/// A partial HTTP response identified a different byte range than requested.
96	#[error("byte range for {url} returned a mismatched Content-Range, expected bytes {start}-{end}")]
97	ByteRangeResponseMismatch {
98		/// The ranged resource.
99		url: url::Url,
100		/// The first requested byte.
101		start: u64,
102		/// The last requested byte, inclusive.
103		end: u64,
104	},
105
106	/// An HLS media or discontinuity sequence was too large to pack into a MoQ group sequence.
107	#[error("HLS {kind} sequence {value} is too large to encode")]
108	SequenceOverflow {
109		/// Which sequence overflowed.
110		kind: SequenceKind,
111		/// The offending sequence value.
112		value: u64,
113	},
114
115	/// An export generation had characters a URL path segment can't carry verbatim.
116	#[error("invalid generation {0:?}: expected ASCII letters, digits, '-', or '_'")]
117	InvalidGeneration(String),
118
119	/// A playlist or segment URI could not be resolved against its base.
120	#[error("url parse: {0}")]
121	UrlParse(#[from] url::ParseError),
122
123	/// HTTP error while fetching a playlist or segment.
124	#[error("reqwest: {0}")]
125	Reqwest(std::sync::Arc<reqwest::Error>),
126
127	/// I/O error while reading a local playlist or segment.
128	#[error("io: {0}")]
129	Io(std::sync::Arc<std::io::Error>),
130}
131
132impl Error {
133	/// The HTTP status the origin answered with, if it answered with one at all.
134	///
135	/// The import loop consults it: a `503` on a playlist fetch is worth another pass, a `404` is
136	/// the origin's settled answer. Nothing else here is classified; a failure with no status falls
137	/// through to the backoff budget.
138	pub fn status(&self) -> Option<u16> {
139		match self {
140			Self::Reqwest(err) => err.status().map(|status| status.as_u16()),
141			_ => None,
142		}
143	}
144}
145
146impl From<reqwest::Error> for Error {
147	fn from(err: reqwest::Error) -> Self {
148		// reqwest prints the full URL in its error, and a playlist or segment URL
149		// routinely carries a signed token in its query.
150		Error::Reqwest(std::sync::Arc::new(err.without_url()))
151	}
152}
153
154impl From<std::io::Error> for Error {
155	fn from(err: std::io::Error) -> Self {
156		Error::Io(std::sync::Arc::new(err))
157	}
158}
159
160/// Convenience alias for results from the HLS gateway.
161pub type Result<T> = std::result::Result<T, Error>;
162
163#[cfg(test)]
164mod tests {
165	use super::*;
166
167	/// The import loop consults this, so an origin's settled answer has to reach it intact.
168	#[test]
169	fn an_http_failure_reports_its_status() {
170		// A failure the origin never answered carries no status, so the budget decides instead.
171		assert_eq!(Error::NoVariants.status(), None);
172		assert_eq!(Error::ParsePlaylist("not a playlist".to_string()).status(), None);
173		assert_eq!(Error::Moq(moq_net::Error::Transport("lost".to_string())).status(), None);
174	}
175
176	#[tokio::test]
177	async fn http_error_redacts_url() {
178		// A freed port refuses the connection, so reqwest fails with the dialed URL attached.
179		let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
180		let address = listener.local_addr().expect("local address");
181		drop(listener);
182
183		let err: Error = reqwest::get(format!("http://user:pass@{address}/media.m3u8?jwt=secret"))
184			.await
185			.expect_err("dial a closed port")
186			.into();
187
188		let printed = format!("{err} {err:?}");
189		for secret in ["jwt", "secret", "user:pass"] {
190			assert!(!printed.contains(secret), "error leaked {secret}: {printed}");
191		}
192	}
193}