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 joining FETCH named a request that is not an active subscription.
327	#[error("invalid joining request ID")]
328	InvalidJoiningRequestId,
329
330	/// A FETCH range is empty or lies beyond the published objects.
331	#[error("invalid fetch range")]
332	InvalidRange,
333
334	/// A broadcast was requested that is neither announced nor served by a dynamic
335	/// router, so there is no route to it.
336	#[error("unroutable")]
337	Unroutable,
338
339	/// A frame's payload length disagreed with its declared size.
340	#[error("wrong frame size")]
341	WrongSize,
342
343	/// The peer broke a protocol rule; the session is unusable.
344	#[error("protocol violation")]
345	ProtocolViolation,
346
347	/// The requested path or operation is not granted, either by the peer's token
348	/// or by the scope of the handle it was requested through.
349	#[error("unauthorized")]
350	Unauthorized,
351
352	/// A valid message arrived in a state where it is not allowed.
353	#[error("unexpected message")]
354	UnexpectedMessage,
355
356	/// The peer asked for a feature this endpoint does not implement.
357	#[error("unsupported")]
358	Unsupported,
359
360	/// A message could not be serialized for the negotiated version.
361	#[error(transparent)]
362	Encode(#[from] coding::EncodeError),
363
364	/// A message carried more parameters than this endpoint accepts.
365	#[error("too many parameters")]
366	TooManyParameters,
367
368	/// The peer offered an ALPN this endpoint doesn't recognize, so no version could be
369	/// negotiated. A connect-time error.
370	#[error("unknown ALPN: {0}")]
371	UnknownAlpn(String),
372
373	/// The producer was dropped without finishing, so the content is incomplete.
374	#[error("dropped")]
375	Dropped,
376
377	/// The handle was already closed by this side.
378	#[error("closed")]
379	Closed,
380
381	/// The reader asked for a frame the group never held: below
382	/// [`crate::group::Producer::start_at`], or skipped past a splice. Named from the
383	/// consumer's side; distinct from [`Self::GroupTooLarge`], which aborts the whole
384	/// group when a write exceeds the cache budget, and from [`Self::Evicted`], which
385	/// drops a whole group under the pool's memory pressure.
386	#[error("lagged")]
387	Lagged,
388
389	/// A frame declared a payload size larger than the receiver accepts.
390	#[error("frame too large")]
391	FrameTooLarge,
392
393	/// A write would grow the group past its cache budget (byte size or frame count).
394	/// The write is refused and the group is aborted, so every reader sees the same
395	/// failure rather than a prefix some of them missed.
396	#[error("group too large")]
397	GroupTooLarge,
398
399	/// A whole-frame write was refused because a frame is already open on the group.
400	///
401	/// A [`crate::group::Producer`] streaming a frame with `create_frame` blocks the
402	/// whole-frame writes on every clone of that producer until it finishes, since
403	/// appending around it would reorder the group.
404	#[error("frame already open")]
405	FrameOpen,
406
407	/// A frame's timestamp doesn't match its track's negotiated timescale: it's
408	/// missing on a timed track, present on an untimed track, or carries a
409	/// different scale than the track advertised.
410	#[error("frame timestamp doesn't match track timescale")]
411	TimestampMismatch,
412
413	/// The group was evicted by its own track to pay eviction debt under memory
414	/// pressure (see [`cache::Pool`](crate::cache::Pool)). Unlike [`Self::Old`],
415	/// the group was still within the publisher's window; it can be re-fetched.
416	#[error("evicted")]
417	Evicted,
418
419	/// The session is going away (a GOAWAY was received); new subscribe and
420	/// announce-interest requests are rejected while existing subscriptions
421	/// keep flowing.
422	#[error("going away")]
423	GoingAway,
424
425	/// The peer did not close the session within the GOAWAY drain deadline.
426	///
427	/// Sent as the session termination code when the draining side force-closes
428	/// after the advertised deadline expires (see [`crate::goaway::Goaway::timeout`]).
429	#[error("goaway timeout")]
430	GoawayTimeout,
431
432	/// The peer could not parse the track's content.
433	#[error("malformed track")]
434	MalformedTrack,
435
436	/// The stream was torn down because the session closed. The specific reason travels on
437	/// the session close, not here.
438	#[error("session closed")]
439	SessionClosed,
440
441	/// A session-scoped protocol error, with its registry and verbatim code.
442	///
443	/// Produced by [`from_transport`](Self::from_transport) for a session close, and by
444	/// converting a [`SessionError`]. Local conditions use the specific variants above.
445	#[error(transparent)]
446	Session(SessionError),
447
448	/// A stream-scoped protocol error, with its registry and verbatim code.
449	///
450	/// The stream counterpart to [`Self::Session`]. The two registries are disjoint, so
451	/// the same integer is a different failure in each.
452	#[error(transparent)]
453	Stream(StreamError),
454
455	/// An unrecognized request-rejection code (the IETF request registry).
456	///
457	/// Session and stream unknowns are [`Self::Session`] / [`Self::Stream`], not this.
458	#[error("remote error: code={0}")]
459	Remote(u32),
460}
461
462impl Error {
463	/// The session-scoped protocol error, if this was received as one.
464	pub fn session(&self) -> Option<&SessionError> {
465		match self {
466			Self::Session(err) => Some(err),
467			_ => None,
468		}
469	}
470
471	/// The stream-scoped protocol error, if this was received as one.
472	pub fn stream(&self) -> Option<&StreamError> {
473		match self {
474			Self::Stream(err) => Some(err),
475			_ => None,
476		}
477	}
478
479	/// Convert a transport error into an [Error], decoding session close and stream reset
480	/// codes through their respective registries.
481	///
482	/// The two spaces are disjoint, so which one applies depends on what failed: a session
483	/// close decodes via [`SessionError::from_code`], a stream reset via
484	/// [`StreamError::from_code`]. Reading a stream reset with the session table (or the
485	/// reverse) silently mistranslates, since e.g. 0 is "no error" for a session but an
486	/// internal error for a stream.
487	pub fn from_transport(err: impl web_transport_trait::Error) -> Self {
488		if let Some((code, _reason)) = err.session_error() {
489			return SessionError::from_code(code).into();
490		}
491
492		if let Some(code) = err.stream_error() {
493			return StreamError::from_code(code).into();
494		}
495
496		Self::Transport(err.to_string())
497	}
498}
499
500/// Preserve the session registry when carrying a protocol error.
501impl From<SessionError> for Error {
502	fn from(err: SessionError) -> Self {
503		Self::Session(err)
504	}
505}
506
507/// Preserve the stream registry when carrying a protocol error.
508impl From<StreamError> for Error {
509	fn from(err: StreamError) -> Self {
510		Self::Stream(err)
511	}
512}
513
514/// Which session code to send for a local error.
515///
516/// Lossy on purpose: [`Error`] describes what went wrong locally, while the registry is what
517/// the peer can act on. Anything stream-scoped reaching a session close is a bug on our side,
518/// so it degrades to [`SessionError::Internal`] rather than inventing a code.
519impl From<&Error> for SessionError {
520	fn from(err: &Error) -> Self {
521		match err {
522			Error::Session(err) => err.clone(),
523			// App codes share the 64+ range in both registries.
524			Error::Stream(StreamError::App(app)) => Self::App(*app),
525			// A stream-scoped code has no meaning in this registry; don't forward the number.
526			Error::Stream(_) => Self::Internal,
527			Error::Cancel | Error::Closed | Error::GoingAway | Error::SessionClosed => Self::Cancel,
528			Error::Unauthorized => Self::Unauthorized,
529			Error::Version | Error::UnknownAlpn(_) => Self::Version,
530			Error::TooManyParameters => Self::KeyValueFormatting,
531			Error::GoawayTimeout => Self::GoawayTimeout,
532			Error::Timeout => Self::Timeout,
533			Error::ProtocolViolation
534			| Error::UnexpectedMessage
535			| Error::UnexpectedStream
536			| Error::Duplicate
537			| Error::Decode(_)
538			| Error::Encode(_)
539			| Error::WrongSize
540			| Error::BoundsExceeded(_)
541			| Error::InvalidPath(_) => Self::ProtocolViolation,
542			Error::App(app) => Self::App(*app),
543			// A code we did not recognize, so we cannot say which space it came from.
544			// Forwarding it into this one risks landing on a value that IS registered here
545			// (a session 0x4 would become the stream's GOING_AWAY), and the draft already
546			// says an unrecognized code is an unspecified error. Send that instead.
547			Error::Remote(_) => Self::Internal,
548			_ => Self::Internal,
549		}
550	}
551}
552
553/// Which stream code to send for a local error.
554///
555/// Session-scoped conditions route through [`StreamError::Session`], which flattens to
556/// `SESSION_CLOSED` on the wire.
557impl From<&Error> for StreamError {
558	fn from(err: &Error) -> Self {
559		match err {
560			Error::Stream(err) => err.clone(),
561			// App codes share the 64+ range in both registries.
562			Error::Session(SessionError::App(app)) => Self::App(*app),
563			// A session-scoped code has no meaning in this registry; don't forward the number.
564			Error::Session(_) => Self::Internal,
565			Error::Cancel | Error::Closed => Self::Cancel,
566			Error::SessionClosed => Self::Session(SessionError::Cancel),
567			Error::Old => Self::Old,
568			Error::Evicted => Self::Evicted,
569			Error::Lagged => Self::TooFarBehind,
570			Error::NotFound => Self::NotFound,
571			Error::Unroutable => Self::Unroutable,
572			Error::WrongSize => Self::WrongSize,
573			Error::FrameTooLarge => Self::FrameTooLarge,
574			Error::GroupTooLarge => Self::GroupTooLarge,
575			Error::TimestampMismatch => Self::TimestampMismatch,
576			Error::Timeout => Self::DeliveryTimeout,
577			Error::GoingAway => Self::GoingAway,
578			// Our own parse failure is, from the peer's side, a malformed track.
579			Error::Decode(_) | Error::BoundsExceeded(_) | Error::InvalidPath(_) | Error::MalformedTrack => {
580				Self::MalformedTrack
581			}
582			Error::App(app) => Self::App(*app),
583			// See the SessionError impl: an unregistered code carries no registry, so
584			// re-sending the number could mistranslate it.
585			Error::Remote(_) => Self::Internal,
586			// Session-scoped: the peer learns the detail from the session close.
587			// A stream refused on its own is not a session failure, so it does not claim
588			// SESSION_CLOSED; there is no stream-scoped PROTOCOL_VIOLATION to send instead.
589			Error::UnexpectedStream => Self::Internal,
590			Error::Unauthorized
591			| Error::Version
592			| Error::UnknownAlpn(_)
593			| Error::TooManyParameters
594			| Error::GoawayTimeout
595			| Error::ProtocolViolation
596			| Error::UnexpectedMessage => Self::Session(SessionError::from(err)),
597			_ => Self::Internal,
598		}
599	}
600}
601
602impl web_transport_trait::Error for Error {
603	fn session_error(&self) -> Option<(u32, String)> {
604		None
605	}
606}
607
608/// A [`Result`](std::result::Result) with this crate's [`Error`].
609pub type Result<T> = std::result::Result<T, Error>;
610
611#[cfg(test)]
612mod tests {
613	use super::*;
614
615	// Both registries are wire contracts now (draft-lcurley-moq-lite, Error Codes), so
616	// every code we send must decode back to what we meant.
617	#[test]
618	fn session_codes_round_trip() {
619		// Only the registered codes are a wire contract, so only they round trip.
620		let registered = [
621			SessionError::Cancel,
622			SessionError::Internal,
623			SessionError::Unauthorized,
624			SessionError::ProtocolViolation,
625			SessionError::KeyValueFormatting,
626			SessionError::GoawayTimeout,
627			SessionError::Timeout,
628			SessionError::Version,
629			SessionError::App(0),
630			SessionError::App(404),
631		];
632		for err in registered {
633			assert_eq!(
634				SessionError::from_code(err.to_code()),
635				err,
636				"{err:?} did not round trip"
637			);
638		}
639
640		// The moq-transport codes we reuse must keep moq-transport's values.
641		assert_eq!(SessionError::Unauthorized.to_code(), 0x2);
642		assert_eq!(SessionError::GoawayTimeout.to_code(), 0x10);
643		assert_eq!(SessionError::Version.to_code(), 0x15);
644
645		// The reserved 32-47 range, and anything else unregistered, keeps its value instead
646		// of being given a meaning. A peer on the old placeholders (0x20-0x22) lands here.
647		for code in [0x1f, 0x20, 0x21, 0x22, 0x2f] {
648			assert_eq!(SessionError::from_code(code), SessionError::Unknown(code));
649		}
650
651		// A disallowed stream fails the session as a protocol violation, and resets just
652		// the stream as an internal error rather than claiming the session closed.
653		assert_eq!(
654			SessionError::from(&Error::UnexpectedStream),
655			SessionError::ProtocolViolation
656		);
657		assert_eq!(StreamError::from(&Error::UnexpectedStream), StreamError::Internal);
658	}
659
660	#[test]
661	fn stream_codes_round_trip() {
662		// Only the registered codes are a wire contract, so only they round trip.
663		let registered = [
664			StreamError::Internal,
665			StreamError::Cancel,
666			StreamError::DeliveryTimeout,
667			StreamError::ControlTimeout,
668			StreamError::GoingAway,
669			StreamError::TooFarBehind,
670			StreamError::MalformedTrack,
671			StreamError::GroupTooLarge,
672			StreamError::NotFound,
673			StreamError::Old,
674			StreamError::Evicted,
675			StreamError::Unroutable,
676			StreamError::WrongSize,
677			StreamError::FrameTooLarge,
678			StreamError::TimestampMismatch,
679			StreamError::App(7),
680		];
681		for err in registered {
682			assert_eq!(StreamError::from_code(err.to_code()), err, "{err:?} did not round trip");
683		}
684
685		// moq-lite's own 48-63 range, pinned to the draft's table. They stay off 0x30
686		// (NO_CAPACITY), which other work assigns.
687		for (err, code) in [
688			(StreamError::ControlTimeout, 0x31),
689			(StreamError::GroupTooLarge, 0x32),
690			(StreamError::NotFound, 0x33),
691			(StreamError::Old, 0x34),
692			(StreamError::Evicted, 0x35),
693			(StreamError::Unroutable, 0x36),
694			(StreamError::WrongSize, 0x37),
695			(StreamError::FrameTooLarge, 0x38),
696			(StreamError::TimestampMismatch, 0x39),
697		] {
698			assert_eq!(err.to_code(), code, "{err:?} moved off its assigned code");
699		}
700
701		// The reserved 32-47 range carries no meaning, including the values the four
702		// codes above used to be sent from: a peer still emitting them is not given one.
703		for code in 0x20..0x30 {
704			assert_eq!(StreamError::from_code(code), StreamError::Unknown(code));
705		}
706
707		// The spaces are disjoint: 0 is a cancellation for a session but an internal
708		// error for a stream, and a cancellation is 1 here.
709		assert_eq!(StreamError::Cancel.to_code(), 0x1);
710		assert_eq!(StreamError::from_code(0x0), StreamError::Internal);
711		assert_eq!(SessionError::from_code(0x0), SessionError::Cancel);
712
713		// A session close flattens to SESSION_CLOSED: the specific reason travels on the
714		// session close, not on the stream.
715		assert_eq!(StreamError::Session(SessionError::Unauthorized).to_code(), 0x3);
716		assert_eq!(
717			StreamError::from_code(0x3),
718			StreamError::Session(SessionError::Internal)
719		);
720	}
721
722	// A relay decodes a peer's code into `Error` and re-encodes it when it tears down the
723	// corresponding downstream stream. That hop must not change what the code means.
724	#[test]
725	fn relaying_a_code_does_not_change_registries() {
726		// SESSION_CLOSED used to decode into a session-space value, which came back out as
727		// 0x1: downstream read a routine CANCELLED for an upstream session teardown.
728		let relayed = StreamError::from(&Error::from(StreamError::from_code(0x3)));
729		assert_eq!(relayed.to_code(), 0x3);
730
731		// Registered stream codes survive the hop unchanged.
732		for code in [
733			0x0, 0x1, 0x2, 0x4, 0x5, 0x12, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39,
734		] {
735			let relayed = StreamError::from(&Error::from(StreamError::from_code(code)));
736			assert_eq!(relayed.to_code(), code, "stream {code:#x} changed across a relay");
737		}
738
739		// So do app codes, in both spaces.
740		assert_eq!(
741			StreamError::from(&Error::from(StreamError::from_code(64 + 7))).to_code(),
742			64 + 7
743		);
744		assert_eq!(
745			SessionError::from(&Error::from(SessionError::from_code(64 + 7))).to_code(),
746			64 + 7
747		);
748
749		// An unregistered code carries no registry, so it must not be re-sent as a number
750		// that means something here: session 0x4 and 0x5 are GOING_AWAY and TOO_FAR_BEHIND
751		// on a stream. Downgrade to the unspecified-error code instead.
752		for code in [0x4, 0x5, 0x1f] {
753			let crossed = StreamError::from(&Error::from(SessionError::from_code(code)));
754			assert_eq!(
755				crossed,
756				StreamError::Internal,
757				"session {code:#x} leaked into the stream space"
758			);
759		}
760	}
761
762	// The registry a code is read with depends on what failed, and picking the wrong one
763	// silently mistranslates rather than erroring.
764	#[test]
765	fn from_transport_selects_the_matching_registry() {
766		#[derive(Debug, thiserror::Error)]
767		#[error("failed")]
768		struct Failed {
769			session: Option<u32>,
770			stream: Option<u32>,
771		}
772
773		impl web_transport_trait::Error for Failed {
774			fn session_error(&self) -> Option<(u32, String)> {
775				self.session.map(|code| (code, "closed".to_string()))
776			}
777			fn stream_error(&self) -> Option<u32> {
778				self.stream
779			}
780		}
781
782		let session = |code| {
783			Error::from_transport(Failed {
784				session: Some(code),
785				stream: None,
786			})
787		};
788		let stream = |code| {
789			Error::from_transport(Failed {
790				session: None,
791				stream: Some(code),
792			})
793		};
794
795		// A MoQ-layer auth rejection is now classifiable, because the code is specified.
796		assert!(matches!(session(0x2), Error::Session(SessionError::Unauthorized)));
797		assert_eq!(session(0x2).session(), Some(&SessionError::Unauthorized));
798		assert!(matches!(session(0x0), Error::Session(SessionError::Cancel)));
799
800		// Same integer, different space: 0 ends a session cleanly but fails a stream.
801		assert!(matches!(stream(0x1), Error::Stream(StreamError::Cancel)));
802		assert_eq!(stream(0x1).stream(), Some(&StreamError::Cancel));
803		assert!(matches!(stream(0x0), Error::Stream(StreamError::Internal)));
804		assert!(matches!(stream(0x5), Error::Stream(StreamError::TooFarBehind)));
805		assert!(matches!(stream(0x32), Error::Stream(StreamError::GroupTooLarge)));
806
807		// Assigned lite codes round-trip to the named error. The reserved 32-47 range
808		// stays opaque: the draft forbids reading a meaning out of it.
809		assert!(matches!(stream(0x34), Error::Stream(StreamError::Old)));
810		assert!(matches!(stream(0x38), Error::Stream(StreamError::FrameTooLarge)));
811		assert!(matches!(stream(0x31), Error::Stream(StreamError::ControlTimeout)));
812		assert!(matches!(stream(0x22), Error::Stream(StreamError::Unknown(0x22))));
813		assert!(matches!(session(0x22), Error::Session(SessionError::Unknown(0x22))));
814
815		// Neither: the transport itself failed.
816		assert!(matches!(
817			Error::from_transport(Failed {
818				session: None,
819				stream: None
820			}),
821			Error::Transport(_)
822		));
823	}
824}