Skip to main content

moq_net/
error.rs

1use crate::coding;
2
3/// A code sent when terminating the session.
4///
5/// One of the two wire registries specified by moq-lite, which reuse moq-transport's codes
6/// unchanged; 64+ are the application's. The stream registry is [`StreamError`] and the two
7/// are disjoint, so the same integer means different things in each.
8///
9/// Every variant is a registered code, so the registry round-trips: 32 through 47 is
10/// reserved, nothing is sent there, and a received one stays [`Unknown`](Self::Unknown).
11#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum SessionError {
14	/// Ending the session normally, with no error.
15	#[error("no error")]
16	Cancel,
17
18	/// Something went wrong that isn't worth a dedicated code.
19	#[error("internal error")]
20	Internal,
21
22	/// The peer's token does not grant the requested path or operation. Retrying with the
23	/// same credentials will fail again.
24	#[error("unauthorized")]
25	Unauthorized,
26
27	/// The peer broke a protocol rule; the session is unusable.
28	#[error("protocol violation")]
29	ProtocolViolation,
30
31	/// A key-value pair was malformed or repeated more than allowed.
32	#[error("key-value formatting error")]
33	KeyValueFormatting,
34
35	/// The peer did not close within the GOAWAY drain deadline.
36	#[error("goaway timeout")]
37	GoawayTimeout,
38
39	/// A control message took too long.
40	#[error("control message timeout")]
41	Timeout,
42
43	/// No version could be negotiated.
44	#[error("version negotiation failed")]
45	Version,
46
47	/// An application-chosen code, offset into the 64+ range on the wire.
48	#[error("app code={0}")]
49	App(u16),
50
51	/// A code this version does not recognize, kept verbatim.
52	#[error("unknown code={0}")]
53	Unknown(u32),
54}
55
56impl SessionError {
57	/// The integer sent on the wire.
58	pub fn to_code(&self) -> u32 {
59		match self {
60			Self::Cancel => 0x0,
61			Self::Internal => 0x1,
62			Self::Unauthorized => 0x2,
63			Self::ProtocolViolation => 0x3,
64			Self::KeyValueFormatting => 0x6,
65			Self::GoawayTimeout => 0x10,
66			Self::Timeout => 0x11,
67			Self::Version => 0x15,
68			Self::App(app) => *app as u32 + 64,
69			Self::Unknown(code) => *code,
70		}
71	}
72
73	/// Decode a code received off the wire.
74	///
75	/// Unlike a raw peer code, the registered ones are specified, so decoding them is a
76	/// wire contract rather than an assumption. Anything unregistered stays
77	/// [`Self::Unknown`], including the reserved 32-47.
78	pub fn from_code(code: u32) -> Self {
79		match code {
80			0x0 => Self::Cancel,
81			0x1 => Self::Internal,
82			0x2 => Self::Unauthorized,
83			0x3 => Self::ProtocolViolation,
84			0x6 => Self::KeyValueFormatting,
85			0x10 => Self::GoawayTimeout,
86			0x11 => Self::Timeout,
87			0x15 => Self::Version,
88			code @ 64.. => match u16::try_from(code - 64) {
89				Ok(app) => Self::App(app),
90				Err(_) => Self::Unknown(code),
91			},
92			code => Self::Unknown(code),
93		}
94	}
95}
96
97/// A code sent when resetting a stream, or refusing to receive one.
98///
99/// The counterpart to [`SessionError`], and a disjoint space: a stream reset of 0 is
100/// [`Internal`](Self::Internal), not a cancellation ([`Cancel`](Self::Cancel) is 1).
101///
102/// Conditions the shared codes don't cover are assigned in moq-lite's own 48-63 range and
103/// round-trip like any other registered code. 32 through 47 is reserved: nothing is sent
104/// there and a received one stays [`Unknown`](Self::Unknown).
105#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum StreamError {
108	/// The session ended, taking this stream with it.
109	///
110	/// The specific [`SessionError`] is local only: the two registries are disjoint, so
111	/// this encodes to a single `SESSION_CLOSED` and the peer learns the reason from the
112	/// session close itself.
113	#[error("session closed: {0}")]
114	Session(#[from] SessionError),
115
116	/// Something went wrong that isn't worth a dedicated code.
117	#[error("internal error")]
118	Internal,
119
120	/// The sender is done with this stream, not failing. A routine unsubscribe.
121	#[error("cancelled")]
122	Cancel,
123
124	/// The content missed its delivery deadline.
125	#[error("delivery timeout")]
126	DeliveryTimeout,
127
128	/// A control request took too long to be answered. The stream counterpart to
129	/// [`SessionError::Timeout`], which covers the same condition at session scope.
130	#[error("control timeout")]
131	ControlTimeout,
132
133	/// The session is going away (a GOAWAY was received).
134	#[error("going away")]
135	GoingAway,
136
137	/// The reader fell too far behind and content was dropped to catch up.
138	#[error("too far behind")]
139	TooFarBehind,
140
141	/// The track's content could not be parsed.
142	#[error("malformed track")]
143	MalformedTrack,
144
145	/// The requested broadcast or track does not exist at the peer.
146	#[error("not found")]
147	NotFound,
148
149	/// The broadcast is neither announced nor served, so there is no route to it.
150	#[error("unroutable")]
151	Unroutable,
152
153	/// The group was superseded by a newer group and dropped.
154	#[error("old")]
155	Old,
156
157	/// The group was dropped under memory pressure. Unlike [`Old`](Self::Old) it was still
158	/// current, so it can be re-fetched.
159	#[error("evicted")]
160	Evicted,
161
162	/// A frame's payload length disagreed with its declared size.
163	#[error("wrong frame size")]
164	WrongSize,
165
166	/// A frame declared a payload larger than the receiver accepts.
167	#[error("frame too large")]
168	FrameTooLarge,
169
170	/// A group grew past its cache budget and was aborted.
171	#[error("group too large")]
172	GroupTooLarge,
173
174	/// A frame's timestamp doesn't match its track's negotiated timescale.
175	#[error("frame timestamp doesn't match track timescale")]
176	TimestampMismatch,
177
178	/// An application-chosen code, offset into the 64+ range on the wire.
179	#[error("app code={0}")]
180	App(u16),
181
182	/// A code this version does not recognize, kept verbatim.
183	#[error("unknown code={0}")]
184	Unknown(u32),
185}
186
187impl StreamError {
188	/// The integer sent on the wire.
189	pub fn to_code(&self) -> u32 {
190		match self {
191			Self::Internal => 0x0,
192			Self::Cancel => 0x1,
193			Self::DeliveryTimeout => 0x2,
194			// Flattened: the session registry is disjoint, so the specific reason
195			// doesn't fit here and travels on the session close instead.
196			Self::Session(_) => 0x3,
197			Self::GoingAway => 0x4,
198			Self::TooFarBehind => 0x5,
199			Self::MalformedTrack => 0x12,
200			// 0x30 NO_CAPACITY is assigned by other work in this range. Do not reuse it.
201			Self::ControlTimeout => 0x31,
202			Self::GroupTooLarge => 0x32,
203			Self::NotFound => 0x33,
204			Self::Old => 0x34,
205			Self::Evicted => 0x35,
206			Self::Unroutable => 0x36,
207			Self::WrongSize => 0x37,
208			Self::FrameTooLarge => 0x38,
209			Self::TimestampMismatch => 0x39,
210			Self::App(app) => *app as u32 + 64,
211			Self::Unknown(code) => *code,
212		}
213	}
214
215	/// Decode a code received off the wire.
216	///
217	/// Only the registered codes decode; anything else, the reserved 32-47 range included,
218	/// stays [`Unknown`](Self::Unknown) rather than being given a meaning the draft does
219	/// not assign.
220	///
221	/// `SESSION_CLOSED` decodes to `Session(SessionError::Internal)`: the peer's actual
222	/// session code is not on this stream, so the specific reason is unknown here.
223	pub fn from_code(code: u32) -> Self {
224		match code {
225			0x0 => Self::Internal,
226			0x1 => Self::Cancel,
227			0x2 => Self::DeliveryTimeout,
228			0x3 => Self::Session(SessionError::Internal),
229			0x4 => Self::GoingAway,
230			0x5 => Self::TooFarBehind,
231			0x12 => Self::MalformedTrack,
232			0x31 => Self::ControlTimeout,
233			0x32 => Self::GroupTooLarge,
234			0x33 => Self::NotFound,
235			0x34 => Self::Old,
236			0x35 => Self::Evicted,
237			0x36 => Self::Unroutable,
238			0x37 => Self::WrongSize,
239			0x38 => Self::FrameTooLarge,
240			0x39 => Self::TimestampMismatch,
241			code @ 64.. => match u16::try_from(code - 64) {
242				Ok(app) => Self::App(app),
243				Err(_) => Self::Unknown(code),
244			},
245			code => Self::Unknown(code),
246		}
247	}
248}
249
250/// Failures in this crate, both local conditions and codes received off the wire.
251///
252/// Local conditions are the flat variants (`Cancel`, `NotFound`, `Lagged`, and so
253/// on): this side decided what went wrong. Codes received from a peer are nested
254/// in [`Self::Session`] or [`Self::Stream`], preserving the registry and the numeric
255/// value. [`Self::Remote`] is an unrecognized request-rejection code (the IETF
256/// request registry), not a session or stream unknown.
257///
258/// The two registries are the wire. Mapping a local condition onto a code is
259/// [`SessionError::from`] / [`StreamError::from`]; folding the flat variants into
260/// those registries is a later decision.
261#[derive(thiserror::Error, Debug, Clone)]
262#[non_exhaustive]
263pub enum Error {
264	/// The underlying QUIC/WebTransport connection failed; carries the backend's message.
265	#[error("transport: {0}")]
266	Transport(String),
267
268	/// A message off the wire could not be parsed.
269	#[error(transparent)]
270	Decode(#[from] coding::DecodeError),
271
272	/// Version negotiation failed, or the negotiated version lacks a requested feature
273	/// (e.g. a FETCH against a version without fetch support). Mostly a connect-time
274	/// error, but the feature-gap case can surface mid-session, so it can't simply move
275	/// to a connect-only error type.
276	#[error("unsupported versions")]
277	Version,
278
279	/// A known stream type arrived where this version or state does not allow it. Closes
280	/// the session as a protocol violation, or resets just the stream when it can be
281	/// refused on its own.
282	#[error("unexpected stream type")]
283	UnexpectedStream,
284
285	/// An integer was too large for the QUIC varint range.
286	#[error(transparent)]
287	BoundsExceeded(#[from] coding::BoundsExceeded),
288
289	/// A path holds a segment no pattern can spell (`*` or `**`), so it can never
290	/// be announced or matched.
291	#[error("invalid path: {0}")]
292	InvalidPath(#[from] crate::InvalidPattern),
293
294	/// A duplicate ID was used
295	// The broadcast/track is a duplicate
296	#[error("duplicate")]
297	Duplicate,
298
299	/// Nobody is reading any more, so the producer stopped. Not a failure.
300	// Cancel is returned when there are no more readers.
301	#[error("cancelled")]
302	Cancel,
303
304	/// It took too long to open or transmit a stream.
305	#[error("timeout")]
306	Timeout,
307
308	/// The group is older than the latest group and dropped.
309	#[error("old")]
310	Old,
311
312	/// An application-chosen close code. Bounded to `u16` and offset past the library's
313	/// reserved range (`+ 64`) on the wire by [`SessionError::to_code`] /
314	/// [`StreamError::to_code`], so app codes never collide with protocol ones.
315	///
316	/// The width asymmetry with [`Self::Remote`] is deliberate: `App` is a code *this*
317	/// side chooses to send, while `Remote` carries a raw code *received* off the wire
318	/// that didn't map to a known variant, which can be any `u32`.
319	#[error("app code={0}")]
320	App(u16),
321
322	/// The requested broadcast or track does not exist at the peer.
323	#[error("not found")]
324	NotFound,
325
326	/// A broadcast was requested that is neither announced nor served by a dynamic
327	/// router, so there is no route to it.
328	#[error("unroutable")]
329	Unroutable,
330
331	/// A frame's payload length disagreed with its declared size.
332	#[error("wrong frame size")]
333	WrongSize,
334
335	/// The peer broke a protocol rule; the session is unusable.
336	#[error("protocol violation")]
337	ProtocolViolation,
338
339	/// The requested path or operation is not granted, either by the peer's token
340	/// or by the scope of the handle it was requested through.
341	#[error("unauthorized")]
342	Unauthorized,
343
344	/// A valid message arrived in a state where it is not allowed.
345	#[error("unexpected message")]
346	UnexpectedMessage,
347
348	/// The peer asked for a feature this endpoint does not implement.
349	#[error("unsupported")]
350	Unsupported,
351
352	/// A message could not be serialized for the negotiated version.
353	#[error(transparent)]
354	Encode(#[from] coding::EncodeError),
355
356	/// A message carried more parameters than this endpoint accepts.
357	#[error("too many parameters")]
358	TooManyParameters,
359
360	/// The peer offered an ALPN this endpoint doesn't recognize, so no version could be
361	/// negotiated. A connect-time error.
362	#[error("unknown ALPN: {0}")]
363	UnknownAlpn(String),
364
365	/// The producer was dropped without finishing, so the content is incomplete.
366	#[error("dropped")]
367	Dropped,
368
369	/// The handle was already closed by this side.
370	#[error("closed")]
371	Closed,
372
373	/// The reader asked for a frame the group never held: below
374	/// [`crate::group::Producer::start_at`], or skipped past a splice. Named from the
375	/// consumer's side; distinct from [`Self::GroupTooLarge`], which aborts the whole
376	/// group when a write exceeds the cache budget, and from [`Self::Evicted`], which
377	/// drops a whole group under the pool's memory pressure.
378	#[error("lagged")]
379	Lagged,
380
381	/// A frame declared a payload size larger than the receiver accepts.
382	#[error("frame too large")]
383	FrameTooLarge,
384
385	/// A write would grow the group past its cache budget (byte size or frame count).
386	/// The write is refused and the group is aborted, so every reader sees the same
387	/// failure rather than a prefix some of them missed.
388	#[error("group too large")]
389	GroupTooLarge,
390
391	/// A whole-frame write was refused because a frame is already open on the group.
392	///
393	/// A [`crate::group::Producer`] streaming a frame with `create_frame` blocks the
394	/// whole-frame writes on every clone of that producer until it finishes, since
395	/// appending around it would reorder the group.
396	#[error("frame already open")]
397	FrameOpen,
398
399	/// A frame's timestamp doesn't match its track's negotiated timescale: it's
400	/// missing on a timed track, present on an untimed track, or carries a
401	/// different scale than the track advertised.
402	#[error("frame timestamp doesn't match track timescale")]
403	TimestampMismatch,
404
405	/// The group was evicted by its own track to pay eviction debt under memory
406	/// pressure (see [`cache::Pool`](crate::cache::Pool)). Unlike [`Self::Old`],
407	/// the group was still within the publisher's window; it can be re-fetched.
408	#[error("evicted")]
409	Evicted,
410
411	/// The session is going away (a GOAWAY was received); new subscribe and
412	/// announce-interest requests are rejected while existing subscriptions
413	/// keep flowing.
414	#[error("going away")]
415	GoingAway,
416
417	/// The peer did not close the session within the GOAWAY drain deadline.
418	///
419	/// Sent as the session termination code when the draining side force-closes
420	/// after the advertised deadline expires (see [`crate::goaway::Goaway::timeout`]).
421	#[error("goaway timeout")]
422	GoawayTimeout,
423
424	/// The peer could not parse the track's content.
425	#[error("malformed track")]
426	MalformedTrack,
427
428	/// The stream was torn down because the session closed. The specific reason travels on
429	/// the session close, not here.
430	#[error("session closed")]
431	SessionClosed,
432
433	/// A session-scoped protocol error, with its registry and verbatim code.
434	///
435	/// Produced by [`from_transport`](Self::from_transport) for a session close, and by
436	/// converting a [`SessionError`]. Local conditions use the specific variants above.
437	#[error(transparent)]
438	Session(SessionError),
439
440	/// A stream-scoped protocol error, with its registry and verbatim code.
441	///
442	/// The stream counterpart to [`Self::Session`]. The two registries are disjoint, so
443	/// the same integer is a different failure in each.
444	#[error(transparent)]
445	Stream(StreamError),
446
447	/// An unrecognized request-rejection code (the IETF request registry).
448	///
449	/// Session and stream unknowns are [`Self::Session`] / [`Self::Stream`], not this.
450	#[error("remote error: code={0}")]
451	Remote(u32),
452}
453
454impl Error {
455	/// The session-scoped protocol error, if this was received as one.
456	pub fn session(&self) -> Option<&SessionError> {
457		match self {
458			Self::Session(err) => Some(err),
459			_ => None,
460		}
461	}
462
463	/// The stream-scoped protocol error, if this was received as one.
464	pub fn stream(&self) -> Option<&StreamError> {
465		match self {
466			Self::Stream(err) => Some(err),
467			_ => None,
468		}
469	}
470
471	/// Convert a transport error into an [Error], decoding session close and stream reset
472	/// codes through their respective registries.
473	///
474	/// The two spaces are disjoint, so which one applies depends on what failed: a session
475	/// close decodes via [`SessionError::from_code`], a stream reset via
476	/// [`StreamError::from_code`]. Reading a stream reset with the session table (or the
477	/// reverse) silently mistranslates, since e.g. 0 is "no error" for a session but an
478	/// internal error for a stream.
479	pub fn from_transport(err: impl web_transport_trait::Error) -> Self {
480		if let Some((code, _reason)) = err.session_error() {
481			return SessionError::from_code(code).into();
482		}
483
484		if let Some(code) = err.stream_error() {
485			return StreamError::from_code(code).into();
486		}
487
488		Self::Transport(err.to_string())
489	}
490}
491
492/// Preserve the session registry when carrying a protocol error.
493impl From<SessionError> for Error {
494	fn from(err: SessionError) -> Self {
495		Self::Session(err)
496	}
497}
498
499/// Preserve the stream registry when carrying a protocol error.
500impl From<StreamError> for Error {
501	fn from(err: StreamError) -> Self {
502		Self::Stream(err)
503	}
504}
505
506/// Which session code to send for a local error.
507///
508/// Lossy on purpose: [`Error`] describes what went wrong locally, while the registry is what
509/// the peer can act on. Anything stream-scoped reaching a session close is a bug on our side,
510/// so it degrades to [`SessionError::Internal`] rather than inventing a code.
511impl From<&Error> for SessionError {
512	fn from(err: &Error) -> Self {
513		match err {
514			Error::Session(err) => err.clone(),
515			// App codes share the 64+ range in both registries.
516			Error::Stream(StreamError::App(app)) => Self::App(*app),
517			// A stream-scoped code has no meaning in this registry; don't forward the number.
518			Error::Stream(_) => Self::Internal,
519			Error::Cancel | Error::Closed | Error::GoingAway | Error::SessionClosed => Self::Cancel,
520			Error::Unauthorized => Self::Unauthorized,
521			Error::Version | Error::UnknownAlpn(_) => Self::Version,
522			Error::TooManyParameters => Self::KeyValueFormatting,
523			Error::GoawayTimeout => Self::GoawayTimeout,
524			Error::Timeout => Self::Timeout,
525			Error::ProtocolViolation
526			| Error::UnexpectedMessage
527			| Error::UnexpectedStream
528			| Error::Duplicate
529			| Error::Decode(_)
530			| Error::Encode(_)
531			| Error::WrongSize
532			| Error::BoundsExceeded(_)
533			| Error::InvalidPath(_) => Self::ProtocolViolation,
534			Error::App(app) => Self::App(*app),
535			// A code we did not recognize, so we cannot say which space it came from.
536			// Forwarding it into this one risks landing on a value that IS registered here
537			// (a session 0x4 would become the stream's GOING_AWAY), and the draft already
538			// says an unrecognized code is an unspecified error. Send that instead.
539			Error::Remote(_) => Self::Internal,
540			_ => Self::Internal,
541		}
542	}
543}
544
545/// Which stream code to send for a local error.
546///
547/// Session-scoped conditions route through [`StreamError::Session`], which flattens to
548/// `SESSION_CLOSED` on the wire.
549impl From<&Error> for StreamError {
550	fn from(err: &Error) -> Self {
551		match err {
552			Error::Stream(err) => err.clone(),
553			// App codes share the 64+ range in both registries.
554			Error::Session(SessionError::App(app)) => Self::App(*app),
555			// A session-scoped code has no meaning in this registry; don't forward the number.
556			Error::Session(_) => Self::Internal,
557			Error::Cancel | Error::Closed => Self::Cancel,
558			Error::SessionClosed => Self::Session(SessionError::Cancel),
559			Error::Old => Self::Old,
560			Error::Evicted => Self::Evicted,
561			Error::Lagged => Self::TooFarBehind,
562			Error::NotFound => Self::NotFound,
563			Error::Unroutable => Self::Unroutable,
564			Error::WrongSize => Self::WrongSize,
565			Error::FrameTooLarge => Self::FrameTooLarge,
566			Error::GroupTooLarge => Self::GroupTooLarge,
567			Error::TimestampMismatch => Self::TimestampMismatch,
568			Error::Timeout => Self::DeliveryTimeout,
569			Error::GoingAway => Self::GoingAway,
570			// Our own parse failure is, from the peer's side, a malformed track.
571			Error::Decode(_) | Error::BoundsExceeded(_) | Error::InvalidPath(_) | Error::MalformedTrack => {
572				Self::MalformedTrack
573			}
574			Error::App(app) => Self::App(*app),
575			// See the SessionError impl: an unregistered code carries no registry, so
576			// re-sending the number could mistranslate it.
577			Error::Remote(_) => Self::Internal,
578			// Session-scoped: the peer learns the detail from the session close.
579			// A stream refused on its own is not a session failure, so it does not claim
580			// SESSION_CLOSED; there is no stream-scoped PROTOCOL_VIOLATION to send instead.
581			Error::UnexpectedStream => Self::Internal,
582			Error::Unauthorized
583			| Error::Version
584			| Error::UnknownAlpn(_)
585			| Error::TooManyParameters
586			| Error::GoawayTimeout
587			| Error::ProtocolViolation
588			| Error::UnexpectedMessage => Self::Session(SessionError::from(err)),
589			_ => Self::Internal,
590		}
591	}
592}
593
594impl web_transport_trait::Error for Error {
595	fn session_error(&self) -> Option<(u32, String)> {
596		None
597	}
598}
599
600/// A [`Result`](std::result::Result) with this crate's [`Error`].
601pub type Result<T> = std::result::Result<T, Error>;
602
603#[cfg(test)]
604mod tests {
605	use super::*;
606
607	// Both registries are wire contracts now (draft-lcurley-moq-lite, Error Codes), so
608	// every code we send must decode back to what we meant.
609	#[test]
610	fn session_codes_round_trip() {
611		// Only the registered codes are a wire contract, so only they round trip.
612		let registered = [
613			SessionError::Cancel,
614			SessionError::Internal,
615			SessionError::Unauthorized,
616			SessionError::ProtocolViolation,
617			SessionError::KeyValueFormatting,
618			SessionError::GoawayTimeout,
619			SessionError::Timeout,
620			SessionError::Version,
621			SessionError::App(0),
622			SessionError::App(404),
623		];
624		for err in registered {
625			assert_eq!(
626				SessionError::from_code(err.to_code()),
627				err,
628				"{err:?} did not round trip"
629			);
630		}
631
632		// The moq-transport codes we reuse must keep moq-transport's values.
633		assert_eq!(SessionError::Unauthorized.to_code(), 0x2);
634		assert_eq!(SessionError::GoawayTimeout.to_code(), 0x10);
635		assert_eq!(SessionError::Version.to_code(), 0x15);
636
637		// The reserved 32-47 range, and anything else unregistered, keeps its value instead
638		// of being given a meaning. A peer on the old placeholders (0x20-0x22) lands here.
639		for code in [0x1f, 0x20, 0x21, 0x22, 0x2f] {
640			assert_eq!(SessionError::from_code(code), SessionError::Unknown(code));
641		}
642
643		// A disallowed stream fails the session as a protocol violation, and resets just
644		// the stream as an internal error rather than claiming the session closed.
645		assert_eq!(
646			SessionError::from(&Error::UnexpectedStream),
647			SessionError::ProtocolViolation
648		);
649		assert_eq!(StreamError::from(&Error::UnexpectedStream), StreamError::Internal);
650	}
651
652	#[test]
653	fn stream_codes_round_trip() {
654		// Only the registered codes are a wire contract, so only they round trip.
655		let registered = [
656			StreamError::Internal,
657			StreamError::Cancel,
658			StreamError::DeliveryTimeout,
659			StreamError::ControlTimeout,
660			StreamError::GoingAway,
661			StreamError::TooFarBehind,
662			StreamError::MalformedTrack,
663			StreamError::GroupTooLarge,
664			StreamError::NotFound,
665			StreamError::Old,
666			StreamError::Evicted,
667			StreamError::Unroutable,
668			StreamError::WrongSize,
669			StreamError::FrameTooLarge,
670			StreamError::TimestampMismatch,
671			StreamError::App(7),
672		];
673		for err in registered {
674			assert_eq!(StreamError::from_code(err.to_code()), err, "{err:?} did not round trip");
675		}
676
677		// moq-lite's own 48-63 range, pinned to the draft's table. They stay off 0x30
678		// (NO_CAPACITY), which other work assigns.
679		for (err, code) in [
680			(StreamError::ControlTimeout, 0x31),
681			(StreamError::GroupTooLarge, 0x32),
682			(StreamError::NotFound, 0x33),
683			(StreamError::Old, 0x34),
684			(StreamError::Evicted, 0x35),
685			(StreamError::Unroutable, 0x36),
686			(StreamError::WrongSize, 0x37),
687			(StreamError::FrameTooLarge, 0x38),
688			(StreamError::TimestampMismatch, 0x39),
689		] {
690			assert_eq!(err.to_code(), code, "{err:?} moved off its assigned code");
691		}
692
693		// The reserved 32-47 range carries no meaning, including the values the four
694		// codes above used to be sent from: a peer still emitting them is not given one.
695		for code in 0x20..0x30 {
696			assert_eq!(StreamError::from_code(code), StreamError::Unknown(code));
697		}
698
699		// The spaces are disjoint: 0 is a cancellation for a session but an internal
700		// error for a stream, and a cancellation is 1 here.
701		assert_eq!(StreamError::Cancel.to_code(), 0x1);
702		assert_eq!(StreamError::from_code(0x0), StreamError::Internal);
703		assert_eq!(SessionError::from_code(0x0), SessionError::Cancel);
704
705		// A session close flattens to SESSION_CLOSED: the specific reason travels on the
706		// session close, not on the stream.
707		assert_eq!(StreamError::Session(SessionError::Unauthorized).to_code(), 0x3);
708		assert_eq!(
709			StreamError::from_code(0x3),
710			StreamError::Session(SessionError::Internal)
711		);
712	}
713
714	// A relay decodes a peer's code into `Error` and re-encodes it when it tears down the
715	// corresponding downstream stream. That hop must not change what the code means.
716	#[test]
717	fn relaying_a_code_does_not_change_registries() {
718		// SESSION_CLOSED used to decode into a session-space value, which came back out as
719		// 0x1: downstream read a routine CANCELLED for an upstream session teardown.
720		let relayed = StreamError::from(&Error::from(StreamError::from_code(0x3)));
721		assert_eq!(relayed.to_code(), 0x3);
722
723		// Registered stream codes survive the hop unchanged.
724		for code in [
725			0x0, 0x1, 0x2, 0x4, 0x5, 0x12, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39,
726		] {
727			let relayed = StreamError::from(&Error::from(StreamError::from_code(code)));
728			assert_eq!(relayed.to_code(), code, "stream {code:#x} changed across a relay");
729		}
730
731		// So do app codes, in both spaces.
732		assert_eq!(
733			StreamError::from(&Error::from(StreamError::from_code(64 + 7))).to_code(),
734			64 + 7
735		);
736		assert_eq!(
737			SessionError::from(&Error::from(SessionError::from_code(64 + 7))).to_code(),
738			64 + 7
739		);
740
741		// An unregistered code carries no registry, so it must not be re-sent as a number
742		// that means something here: session 0x4 and 0x5 are GOING_AWAY and TOO_FAR_BEHIND
743		// on a stream. Downgrade to the unspecified-error code instead.
744		for code in [0x4, 0x5, 0x1f] {
745			let crossed = StreamError::from(&Error::from(SessionError::from_code(code)));
746			assert_eq!(
747				crossed,
748				StreamError::Internal,
749				"session {code:#x} leaked into the stream space"
750			);
751		}
752	}
753
754	// The registry a code is read with depends on what failed, and picking the wrong one
755	// silently mistranslates rather than erroring.
756	#[test]
757	fn from_transport_selects_the_matching_registry() {
758		#[derive(Debug, thiserror::Error)]
759		#[error("failed")]
760		struct Failed {
761			session: Option<u32>,
762			stream: Option<u32>,
763		}
764
765		impl web_transport_trait::Error for Failed {
766			fn session_error(&self) -> Option<(u32, String)> {
767				self.session.map(|code| (code, "closed".to_string()))
768			}
769			fn stream_error(&self) -> Option<u32> {
770				self.stream
771			}
772		}
773
774		let session = |code| {
775			Error::from_transport(Failed {
776				session: Some(code),
777				stream: None,
778			})
779		};
780		let stream = |code| {
781			Error::from_transport(Failed {
782				session: None,
783				stream: Some(code),
784			})
785		};
786
787		// A MoQ-layer auth rejection is now classifiable, because the code is specified.
788		assert!(matches!(session(0x2), Error::Session(SessionError::Unauthorized)));
789		assert_eq!(session(0x2).session(), Some(&SessionError::Unauthorized));
790		assert!(matches!(session(0x0), Error::Session(SessionError::Cancel)));
791
792		// Same integer, different space: 0 ends a session cleanly but fails a stream.
793		assert!(matches!(stream(0x1), Error::Stream(StreamError::Cancel)));
794		assert_eq!(stream(0x1).stream(), Some(&StreamError::Cancel));
795		assert!(matches!(stream(0x0), Error::Stream(StreamError::Internal)));
796		assert!(matches!(stream(0x5), Error::Stream(StreamError::TooFarBehind)));
797		assert!(matches!(stream(0x32), Error::Stream(StreamError::GroupTooLarge)));
798
799		// Assigned lite codes round-trip to the named error. The reserved 32-47 range
800		// stays opaque: the draft forbids reading a meaning out of it.
801		assert!(matches!(stream(0x34), Error::Stream(StreamError::Old)));
802		assert!(matches!(stream(0x38), Error::Stream(StreamError::FrameTooLarge)));
803		assert!(matches!(stream(0x31), Error::Stream(StreamError::ControlTimeout)));
804		assert!(matches!(stream(0x22), Error::Stream(StreamError::Unknown(0x22))));
805		assert!(matches!(session(0x22), Error::Session(SessionError::Unknown(0x22))));
806
807		// Neither: the transport itself failed.
808		assert!(matches!(
809			Error::from_transport(Failed {
810				session: None,
811				stream: None
812			}),
813			Error::Transport(_)
814		));
815	}
816}