use std::{error::Error, fmt, time::Duration};
use tokio_tungstenite::tungstenite::{self, http::header::RETRY_AFTER, protocol::CloseFrame};
pub struct WsError(Box<Repr>);
#[derive(Debug, thiserror::Error)]
enum Repr {
#[error("{0}")]
Transport(#[source] tungstenite::Error),
#[error("no WebSocket connection within {after:?}")]
ConnectTimeout { after: Duration },
#[error("the server closed the WebSocket connection{}", describe_close(*.code, .reason))]
Closed { code: Option<u16>, reason: String },
#[error("no answer within {after:?}")]
NoAnswer { after: Duration },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Kind<'a> {
Io,
Protocol,
ConnectionClosed,
Closed {
code: Option<u16>,
reason: &'a str,
},
Refused {
status: u16,
retry_after: Option<&'a str>,
},
Url,
HttpFormat,
Tls,
AttackAttempt,
Capacity,
AlreadyClosed,
ConnectTimeout {
after: Duration,
},
NoAnswer {
after: Duration,
},
Other,
}
impl WsError {
pub fn connect_timeout(after: Duration) -> Self {
Self(Box::new(Repr::ConnectTimeout { after }))
}
pub fn closed(code: Option<u16>, reason: impl Into<String>) -> Self {
Self(Box::new(Repr::Closed {
code,
reason: reason.into(),
}))
}
pub fn from_close_frame(frame: Option<&CloseFrame>) -> Self {
match frame {
Some(frame) => Self::closed(Some(u16::from(frame.code)), frame.reason.as_str()),
None => Self::closed(None, String::new()),
}
}
pub fn no_answer(after: Duration) -> Self {
Self(Box::new(Repr::NoAnswer { after }))
}
pub fn kind(&self) -> Kind<'_> {
use tungstenite::Error as Ws;
match &*self.0 {
Repr::Transport(err) => match err {
Ws::Io(_) => Kind::Io,
Ws::Protocol(_) => Kind::Protocol,
Ws::ConnectionClosed => Kind::ConnectionClosed,
Ws::Http(response) => Kind::Refused {
status: response.status().as_u16(),
retry_after: response
.headers()
.get(RETRY_AFTER)
.and_then(|value| value.to_str().ok()),
},
Ws::Url(_) => Kind::Url,
Ws::HttpFormat(_) => Kind::HttpFormat,
Ws::Tls(_) => Kind::Tls,
Ws::AttackAttempt => Kind::AttackAttempt,
Ws::Capacity(_) => Kind::Capacity,
Ws::AlreadyClosed => Kind::AlreadyClosed,
_ => Kind::Other,
},
Repr::ConnectTimeout { after } => Kind::ConnectTimeout { after: *after },
Repr::Closed { code, reason } => Kind::Closed {
code: *code,
reason,
},
Repr::NoAnswer { after } => Kind::NoAnswer { after: *after },
}
}
}
impl From<tungstenite::Error> for WsError {
fn from(err: tungstenite::Error) -> Self {
Self(Box::new(Repr::Transport(err)))
}
}
impl fmt::Debug for WsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("WsError").field(&self.0).finish()
}
}
impl fmt::Display for WsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl Error for WsError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.0.source()
}
}
fn describe_close(code: Option<u16>, reason: &str) -> String {
match (code, reason.is_empty()) {
(Some(code), true) => format!(" with code {code}"),
(Some(code), false) => format!(" with code {code}: {reason}"),
(None, true) => " without a status code".to_owned(),
(None, false) => format!(": {reason}"),
}
}
#[cfg(test)]
mod tests {
use tungstenite::{
error::{CapacityError, ProtocolError, TlsError, UrlError},
http,
protocol::frame::coding::CloseCode,
Message,
};
use super::*;
fn refused(status: u16, retry_after: Option<&str>) -> WsError {
let mut response = http::Response::builder().status(status);
if let Some(value) = retry_after {
response = response.header("Retry-After", value);
}
WsError::from(tungstenite::Error::Http(response.body(None).unwrap()))
}
#[test]
fn every_transport_error_has_its_kind() {
let bad_header = http::header::HeaderName::from_bytes(b"in valid").unwrap_err();
let rows = [
(
tungstenite::Error::Io(std::io::ErrorKind::UnexpectedEof.into()),
Kind::Io,
),
(
tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake),
Kind::Protocol,
),
(tungstenite::Error::ConnectionClosed, Kind::ConnectionClosed),
(tungstenite::Error::Url(UrlError::NoHostName), Kind::Url),
(
tungstenite::Error::HttpFormat(bad_header.into()),
Kind::HttpFormat,
),
(tungstenite::Error::Tls(TlsError::InvalidDnsName), Kind::Tls),
(tungstenite::Error::AttackAttempt, Kind::AttackAttempt),
(
tungstenite::Error::Capacity(CapacityError::TooManyHeaders),
Kind::Capacity,
),
(tungstenite::Error::AlreadyClosed, Kind::AlreadyClosed),
(
tungstenite::Error::WriteBufferFull(Message::Close(None)),
Kind::Other,
),
(tungstenite::Error::Utf8, Kind::Other),
];
for (err, kind) in rows {
let err = WsError::from(err);
assert_eq!(err.kind(), kind, "{err:?}");
assert!(err.source().is_some(), "{err:?} lost its transport error");
}
}
#[test]
fn a_refused_upgrade_keeps_its_status_and_raw_retry_after() {
assert_eq!(
refused(429, Some("7")).kind(),
Kind::Refused {
status: 429,
retry_after: Some("7")
}
);
assert_eq!(
refused(503, None).kind(),
Kind::Refused {
status: 503,
retry_after: None
}
);
assert_eq!(
refused(429, Some(" 0 ")).kind(),
Kind::Refused {
status: 429,
retry_after: Some(" 0 ")
}
);
}
#[test]
fn the_kits_own_failures_have_their_kinds_and_no_source() {
let after = Duration::from_secs(10);
let rows = [
(
WsError::connect_timeout(after),
Kind::ConnectTimeout { after },
),
(WsError::no_answer(after), Kind::NoAnswer { after }),
(
WsError::closed(Some(1008), "policy"),
Kind::Closed {
code: Some(1008),
reason: "policy",
},
),
];
for (err, kind) in rows {
assert_eq!(err.kind(), kind);
assert!(err.source().is_none(), "{err:?}");
}
}
#[test]
fn a_close_frame_is_captured_with_its_code_and_reason() {
let frame = CloseFrame {
code: CloseCode::Error,
reason: "overloaded".into(),
};
assert_eq!(
WsError::from_close_frame(Some(&frame)).kind(),
Kind::Closed {
code: Some(1011),
reason: "overloaded"
}
);
assert_eq!(
WsError::from_close_frame(None).kind(),
Kind::Closed {
code: None,
reason: ""
}
);
}
#[test]
fn messages_name_what_happened() {
assert_eq!(
WsError::closed(Some(1001), "going away").to_string(),
"the server closed the WebSocket connection with code 1001: going away"
);
assert_eq!(
WsError::closed(None, "").to_string(),
"the server closed the WebSocket connection without a status code"
);
assert_eq!(
WsError::connect_timeout(Duration::from_secs(10)).to_string(),
"no WebSocket connection within 10s"
);
assert_eq!(
WsError::no_answer(Duration::from_millis(200)).to_string(),
"no answer within 200ms"
);
assert_eq!(
refused(503, None).to_string(),
"HTTP error: 503 Service Unavailable"
);
}
#[test]
fn errors_cross_threads() {
fn assert_send_sync<T: Send + Sync + 'static>() {}
assert_send_sync::<WsError>();
}
}