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}