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 tokio_tungstenite::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 connection did not open within the connect timeout.
88    ConnectTimeout {
89        /// The timeout that elapsed.
90        after: Duration,
91    },
92    /// No answer arrived within the answer timeout.
93    NoAnswer {
94        /// The timeout that elapsed.
95        after: Duration,
96    },
97    /// Any other transport failure: a full write buffer, a frame that is not
98    /// valid UTF-8, or a failure a later tungstenite adds.
99    Other,
100}
101
102impl WsError {
103    /// The connection did not open within `after`.
104    pub fn connect_timeout(after: Duration) -> Self {
105        Self(Box::new(Repr::ConnectTimeout { after }))
106    }
107
108    /// The server closed the connection with this code and reason; `None`
109    /// and an empty reason for a close frame without a code, or an end of the
110    /// stream with no close frame.
111    pub fn closed(code: Option<u16>, reason: impl Into<String>) -> Self {
112        Self(Box::new(Repr::Closed {
113            code,
114            reason: reason.into(),
115        }))
116    }
117
118    /// The server closed the connection with this close frame, or with none.
119    pub fn from_close_frame(frame: Option<&CloseFrame>) -> Self {
120        match frame {
121            Some(frame) => Self::closed(Some(u16::from(frame.code)), frame.reason.as_str()),
122            None => Self::closed(None, String::new()),
123        }
124    }
125
126    /// No answer arrived within `after`.
127    pub fn no_answer(after: Duration) -> Self {
128        Self(Box::new(Repr::NoAnswer { after }))
129    }
130
131    /// What kind of failure this is.
132    pub fn kind(&self) -> Kind<'_> {
133        use tungstenite::Error as Ws;
134        match &*self.0 {
135            Repr::Transport(err) => match err {
136                Ws::Io(_) => Kind::Io,
137                Ws::Protocol(_) => Kind::Protocol,
138                Ws::ConnectionClosed => Kind::ConnectionClosed,
139                Ws::Http(response) => Kind::Refused {
140                    status: response.status().as_u16(),
141                    retry_after: response
142                        .headers()
143                        .get(RETRY_AFTER)
144                        .and_then(|value| value.to_str().ok()),
145                },
146                Ws::Url(_) => Kind::Url,
147                Ws::HttpFormat(_) => Kind::HttpFormat,
148                Ws::Tls(_) => Kind::Tls,
149                Ws::AttackAttempt => Kind::AttackAttempt,
150                Ws::Capacity(_) => Kind::Capacity,
151                Ws::AlreadyClosed => Kind::AlreadyClosed,
152                // WriteBufferFull, Utf8, and any variant a later tungstenite
153                // adds.
154                _ => Kind::Other,
155            },
156            Repr::ConnectTimeout { after } => Kind::ConnectTimeout { after: *after },
157            Repr::Closed { code, reason } => Kind::Closed {
158                code: *code,
159                reason,
160            },
161            Repr::NoAnswer { after } => Kind::NoAnswer { after: *after },
162        }
163    }
164}
165
166impl From<tungstenite::Error> for WsError {
167    fn from(err: tungstenite::Error) -> Self {
168        Self(Box::new(Repr::Transport(err)))
169    }
170}
171
172impl fmt::Debug for WsError {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.debug_tuple("WsError").field(&self.0).finish()
175    }
176}
177
178impl fmt::Display for WsError {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        fmt::Display::fmt(&self.0, f)
181    }
182}
183
184impl Error for WsError {
185    fn source(&self) -> Option<&(dyn Error + 'static)> {
186        self.0.source()
187    }
188}
189
190/// The tail of a close's message: the code and reason when present.
191fn describe_close(code: Option<u16>, reason: &str) -> String {
192    match (code, reason.is_empty()) {
193        (Some(code), true) => format!(" with code {code}"),
194        (Some(code), false) => format!(" with code {code}: {reason}"),
195        (None, true) => " without a status code".to_owned(),
196        (None, false) => format!(": {reason}"),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use tungstenite::{
203        error::{CapacityError, ProtocolError, TlsError, UrlError},
204        http,
205        protocol::frame::coding::CloseCode,
206        Message,
207    };
208
209    use super::*;
210
211    fn refused(status: u16, retry_after: Option<&str>) -> WsError {
212        let mut response = http::Response::builder().status(status);
213        if let Some(value) = retry_after {
214            response = response.header("Retry-After", value);
215        }
216        WsError::from(tungstenite::Error::Http(response.body(None).unwrap()))
217    }
218
219    #[test]
220    fn every_transport_error_has_its_kind() {
221        let bad_header = http::header::HeaderName::from_bytes(b"in valid").unwrap_err();
222        let rows = [
223            (
224                tungstenite::Error::Io(std::io::ErrorKind::UnexpectedEof.into()),
225                Kind::Io,
226            ),
227            (
228                tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake),
229                Kind::Protocol,
230            ),
231            (tungstenite::Error::ConnectionClosed, Kind::ConnectionClosed),
232            (tungstenite::Error::Url(UrlError::NoHostName), Kind::Url),
233            (
234                tungstenite::Error::HttpFormat(bad_header.into()),
235                Kind::HttpFormat,
236            ),
237            (tungstenite::Error::Tls(TlsError::InvalidDnsName), Kind::Tls),
238            (tungstenite::Error::AttackAttempt, Kind::AttackAttempt),
239            (
240                tungstenite::Error::Capacity(CapacityError::TooManyHeaders),
241                Kind::Capacity,
242            ),
243            (tungstenite::Error::AlreadyClosed, Kind::AlreadyClosed),
244            (
245                tungstenite::Error::WriteBufferFull(Message::Close(None)),
246                Kind::Other,
247            ),
248            (tungstenite::Error::Utf8, Kind::Other),
249        ];
250        for (err, kind) in rows {
251            let err = WsError::from(err);
252            assert_eq!(err.kind(), kind, "{err:?}");
253            assert!(err.source().is_some(), "{err:?} lost its transport error");
254        }
255    }
256
257    #[test]
258    fn a_refused_upgrade_keeps_its_status_and_raw_retry_after() {
259        assert_eq!(
260            refused(429, Some("7")).kind(),
261            Kind::Refused {
262                status: 429,
263                retry_after: Some("7")
264            }
265        );
266        assert_eq!(
267            refused(503, None).kind(),
268            Kind::Refused {
269                status: 503,
270                retry_after: None
271            }
272        );
273        // Kept verbatim: parsing it, zero included, is the classifier's job.
274        assert_eq!(
275            refused(429, Some(" 0 ")).kind(),
276            Kind::Refused {
277                status: 429,
278                retry_after: Some(" 0 ")
279            }
280        );
281    }
282
283    #[test]
284    fn the_kits_own_failures_have_their_kinds_and_no_source() {
285        let after = Duration::from_secs(10);
286        let rows = [
287            (
288                WsError::connect_timeout(after),
289                Kind::ConnectTimeout { after },
290            ),
291            (WsError::no_answer(after), Kind::NoAnswer { after }),
292            (
293                WsError::closed(Some(1008), "policy"),
294                Kind::Closed {
295                    code: Some(1008),
296                    reason: "policy",
297                },
298            ),
299        ];
300        for (err, kind) in rows {
301            assert_eq!(err.kind(), kind);
302            assert!(err.source().is_none(), "{err:?}");
303        }
304    }
305
306    #[test]
307    fn a_close_frame_is_captured_with_its_code_and_reason() {
308        let frame = CloseFrame {
309            code: CloseCode::Error,
310            reason: "overloaded".into(),
311        };
312        assert_eq!(
313            WsError::from_close_frame(Some(&frame)).kind(),
314            Kind::Closed {
315                code: Some(1011),
316                reason: "overloaded"
317            }
318        );
319        assert_eq!(
320            WsError::from_close_frame(None).kind(),
321            Kind::Closed {
322                code: None,
323                reason: ""
324            }
325        );
326    }
327
328    #[test]
329    fn messages_name_what_happened() {
330        assert_eq!(
331            WsError::closed(Some(1001), "going away").to_string(),
332            "the server closed the WebSocket connection with code 1001: going away"
333        );
334        assert_eq!(
335            WsError::closed(None, "").to_string(),
336            "the server closed the WebSocket connection without a status code"
337        );
338        assert_eq!(
339            WsError::connect_timeout(Duration::from_secs(10)).to_string(),
340            "no WebSocket connection within 10s"
341        );
342        assert_eq!(
343            WsError::no_answer(Duration::from_millis(200)).to_string(),
344            "no answer within 200ms"
345        );
346        assert_eq!(
347            refused(503, None).to_string(),
348            "HTTP error: 503 Service Unavailable"
349        );
350    }
351
352    #[test]
353    fn errors_cross_threads() {
354        fn assert_send_sync<T: Send + Sync + 'static>() {}
355        assert_send_sync::<WsError>();
356    }
357}