Skip to main content

polyoxide_ws/
error.rs

1//! The one socket error (W6), and what kind of failure it is.
2
3use std::{error::Error, fmt, time::Duration};
4
5use crate::tungstenite::{self, http::header::RETRY_AFTER, protocol::CloseFrame};
6
7/// A WebSocket failure: a transport error, a refused upgrade, a server close,
8/// or one of the kit's own timeouts.
9///
10/// Opaque on purpose: read it through [`kind`](Self::kind), which no later
11/// tungstenite release can break, and classify it through
12/// `polyoxide_venue::impl_ws_classification!` in the venue crate that wraps
13/// it. Its `source` is the transport error it came from, when there is one.
14///
15/// Built from a `tungstenite::Error` by `From`, and by
16/// [`connect_timeout`](Self::connect_timeout), [`closed`](Self::closed),
17/// [`from_close_frame`](Self::from_close_frame) and
18/// [`no_answer`](Self::no_answer) for the failures the kit detects itself.
19///
20/// The basis is the boxed `tungstenite::Error` each socket crate wrapped:
21/// the errors of all five socket crates wrap this type instead since Stories
22/// 4.6 to 4.10. One divergence row stays with each venue: **D19**, what to do
23/// after [`Kind::AlreadyClosed`] (some venues stop, others reconnect).
24pub struct WsError(Box<Repr>);
25
26#[derive(Debug, thiserror::Error)]
27enum Repr {
28    // tungstenite's own text, so a venue error that wraps this one reads as
29    // it did when it wrapped tungstenite's.
30    #[error("{0}")]
31    Transport(#[source] tungstenite::Error),
32    #[error("no WebSocket connection within {after:?}")]
33    ConnectTimeout { after: Duration },
34    #[error("the server closed the WebSocket connection{}", describe_close(*.code, .reason))]
35    Closed { code: Option<u16>, reason: String },
36    #[error("no answer within {after:?}")]
37    NoAnswer { after: Duration },
38}
39
40/// What kind of failure a [`WsError`] is.
41///
42/// Matching on it is how a venue classifies, recovers or reports a socket
43/// failure without naming tungstenite's own error. New kinds may be added.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum Kind<'a> {
47    /// The connection's I/O failed, a TLS EOF included.
48    Io,
49    /// The peer broke the WebSocket protocol, such as by resetting without a
50    /// closing handshake.
51    Protocol,
52    /// The connection is closed, after a completed closing handshake.
53    ConnectionClosed,
54    /// The server closed the connection: with a close frame, whose code and
55    /// reason these are, or by ending the stream with none, which reads as no
56    /// code and an empty reason. A close frame that carries no code reads the
57    /// same as no frame.
58    Closed {
59        /// The close code, when a close frame carried one.
60        code: Option<u16>,
61        /// The close reason, empty when none was given.
62        reason: &'a str,
63    },
64    /// The server refused the WebSocket upgrade with an HTTP status.
65    Refused {
66        /// The status.
67        status: u16,
68        /// The refusal's `Retry-After` header as sent, when it had one that
69        /// is valid text.
70        retry_after: Option<&'a str>,
71    },
72    /// The URL is not a WebSocket URL, or names no host.
73    Url,
74    /// The upgrade request could not be built, such as from an invalid
75    /// header.
76    HttpFormat,
77    /// The TLS configuration was refused, such as an invalid server name. A
78    /// certificate the server presents and rustls rejects arrives as
79    /// [`Io`](Self::Io).
80    Tls,
81    /// tungstenite detected an attack on the connection.
82    AttackAttempt,
83    /// A message or a header block was larger than the configured limits.
84    Capacity,
85    /// The connection was used after it closed.
86    AlreadyClosed,
87    /// The transport cannot perform the operation, such as a handshake header
88    /// or a control frame on a transport that has none. A transport reports it
89    /// as an I/O error of kind `std::io::ErrorKind::Unsupported`, so reconnecting
90    /// never fixes it.
91    Unsupported,
92    /// The connection did not open within the connect timeout.
93    ConnectTimeout {
94        /// The timeout that elapsed.
95        after: Duration,
96    },
97    /// No answer arrived within the answer timeout.
98    NoAnswer {
99        /// The timeout that elapsed.
100        after: Duration,
101    },
102    /// Any other transport failure: a full write buffer, a frame that is not
103    /// valid UTF-8, or a failure a later tungstenite adds.
104    Other,
105}
106
107impl WsError {
108    /// The connection did not open within `after`.
109    pub fn connect_timeout(after: Duration) -> Self {
110        Self(Box::new(Repr::ConnectTimeout { after }))
111    }
112
113    /// The server closed the connection with this code and reason; `None`
114    /// and an empty reason for a close frame without a code, or an end of the
115    /// stream with no close frame.
116    pub fn closed(code: Option<u16>, reason: impl Into<String>) -> Self {
117        Self(Box::new(Repr::Closed {
118            code,
119            reason: reason.into(),
120        }))
121    }
122
123    /// The server closed the connection with this close frame, or with none.
124    pub fn from_close_frame(frame: Option<&CloseFrame>) -> Self {
125        match frame {
126            Some(frame) => Self::closed(Some(u16::from(frame.code)), frame.reason.as_str()),
127            None => Self::closed(None, String::new()),
128        }
129    }
130
131    /// No answer arrived within `after`.
132    pub fn no_answer(after: Duration) -> Self {
133        Self(Box::new(Repr::NoAnswer { after }))
134    }
135
136    /// What kind of failure this is.
137    pub fn kind(&self) -> Kind<'_> {
138        use tungstenite::Error as Ws;
139        match &*self.0 {
140            Repr::Transport(err) => match err {
141                Ws::Io(err) if err.kind() == std::io::ErrorKind::Unsupported => Kind::Unsupported,
142                Ws::Io(_) => Kind::Io,
143                Ws::Protocol(_) => Kind::Protocol,
144                Ws::ConnectionClosed => Kind::ConnectionClosed,
145                Ws::Http(response) => Kind::Refused {
146                    status: response.status().as_u16(),
147                    retry_after: response
148                        .headers()
149                        .get(RETRY_AFTER)
150                        .and_then(|value| value.to_str().ok()),
151                },
152                Ws::Url(_) => Kind::Url,
153                Ws::HttpFormat(_) => Kind::HttpFormat,
154                Ws::Tls(_) => Kind::Tls,
155                Ws::AttackAttempt => Kind::AttackAttempt,
156                Ws::Capacity(_) => Kind::Capacity,
157                Ws::AlreadyClosed => Kind::AlreadyClosed,
158                // WriteBufferFull, Utf8, and any variant a later tungstenite
159                // adds.
160                _ => Kind::Other,
161            },
162            Repr::ConnectTimeout { after } => Kind::ConnectTimeout { after: *after },
163            Repr::Closed { code, reason } => Kind::Closed {
164                code: *code,
165                reason,
166            },
167            Repr::NoAnswer { after } => Kind::NoAnswer { after: *after },
168        }
169    }
170}
171
172impl From<tungstenite::Error> for WsError {
173    fn from(err: tungstenite::Error) -> Self {
174        Self(Box::new(Repr::Transport(err)))
175    }
176}
177
178impl fmt::Debug for WsError {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.debug_tuple("WsError").field(&self.0).finish()
181    }
182}
183
184impl fmt::Display for WsError {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        fmt::Display::fmt(&self.0, f)
187    }
188}
189
190impl Error for WsError {
191    fn source(&self) -> Option<&(dyn Error + 'static)> {
192        self.0.source()
193    }
194}
195
196/// The tail of a close's message: the code and reason when present.
197fn describe_close(code: Option<u16>, reason: &str) -> String {
198    match (code, reason.is_empty()) {
199        (Some(code), true) => format!(" with code {code}"),
200        (Some(code), false) => format!(" with code {code}: {reason}"),
201        (None, true) => " without a status code".to_owned(),
202        (None, false) => format!(": {reason}"),
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use tungstenite::{
209        error::{CapacityError, ProtocolError, TlsError, UrlError},
210        http,
211        protocol::frame::coding::CloseCode,
212        Message,
213    };
214
215    use super::*;
216
217    fn refused(status: u16, retry_after: Option<&str>) -> WsError {
218        let mut response = http::Response::builder().status(status);
219        if let Some(value) = retry_after {
220            response = response.header("Retry-After", value);
221        }
222        WsError::from(tungstenite::Error::Http(response.body(None).unwrap()))
223    }
224
225    #[test]
226    fn every_transport_error_has_its_kind() {
227        let bad_header = http::header::HeaderName::from_bytes(b"in valid").unwrap_err();
228        let rows = [
229            (
230                tungstenite::Error::Io(std::io::ErrorKind::UnexpectedEof.into()),
231                Kind::Io,
232            ),
233            (
234                tungstenite::Error::Io(std::io::ErrorKind::Unsupported.into()),
235                Kind::Unsupported,
236            ),
237            (
238                tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake),
239                Kind::Protocol,
240            ),
241            (tungstenite::Error::ConnectionClosed, Kind::ConnectionClosed),
242            (tungstenite::Error::Url(UrlError::NoHostName), Kind::Url),
243            (
244                tungstenite::Error::HttpFormat(bad_header.into()),
245                Kind::HttpFormat,
246            ),
247            (tungstenite::Error::Tls(TlsError::InvalidDnsName), Kind::Tls),
248            (tungstenite::Error::AttackAttempt, Kind::AttackAttempt),
249            (
250                tungstenite::Error::Capacity(CapacityError::TooManyHeaders),
251                Kind::Capacity,
252            ),
253            (tungstenite::Error::AlreadyClosed, Kind::AlreadyClosed),
254            (
255                tungstenite::Error::WriteBufferFull(Message::Close(None)),
256                Kind::Other,
257            ),
258            (tungstenite::Error::Utf8, Kind::Other),
259        ];
260        for (err, kind) in rows {
261            let err = WsError::from(err);
262            assert_eq!(err.kind(), kind, "{err:?}");
263            assert!(err.source().is_some(), "{err:?} lost its transport error");
264        }
265    }
266
267    #[test]
268    fn a_refused_upgrade_keeps_its_status_and_raw_retry_after() {
269        assert_eq!(
270            refused(429, Some("7")).kind(),
271            Kind::Refused {
272                status: 429,
273                retry_after: Some("7")
274            }
275        );
276        assert_eq!(
277            refused(503, None).kind(),
278            Kind::Refused {
279                status: 503,
280                retry_after: None
281            }
282        );
283        // Kept verbatim: parsing it, zero included, is the classifier's job.
284        assert_eq!(
285            refused(429, Some(" 0 ")).kind(),
286            Kind::Refused {
287                status: 429,
288                retry_after: Some(" 0 ")
289            }
290        );
291    }
292
293    #[test]
294    fn the_kits_own_failures_have_their_kinds_and_no_source() {
295        let after = Duration::from_secs(10);
296        let rows = [
297            (
298                WsError::connect_timeout(after),
299                Kind::ConnectTimeout { after },
300            ),
301            (WsError::no_answer(after), Kind::NoAnswer { after }),
302            (
303                WsError::closed(Some(1008), "policy"),
304                Kind::Closed {
305                    code: Some(1008),
306                    reason: "policy",
307                },
308            ),
309        ];
310        for (err, kind) in rows {
311            assert_eq!(err.kind(), kind);
312            assert!(err.source().is_none(), "{err:?}");
313        }
314    }
315
316    #[test]
317    fn a_close_frame_is_captured_with_its_code_and_reason() {
318        let frame = CloseFrame {
319            code: CloseCode::Error,
320            reason: "overloaded".into(),
321        };
322        assert_eq!(
323            WsError::from_close_frame(Some(&frame)).kind(),
324            Kind::Closed {
325                code: Some(1011),
326                reason: "overloaded"
327            }
328        );
329        assert_eq!(
330            WsError::from_close_frame(None).kind(),
331            Kind::Closed {
332                code: None,
333                reason: ""
334            }
335        );
336    }
337
338    #[test]
339    fn messages_name_what_happened() {
340        assert_eq!(
341            WsError::closed(Some(1001), "going away").to_string(),
342            "the server closed the WebSocket connection with code 1001: going away"
343        );
344        assert_eq!(
345            WsError::closed(None, "").to_string(),
346            "the server closed the WebSocket connection without a status code"
347        );
348        assert_eq!(
349            WsError::connect_timeout(Duration::from_secs(10)).to_string(),
350            "no WebSocket connection within 10s"
351        );
352        assert_eq!(
353            WsError::no_answer(Duration::from_millis(200)).to_string(),
354            "no answer within 200ms"
355        );
356        assert_eq!(
357            refused(503, None).to_string(),
358            "HTTP error: 503 Service Unavailable"
359        );
360    }
361
362    #[test]
363    fn errors_cross_threads() {
364        fn assert_send_sync<T: Send + Sync + 'static>() {}
365        assert_send_sync::<WsError>();
366    }
367}