Skip to main content

weida_core/
error.rs

1//! The single error type of the framework.
2//!
3//! Hand-written `Display`/`Error` impls: the project's dependency discipline
4//! (master doc §72) does not admit `thiserror` for one enum.
5
6use std::fmt;
7
8/// Convenience alias used throughout the framework.
9pub type Result<T> = std::result::Result<T, Error>;
10
11/// Everything that can go wrong in a weida operation.
12///
13/// The variants are deliberately outcome-shaped rather than cause-shaped: an
14/// application must distinguish `ConnectionLost` (definitely not delivered)
15/// from `Indeterminate` (may or may not have been delivered) because the two
16/// permit different retry decisions. See `docs/FAILURE_MODEL.md`.
17#[derive(Debug)]
18pub enum Error {
19    /// The runtime could not be created or used (e.g. no ambient reactor).
20    Runtime(String),
21    /// A `weida://` URL could not be parsed.
22    InvalidAddress(String),
23    /// An endpoint path violated the addressing rules.
24    InvalidEndpointPath,
25    /// A fingerprint's text form was not `sha256:` plus 64 hex digits.
26    InvalidFingerprint(String),
27    /// An endpoint path is already registered on this listener.
28    AlreadyRegistered,
29    /// The endpoint has no usable peer connection.
30    NotConnected,
31    /// The connection was lost before the local transfer reached FIN; the
32    /// payload was definitely not delivered. The [`LossCause`] says why,
33    /// which is what an application deciding whether to redial needs.
34    ConnectionLost(LossCause),
35    /// Version/capability negotiation failed.
36    Negotiation(String),
37    /// The peer violated the wire protocol.
38    Protocol(String),
39    /// The peer refused the transfer (`STOP_SENDING(REJECTED)` or
40    /// `ERROR{REJECTED}`).
41    Rejected,
42    /// The peer has no endpoint registered under the requested path.
43    UnknownEndpoint,
44    /// The peer does not support a requested protocol feature.
45    Unsupported,
46    /// A stream toward the peer was needed and the peer had parked no
47    /// connection for one. Only a local socket transport can produce this:
48    /// an accepted socket cannot be dialled back, so fan-out rides the
49    /// connections a subscriber parks
50    /// ([decisions/0012](../../../docs/decisions/0012-local-connection-grouping.md)
51    /// §4.4). A publisher treats it as a drop of that copy, not as a failure
52    /// of the subscription.
53    NoParkedConnection,
54    /// The peer accepted the request but never opened a reply stream.
55    NoReply,
56    /// The transfer was canceled, locally or by the peer.
57    Canceled,
58    /// The transfer's deadline passed before the peer acknowledged every
59    /// byte, so this side reset it with `CANCELED`
60    /// ([decisions/0034](../../../docs/decisions/0034-late-is-lost.md) §4.3).
61    /// **Not** a definite failure: the peer may have read every byte before
62    /// the reset landed.
63    Expired,
64    /// The connection was lost after the local FIN while awaiting an ACK or a
65    /// reply: the outcome is genuinely unknown (master doc §22).
66    Indeterminate,
67    /// A local or negotiated resource limit was reached.
68    LimitExceeded,
69    /// The connection carries no datagrams: one side's profile did not
70    /// enable flows, so capability code `1` was not agreed
71    /// ([decisions/0034](../../../docs/decisions/0034-late-is-lost.md) §4.5).
72    /// weida never substitutes a stream, which would deliver the unit late.
73    DatagramsUnavailable,
74    /// A datagram payload exceeds the largest datagram this connection
75    /// carries right now, `max` bytes after the flow id's prefix.
76    TooLarge {
77        /// The largest payload the connection carries.
78        max: usize,
79    },
80    /// TLS material could not be loaded or configured, or the handshake
81    /// failed for a reason other than an untrusted peer.
82    Tls(String),
83    /// The peer proved possession of a key whose fingerprint is neither
84    /// pinned nor certified by a configured anchor. Carries what the peer
85    /// presented, so an operator can pin it after checking it out of band.
86    Untrusted(crate::identity::Fingerprint),
87    /// Underlying I/O failure.
88    Io(std::io::Error),
89    /// Transport-level failure that is not one of the modelled outcomes.
90    Transport(String),
91}
92
93/// Why a connection is gone.
94///
95/// The *outcome* is the same whichever it is — nothing that was in flight
96/// completed, which is what [`Error::ConnectionLost`] promises — so this is
97/// not a second outcome vocabulary. It exists because the next action differs:
98/// an idle timeout invites a redial, a peer that closed deliberately may not
99/// want one yet, and a local close means the application already decided.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum LossCause {
102    /// No traffic for the idle period, on whichever side's timeout was
103    /// shorter. Nothing is wrong with either peer.
104    IdleTimeout,
105    /// The peer closed the connection deliberately, with a code this side
106    /// does not map to a more specific outcome — a shutdown, typically.
107    PeerClosed,
108    /// The peer closed with `RESTARTING`: its server is being replaced, and a
109    /// successor serves, or is about to serve, the same address. A redial is
110    /// due at once.
111    PeerRestarting,
112    /// This side closed it: `Runtime::shutdown`, or a dropped runtime.
113    LocallyClosed,
114    /// A stateless reset: the peer has forgotten the connection, usually
115    /// because it restarted.
116    Reset,
117    /// A QUIC transport error ended the connection. Either peer may be at
118    /// fault, and a redial is unlikely to behave differently.
119    TransportError,
120}
121
122impl fmt::Display for LossCause {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(match self {
125            LossCause::IdleTimeout => "idle timeout",
126            LossCause::PeerClosed => "closed by the peer",
127            LossCause::PeerRestarting => "the peer is restarting",
128            LossCause::LocallyClosed => "closed locally",
129            LossCause::Reset => "stateless reset",
130            LossCause::TransportError => "transport error",
131        })
132    }
133}
134
135impl fmt::Display for Error {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Error::Runtime(m) => write!(f, "runtime error: {m}"),
139            Error::InvalidAddress(m) => write!(f, "invalid address: {m}"),
140            Error::InvalidEndpointPath => f.write_str(
141                "invalid endpoint path: must start with '/', be 1..=512 bytes and contain no control bytes",
142            ),
143            Error::InvalidFingerprint(m) => {
144                write!(f, "invalid fingerprint: expected sha256:<64 hex digits>, got {m:?}")
145            }
146            Error::AlreadyRegistered => f.write_str("endpoint path already registered"),
147            Error::NotConnected => f.write_str("endpoint is not connected to any peer"),
148            Error::ConnectionLost(cause) => {
149                write!(f, "connection lost before the transfer completed: {cause}")
150            }
151            Error::Negotiation(m) => write!(f, "negotiation failed: {m}"),
152            Error::Protocol(m) => write!(f, "protocol violation: {m}"),
153            Error::Rejected => f.write_str("peer rejected the transfer"),
154            Error::UnknownEndpoint => f.write_str("peer has no such endpoint"),
155            Error::Unsupported => f.write_str("peer does not support the requested feature"),
156            Error::NoParkedConnection => {
157                f.write_str("peer has no parked connection for a stream toward it")
158            }
159            Error::NoReply => f.write_str("peer accepted the request but sent no reply"),
160            Error::Canceled => f.write_str("transfer canceled"),
161            Error::Expired => {
162                f.write_str("the transfer expired before the peer acknowledged it")
163            }
164            Error::Indeterminate => {
165                f.write_str("outcome indeterminate: the transfer may or may not have been accepted")
166            }
167            Error::LimitExceeded => f.write_str("resource limit exceeded"),
168            Error::DatagramsUnavailable => {
169                f.write_str("datagrams are not available on this connection")
170            }
171            Error::TooLarge { max } => write!(
172                f,
173                "payload exceeds the largest datagram this connection carries ({max} bytes)"
174            ),
175            Error::Tls(m) => write!(f, "tls error: {m}"),
176            Error::Untrusted(fp) => write!(f, "peer identity {fp} is not trusted"),
177            Error::Io(e) => write!(f, "io error: {e}"),
178            Error::Transport(m) => write!(f, "transport error: {m}"),
179        }
180    }
181}
182
183impl std::error::Error for Error {
184    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
185        match self {
186            Error::Io(e) => Some(e),
187            _ => None,
188        }
189    }
190}
191
192impl From<std::io::Error> for Error {
193    fn from(e: std::io::Error) -> Self {
194        Error::Io(e)
195    }
196}
197
198impl Error {
199    /// True if the error proves the transfer had no effect at the peer.
200    ///
201    /// `Indeterminate` is deliberately *not* in this set: modelling it apart
202    /// from definite failure is the point of master doc §22. Neither is
203    /// `NoReply` — the request was accepted and may well have had an effect;
204    /// only the answer is missing.
205    pub fn is_definite_failure(&self) -> bool {
206        matches!(
207            self,
208            Error::ConnectionLost(_)
209                | Error::Rejected
210                | Error::UnknownEndpoint
211                | Error::Unsupported
212                | Error::NoParkedConnection
213                | Error::Canceled
214                | Error::NotConnected
215                | Error::LimitExceeded
216                | Error::DatagramsUnavailable
217                | Error::TooLarge { .. }
218                | Error::Untrusted(_)
219        )
220    }
221}
222
223/// Wire codes carried in an ERROR frame (`docs/PROTOCOL.md` §6.4).
224#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
225pub enum ErrorCode {
226    /// No endpoint is registered for the requested path.
227    UnknownEndpoint,
228    /// The receiving side declined the transfer.
229    Rejected,
230    /// A reserved or unsupported header value was requested.
231    Unsupported,
232    /// The receiving side failed internally.
233    Internal,
234    /// The request was accepted but no reply will be produced.
235    NoReply,
236}
237
238impl ErrorCode {
239    /// The wire code.
240    pub const fn to_wire(self) -> u64 {
241        match self {
242            ErrorCode::UnknownEndpoint => 1,
243            ErrorCode::Rejected => 2,
244            ErrorCode::Unsupported => 3,
245            ErrorCode::Internal => 4,
246            ErrorCode::NoReply => 5,
247        }
248    }
249
250    /// Interprets a wire code, returning `None` for unknown values.
251    pub const fn from_wire(code: u64) -> Option<ErrorCode> {
252        match code {
253            1 => Some(ErrorCode::UnknownEndpoint),
254            2 => Some(ErrorCode::Rejected),
255            3 => Some(ErrorCode::Unsupported),
256            4 => Some(ErrorCode::Internal),
257            5 => Some(ErrorCode::NoReply),
258            _ => None,
259        }
260    }
261}
262
263impl From<ErrorCode> for Error {
264    fn from(code: ErrorCode) -> Error {
265        match code {
266            ErrorCode::UnknownEndpoint => Error::UnknownEndpoint,
267            ErrorCode::Rejected => Error::Rejected,
268            ErrorCode::Unsupported => Error::Unsupported,
269            ErrorCode::Internal => Error::Transport("peer reported an internal error".into()),
270            ErrorCode::NoReply => Error::NoReply,
271        }
272    }
273}
274
275/// Why a peer refused to receive more payload, as carried by
276/// `STOP_SENDING`'s QUIC application error code.
277///
278/// The transport maps the numeric code (`weida_protocol::codes`) onto this enum;
279/// the core stays free of transport constants.
280#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
281pub enum StopReason {
282    /// `REJECTED`: the application declined the transfer.
283    Rejected,
284    /// `CANCELED`: the peer is no longer interested.
285    Canceled,
286    /// `UNKNOWN_ENDPOINT`: no endpoint is registered for the path.
287    UnknownEndpoint,
288    /// `UNSUPPORTED`: the endpoint exists but does not serve this stream kind.
289    Unsupported,
290    /// `LIMIT_EXCEEDED`: the endpoint is at a capacity bound — a paired
291    /// endpoint that already has its peer, for instance. A refusal of this
292    /// stream, not of the connection.
293    LimitExceeded,
294    /// `SHUTDOWN`: the peer's runtime has stopped admitting work — it is
295    /// draining or closing, and this stream arrived too late.
296    ShuttingDown,
297    /// Any other code, kept for diagnostics.
298    Other(u64),
299}
300
301impl From<StopReason> for Error {
302    fn from(reason: StopReason) -> Error {
303        match reason {
304            StopReason::Rejected => Error::Rejected,
305            StopReason::Canceled => Error::Canceled,
306            StopReason::UnknownEndpoint => Error::UnknownEndpoint,
307            StopReason::Unsupported => Error::Unsupported,
308            StopReason::LimitExceeded => Error::LimitExceeded,
309            // A refusal, and a definite one: nothing of this transfer was
310            // taken, and the peer will not take it later either. It is
311            // `Rejected` rather than a variant of its own because the outcome
312            // an application must act on is identical — do not retry against
313            // this peer — and a second word for the same outcome is what
314            // `LossCause` was introduced to avoid.
315            StopReason::ShuttingDown => Error::Rejected,
316            StopReason::Other(code) => {
317                Error::Transport(format!("peer stopped receiving with code {code}"))
318            }
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn display_is_non_empty_for_every_variant() {
329        let variants = [
330            Error::Runtime("x".into()),
331            Error::InvalidAddress("x".into()),
332            Error::InvalidEndpointPath,
333            Error::AlreadyRegistered,
334            Error::NotConnected,
335            Error::ConnectionLost(LossCause::IdleTimeout),
336            Error::Negotiation("x".into()),
337            Error::Protocol("x".into()),
338            Error::Rejected,
339            Error::UnknownEndpoint,
340            Error::Unsupported,
341            Error::NoReply,
342            Error::Canceled,
343            Error::Expired,
344            Error::Indeterminate,
345            Error::LimitExceeded,
346            Error::DatagramsUnavailable,
347            Error::TooLarge { max: 1200 },
348            Error::Tls("x".into()),
349            Error::Io(std::io::Error::other("x")),
350            Error::Transport("x".into()),
351        ];
352        for v in &variants {
353            assert!(!v.to_string().is_empty(), "{v:?}");
354        }
355    }
356
357    #[test]
358    fn definite_failures_exclude_the_unknowable_ones() {
359        // A typed refusal proves the payload never reached an application.
360        for definite in [
361            Error::ConnectionLost(LossCause::PeerClosed),
362            Error::Rejected,
363            Error::UnknownEndpoint,
364            Error::Unsupported,
365            Error::Canceled,
366            Error::NotConnected,
367            Error::LimitExceeded,
368            Error::DatagramsUnavailable,
369            Error::TooLarge { max: 1200 },
370        ] {
371            assert!(definite.is_definite_failure(), "{definite:?}");
372        }
373        // `Indeterminate` is unknown by construction, a missing reply says
374        // nothing about whether the request had an effect, and an expired
375        // transfer may have been read whole before its reset landed.
376        assert!(!Error::Indeterminate.is_definite_failure());
377        assert!(!Error::NoReply.is_definite_failure());
378        assert!(!Error::Expired.is_definite_failure());
379    }
380
381    #[test]
382    fn io_error_is_the_source() {
383        use std::error::Error as _;
384        let e = Error::Io(std::io::Error::other("boom"));
385        assert!(e.source().is_some());
386        assert!(Error::Canceled.source().is_none());
387    }
388}