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 /// This side closed it: `Runtime::shutdown`, or a dropped runtime.
109 LocallyClosed,
110 /// A stateless reset: the peer has forgotten the connection, usually
111 /// because it restarted.
112 Reset,
113 /// A QUIC transport error ended the connection. Either peer may be at
114 /// fault, and a redial is unlikely to behave differently.
115 TransportError,
116}
117
118impl fmt::Display for LossCause {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 f.write_str(match self {
121 LossCause::IdleTimeout => "idle timeout",
122 LossCause::PeerClosed => "closed by the peer",
123 LossCause::LocallyClosed => "closed locally",
124 LossCause::Reset => "stateless reset",
125 LossCause::TransportError => "transport error",
126 })
127 }
128}
129
130impl fmt::Display for Error {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 match self {
133 Error::Runtime(m) => write!(f, "runtime error: {m}"),
134 Error::InvalidAddress(m) => write!(f, "invalid address: {m}"),
135 Error::InvalidEndpointPath => f.write_str(
136 "invalid endpoint path: must start with '/', be 1..=512 bytes and contain no control bytes",
137 ),
138 Error::InvalidFingerprint(m) => {
139 write!(f, "invalid fingerprint: expected sha256:<64 hex digits>, got {m:?}")
140 }
141 Error::AlreadyRegistered => f.write_str("endpoint path already registered"),
142 Error::NotConnected => f.write_str("endpoint is not connected to any peer"),
143 Error::ConnectionLost(cause) => {
144 write!(f, "connection lost before the transfer completed: {cause}")
145 }
146 Error::Negotiation(m) => write!(f, "negotiation failed: {m}"),
147 Error::Protocol(m) => write!(f, "protocol violation: {m}"),
148 Error::Rejected => f.write_str("peer rejected the transfer"),
149 Error::UnknownEndpoint => f.write_str("peer has no such endpoint"),
150 Error::Unsupported => f.write_str("peer does not support the requested feature"),
151 Error::NoParkedConnection => {
152 f.write_str("peer has no parked connection for a stream toward it")
153 }
154 Error::NoReply => f.write_str("peer accepted the request but sent no reply"),
155 Error::Canceled => f.write_str("transfer canceled"),
156 Error::Expired => {
157 f.write_str("the transfer expired before the peer acknowledged it")
158 }
159 Error::Indeterminate => {
160 f.write_str("outcome indeterminate: the transfer may or may not have been accepted")
161 }
162 Error::LimitExceeded => f.write_str("resource limit exceeded"),
163 Error::DatagramsUnavailable => {
164 f.write_str("datagrams are not available on this connection")
165 }
166 Error::TooLarge { max } => write!(
167 f,
168 "payload exceeds the largest datagram this connection carries ({max} bytes)"
169 ),
170 Error::Tls(m) => write!(f, "tls error: {m}"),
171 Error::Untrusted(fp) => write!(f, "peer identity {fp} is not trusted"),
172 Error::Io(e) => write!(f, "io error: {e}"),
173 Error::Transport(m) => write!(f, "transport error: {m}"),
174 }
175 }
176}
177
178impl std::error::Error for Error {
179 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
180 match self {
181 Error::Io(e) => Some(e),
182 _ => None,
183 }
184 }
185}
186
187impl From<std::io::Error> for Error {
188 fn from(e: std::io::Error) -> Self {
189 Error::Io(e)
190 }
191}
192
193impl Error {
194 /// True if the error proves the transfer had no effect at the peer.
195 ///
196 /// `Indeterminate` is deliberately *not* in this set: modelling it apart
197 /// from definite failure is the point of master doc §22. Neither is
198 /// `NoReply` — the request was accepted and may well have had an effect;
199 /// only the answer is missing.
200 pub fn is_definite_failure(&self) -> bool {
201 matches!(
202 self,
203 Error::ConnectionLost(_)
204 | Error::Rejected
205 | Error::UnknownEndpoint
206 | Error::Unsupported
207 | Error::NoParkedConnection
208 | Error::Canceled
209 | Error::NotConnected
210 | Error::LimitExceeded
211 | Error::DatagramsUnavailable
212 | Error::TooLarge { .. }
213 | Error::Untrusted(_)
214 )
215 }
216}
217
218/// Wire codes carried in an ERROR frame (`docs/PROTOCOL.md` §6.4).
219#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
220pub enum ErrorCode {
221 /// No endpoint is registered for the requested path.
222 UnknownEndpoint,
223 /// The receiving side declined the transfer.
224 Rejected,
225 /// A reserved or unsupported header value was requested.
226 Unsupported,
227 /// The receiving side failed internally.
228 Internal,
229 /// The request was accepted but no reply will be produced.
230 NoReply,
231}
232
233impl ErrorCode {
234 /// The wire code.
235 pub const fn to_wire(self) -> u64 {
236 match self {
237 ErrorCode::UnknownEndpoint => 1,
238 ErrorCode::Rejected => 2,
239 ErrorCode::Unsupported => 3,
240 ErrorCode::Internal => 4,
241 ErrorCode::NoReply => 5,
242 }
243 }
244
245 /// Interprets a wire code, returning `None` for unknown values.
246 pub const fn from_wire(code: u64) -> Option<ErrorCode> {
247 match code {
248 1 => Some(ErrorCode::UnknownEndpoint),
249 2 => Some(ErrorCode::Rejected),
250 3 => Some(ErrorCode::Unsupported),
251 4 => Some(ErrorCode::Internal),
252 5 => Some(ErrorCode::NoReply),
253 _ => None,
254 }
255 }
256}
257
258impl From<ErrorCode> for Error {
259 fn from(code: ErrorCode) -> Error {
260 match code {
261 ErrorCode::UnknownEndpoint => Error::UnknownEndpoint,
262 ErrorCode::Rejected => Error::Rejected,
263 ErrorCode::Unsupported => Error::Unsupported,
264 ErrorCode::Internal => Error::Transport("peer reported an internal error".into()),
265 ErrorCode::NoReply => Error::NoReply,
266 }
267 }
268}
269
270/// Why a peer refused to receive more payload, as carried by
271/// `STOP_SENDING`'s QUIC application error code.
272///
273/// The transport maps the numeric code (`weida_protocol::codes`) onto this enum;
274/// the core stays free of transport constants.
275#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
276pub enum StopReason {
277 /// `REJECTED`: the application declined the transfer.
278 Rejected,
279 /// `CANCELED`: the peer is no longer interested.
280 Canceled,
281 /// `UNKNOWN_ENDPOINT`: no endpoint is registered for the path.
282 UnknownEndpoint,
283 /// `UNSUPPORTED`: the endpoint exists but does not serve this stream kind.
284 Unsupported,
285 /// `LIMIT_EXCEEDED`: the endpoint is at a capacity bound — a paired
286 /// endpoint that already has its peer, for instance. A refusal of this
287 /// stream, not of the connection.
288 LimitExceeded,
289 /// `SHUTDOWN`: the peer's runtime has stopped admitting work — it is
290 /// draining or closing, and this stream arrived too late.
291 ShuttingDown,
292 /// Any other code, kept for diagnostics.
293 Other(u64),
294}
295
296impl From<StopReason> for Error {
297 fn from(reason: StopReason) -> Error {
298 match reason {
299 StopReason::Rejected => Error::Rejected,
300 StopReason::Canceled => Error::Canceled,
301 StopReason::UnknownEndpoint => Error::UnknownEndpoint,
302 StopReason::Unsupported => Error::Unsupported,
303 StopReason::LimitExceeded => Error::LimitExceeded,
304 // A refusal, and a definite one: nothing of this transfer was
305 // taken, and the peer will not take it later either. It is
306 // `Rejected` rather than a variant of its own because the outcome
307 // an application must act on is identical — do not retry against
308 // this peer — and a second word for the same outcome is what
309 // `LossCause` was introduced to avoid.
310 StopReason::ShuttingDown => Error::Rejected,
311 StopReason::Other(code) => {
312 Error::Transport(format!("peer stopped receiving with code {code}"))
313 }
314 }
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn display_is_non_empty_for_every_variant() {
324 let variants = [
325 Error::Runtime("x".into()),
326 Error::InvalidAddress("x".into()),
327 Error::InvalidEndpointPath,
328 Error::AlreadyRegistered,
329 Error::NotConnected,
330 Error::ConnectionLost(LossCause::IdleTimeout),
331 Error::Negotiation("x".into()),
332 Error::Protocol("x".into()),
333 Error::Rejected,
334 Error::UnknownEndpoint,
335 Error::Unsupported,
336 Error::NoReply,
337 Error::Canceled,
338 Error::Expired,
339 Error::Indeterminate,
340 Error::LimitExceeded,
341 Error::DatagramsUnavailable,
342 Error::TooLarge { max: 1200 },
343 Error::Tls("x".into()),
344 Error::Io(std::io::Error::other("x")),
345 Error::Transport("x".into()),
346 ];
347 for v in &variants {
348 assert!(!v.to_string().is_empty(), "{v:?}");
349 }
350 }
351
352 #[test]
353 fn definite_failures_exclude_the_unknowable_ones() {
354 // A typed refusal proves the payload never reached an application.
355 for definite in [
356 Error::ConnectionLost(LossCause::PeerClosed),
357 Error::Rejected,
358 Error::UnknownEndpoint,
359 Error::Unsupported,
360 Error::Canceled,
361 Error::NotConnected,
362 Error::LimitExceeded,
363 Error::DatagramsUnavailable,
364 Error::TooLarge { max: 1200 },
365 ] {
366 assert!(definite.is_definite_failure(), "{definite:?}");
367 }
368 // `Indeterminate` is unknown by construction, a missing reply says
369 // nothing about whether the request had an effect, and an expired
370 // transfer may have been read whole before its reset landed.
371 assert!(!Error::Indeterminate.is_definite_failure());
372 assert!(!Error::NoReply.is_definite_failure());
373 assert!(!Error::Expired.is_definite_failure());
374 }
375
376 #[test]
377 fn io_error_is_the_source() {
378 use std::error::Error as _;
379 let e = Error::Io(std::io::Error::other("boom"));
380 assert!(e.source().is_some());
381 assert!(Error::Canceled.source().is_none());
382 }
383}