1use std::{error::Error, fmt, time::Duration};
4
5use tokio_tungstenite::tungstenite::{self, http::header::RETRY_AFTER, protocol::CloseFrame};
6
7pub struct WsError(Box<Repr>);
25
26#[derive(Debug, thiserror::Error)]
27enum Repr {
28 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum Kind<'a> {
47 Io,
49 Protocol,
52 ConnectionClosed,
54 Closed {
59 code: Option<u16>,
61 reason: &'a str,
63 },
64 Refused {
66 status: u16,
68 retry_after: Option<&'a str>,
71 },
72 Url,
74 HttpFormat,
77 Tls,
81 AttackAttempt,
83 Capacity,
85 AlreadyClosed,
87 ConnectTimeout {
89 after: Duration,
91 },
92 NoAnswer {
94 after: Duration,
96 },
97 Other,
100}
101
102impl WsError {
103 pub fn connect_timeout(after: Duration) -> Self {
105 Self(Box::new(Repr::ConnectTimeout { after }))
106 }
107
108 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 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 pub fn no_answer(after: Duration) -> Self {
128 Self(Box::new(Repr::NoAnswer { after }))
129 }
130
131 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 _ => 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
190fn 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 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}