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}