use std::io;
use thiserror::Error;
use super::message::CloseFrame;
#[derive(Debug, Error)]
pub enum TransportError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("handshake failed: {0}")]
Handshake(String),
#[error("WebSocket upgrade rejected with status {0}")]
UpgradeRejected(u16),
#[error("proxy CONNECT rejected with status {0}")]
ProxyConnectRejected(u16),
#[error("invalid URL: {0}")]
InvalidUrl(String),
#[error("TLS error: {0}")]
Tls(String),
#[error("protocol error: {0}")]
Protocol(String),
#[error("connection closed by peer")]
ClosedByPeer(Option<CloseFrame>),
#[error("connection closed")]
ConnectionClosed,
#[error("connection reset")]
ConnectionReset,
#[error("message too large")]
MessageTooLarge,
#[error("frame too large")]
FrameTooLarge,
#[error("invalid UTF-8 in text frame")]
InvalidUtf8,
#[error("transport error: {0}")]
Other(String),
}
impl TransportError {
#[must_use]
pub fn redacted_message(&self) -> String {
match self {
Self::Io(e) => format!("I/O error: {:?}", e.kind()),
Self::Handshake(_) => "handshake failed".to_string(),
Self::InvalidUrl(_) => "invalid URL".to_string(),
Self::Tls(_) => "TLS error".to_string(),
Self::Protocol(_) => "protocol error".to_string(),
Self::Other(_) => "transport error".to_string(),
Self::UpgradeRejected(_)
| Self::ProxyConnectRejected(_)
| Self::ClosedByPeer(_)
| Self::ConnectionClosed
| Self::ConnectionReset
| Self::MessageTooLarge
| Self::FrameTooLarge
| Self::InvalidUtf8 => self.to_string(),
}
}
#[must_use]
pub fn is_fatal(&self) -> bool {
!matches!(self, Self::InvalidUrl(_))
}
#[must_use]
pub fn is_closed(&self) -> bool {
matches!(
self,
Self::ConnectionClosed | Self::ConnectionReset | Self::ClosedByPeer(_)
)
}
}
pub(crate) const fn retryable_status(status: u16) -> bool {
matches!(status, 408 | 425 | 429 | 500..=599)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case(
TransportError::Io(io::Error::new(io::ErrorKind::ConnectionRefused, "SYN-secret")),
"I/O error: ConnectionRefused"
)]
#[case(
TransportError::UpgradeRejected(401),
"WebSocket upgrade rejected with status 401"
)]
#[case(
TransportError::ProxyConnectRejected(407),
"proxy CONNECT rejected with status 407"
)]
#[case(TransportError::Handshake("SYN-secret".to_string()), "handshake failed")]
#[case(TransportError::InvalidUrl("SYN-secret".to_string()), "invalid URL")]
#[case(TransportError::Tls("SYN-secret".to_string()), "TLS error")]
#[case(TransportError::Protocol("SYN-secret".to_string()), "protocol error")]
#[case(
TransportError::ClosedByPeer(Some(CloseFrame::new(1000, "SYN-secret"))),
"connection closed by peer"
)]
#[case(TransportError::ConnectionClosed, "connection closed")]
#[case(TransportError::ConnectionReset, "connection reset")]
#[case(TransportError::MessageTooLarge, "message too large")]
#[case(TransportError::FrameTooLarge, "frame too large")]
#[case(TransportError::InvalidUtf8, "invalid UTF-8 in text frame")]
#[case(TransportError::Other("SYN-secret".to_string()), "transport error")]
fn test_redacted_message_preserves_classification(
#[case] error: TransportError,
#[case] expected: &str,
) {
assert_eq!(error.redacted_message(), expected);
}
#[rstest]
fn io_error_is_fatal() {
let err = TransportError::Io(io::Error::other("boom"));
assert!(err.is_fatal());
assert!(!err.is_closed());
}
#[rstest]
fn other_error_is_fatal() {
let err = TransportError::Other("unexpected".into());
assert!(err.is_fatal());
assert!(!err.is_closed());
}
#[rstest]
fn invalid_url_is_not_fatal() {
let err = TransportError::InvalidUrl("ws://".into());
assert!(!err.is_fatal());
assert!(!err.is_closed());
}
#[rstest]
fn closed_variants_are_closed_and_fatal() {
let err = TransportError::ConnectionClosed;
assert!(err.is_fatal());
assert!(err.is_closed());
let err = TransportError::ConnectionReset;
assert!(err.is_fatal());
assert!(err.is_closed());
let err = TransportError::ClosedByPeer(Some(CloseFrame::new(1000, "bye")));
assert!(err.is_fatal());
assert!(err.is_closed());
}
#[rstest]
fn protocol_error_is_fatal() {
let err = TransportError::Protocol("bad opcode".into());
assert!(err.is_fatal());
assert!(!err.is_closed());
}
#[rstest]
fn capacity_and_handshake_variants_are_fatal() {
for err in [
TransportError::MessageTooLarge,
TransportError::FrameTooLarge,
TransportError::InvalidUtf8,
TransportError::Tls("bad".into()),
TransportError::Handshake("bad".into()),
TransportError::UpgradeRejected(429),
TransportError::ProxyConnectRejected(503),
] {
assert!(err.is_fatal(), "expected fatal: {err:?}");
assert!(!err.is_closed(), "expected not closed: {err:?}");
}
}
}