Skip to main content

nautilus_network/transport/
error.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Neutral error type for the WebSocket transport abstraction.
17
18use std::io;
19
20use thiserror::Error;
21
22use super::message::CloseFrame;
23
24/// A backend-agnostic WebSocket transport error.
25///
26/// Each backend translates its native error type into this enum via `From` impls
27/// so the higher layers operate against a single error surface.
28#[derive(Debug, Error)]
29pub enum TransportError {
30    /// Underlying I/O error from the socket.
31    #[error("I/O error: {0}")]
32    Io(#[from] io::Error),
33
34    /// HTTP upgrade handshake failed.
35    #[error("handshake failed: {0}")]
36    Handshake(String),
37
38    /// Server rejected the WebSocket HTTP upgrade.
39    #[error("WebSocket upgrade rejected with status {0}")]
40    UpgradeRejected(u16),
41
42    /// Proxy rejected the HTTP CONNECT request.
43    #[error("proxy CONNECT rejected with status {0}")]
44    ProxyConnectRejected(u16),
45
46    /// URL was invalid or unsupported.
47    #[error("invalid URL: {0}")]
48    InvalidUrl(String),
49
50    /// TLS-layer failure during connect or stream operation.
51    #[error("TLS error: {0}")]
52    Tls(String),
53
54    /// WebSocket protocol violation reported by the peer or detected locally.
55    #[error("protocol error: {0}")]
56    Protocol(String),
57
58    /// Peer sent a close frame and the connection is closing.
59    #[error("connection closed by peer")]
60    ClosedByPeer(Option<CloseFrame>),
61
62    /// Connection closed without a close frame (abnormal).
63    #[error("connection closed")]
64    ConnectionClosed,
65
66    /// Connection reset by peer.
67    #[error("connection reset")]
68    ConnectionReset,
69
70    /// Message exceeded the configured maximum size.
71    #[error("message too large")]
72    MessageTooLarge,
73
74    /// Frame exceeded the configured maximum size.
75    #[error("frame too large")]
76    FrameTooLarge,
77
78    /// UTF-8 validation failed on a text frame.
79    ///
80    /// Both shipped backends validate incoming text frames: tokio-tungstenite
81    /// during frame assembly and sockudo-ws at parse time.
82    #[error("invalid UTF-8 in text frame")]
83    InvalidUtf8,
84
85    /// Backend returned an error not covered by other variants. Carries a
86    /// short description; consumers should treat as fatal.
87    #[error("transport error: {0}")]
88    Other(String),
89}
90
91impl TransportError {
92    /// Returns an error description without URL, server, or operating-system error text.
93    ///
94    /// Retains the transport error category, I/O kind, and HTTP rejection status.
95    #[must_use]
96    pub fn redacted_message(&self) -> String {
97        match self {
98            Self::Io(e) => format!("I/O error: {:?}", e.kind()),
99            Self::Handshake(_) => "handshake failed".to_string(),
100            Self::InvalidUrl(_) => "invalid URL".to_string(),
101            Self::Tls(_) => "TLS error".to_string(),
102            Self::Protocol(_) => "protocol error".to_string(),
103            Self::Other(_) => "transport error".to_string(),
104            Self::UpgradeRejected(_)
105            | Self::ProxyConnectRejected(_)
106            | Self::ClosedByPeer(_)
107            | Self::ConnectionClosed
108            | Self::ConnectionReset
109            | Self::MessageTooLarge
110            | Self::FrameTooLarge
111            | Self::InvalidUtf8 => self.to_string(),
112        }
113    }
114
115    /// Returns `true` if the error indicates the connection is no longer usable.
116    ///
117    /// `InvalidUrl` is the only non-fatal variant: a bad URL is a caller-side
118    /// configuration mistake that does not damage an existing connection.
119    /// Everything else (including `Io` and the catch-all `Other`) implies the
120    /// underlying transport cannot be reused.
121    #[must_use]
122    pub fn is_fatal(&self) -> bool {
123        !matches!(self, Self::InvalidUrl(_))
124    }
125
126    /// Returns `true` for connection-closed style errors.
127    #[must_use]
128    pub fn is_closed(&self) -> bool {
129        matches!(
130            self,
131            Self::ConnectionClosed | Self::ConnectionReset | Self::ClosedByPeer(_)
132        )
133    }
134}
135
136// Keep initial-connect retry policy and handshake log severity consistent
137pub(crate) const fn retryable_status(status: u16) -> bool {
138    matches!(status, 408 | 425 | 429 | 500..=599)
139}
140
141#[cfg(test)]
142mod tests {
143    use rstest::rstest;
144
145    use super::*;
146
147    #[rstest]
148    #[case(
149        TransportError::Io(io::Error::new(io::ErrorKind::ConnectionRefused, "SYN-secret")),
150        "I/O error: ConnectionRefused"
151    )]
152    #[case(
153        TransportError::UpgradeRejected(401),
154        "WebSocket upgrade rejected with status 401"
155    )]
156    #[case(
157        TransportError::ProxyConnectRejected(407),
158        "proxy CONNECT rejected with status 407"
159    )]
160    #[case(TransportError::Handshake("SYN-secret".to_string()), "handshake failed")]
161    #[case(TransportError::InvalidUrl("SYN-secret".to_string()), "invalid URL")]
162    #[case(TransportError::Tls("SYN-secret".to_string()), "TLS error")]
163    #[case(TransportError::Protocol("SYN-secret".to_string()), "protocol error")]
164    #[case(
165        TransportError::ClosedByPeer(Some(CloseFrame::new(1000, "SYN-secret"))),
166        "connection closed by peer"
167    )]
168    #[case(TransportError::ConnectionClosed, "connection closed")]
169    #[case(TransportError::ConnectionReset, "connection reset")]
170    #[case(TransportError::MessageTooLarge, "message too large")]
171    #[case(TransportError::FrameTooLarge, "frame too large")]
172    #[case(TransportError::InvalidUtf8, "invalid UTF-8 in text frame")]
173    #[case(TransportError::Other("SYN-secret".to_string()), "transport error")]
174    fn test_redacted_message_preserves_classification(
175        #[case] error: TransportError,
176        #[case] expected: &str,
177    ) {
178        assert_eq!(error.redacted_message(), expected);
179    }
180
181    #[rstest]
182    fn io_error_is_fatal() {
183        let err = TransportError::Io(io::Error::other("boom"));
184        assert!(err.is_fatal());
185        assert!(!err.is_closed());
186    }
187
188    #[rstest]
189    fn other_error_is_fatal() {
190        let err = TransportError::Other("unexpected".into());
191        assert!(err.is_fatal());
192        assert!(!err.is_closed());
193    }
194
195    #[rstest]
196    fn invalid_url_is_not_fatal() {
197        let err = TransportError::InvalidUrl("ws://".into());
198        assert!(!err.is_fatal());
199        assert!(!err.is_closed());
200    }
201
202    #[rstest]
203    fn closed_variants_are_closed_and_fatal() {
204        let err = TransportError::ConnectionClosed;
205        assert!(err.is_fatal());
206        assert!(err.is_closed());
207
208        let err = TransportError::ConnectionReset;
209        assert!(err.is_fatal());
210        assert!(err.is_closed());
211
212        let err = TransportError::ClosedByPeer(Some(CloseFrame::new(1000, "bye")));
213        assert!(err.is_fatal());
214        assert!(err.is_closed());
215    }
216
217    #[rstest]
218    fn protocol_error_is_fatal() {
219        let err = TransportError::Protocol("bad opcode".into());
220        assert!(err.is_fatal());
221        assert!(!err.is_closed());
222    }
223
224    #[rstest]
225    fn capacity_and_handshake_variants_are_fatal() {
226        for err in [
227            TransportError::MessageTooLarge,
228            TransportError::FrameTooLarge,
229            TransportError::InvalidUtf8,
230            TransportError::Tls("bad".into()),
231            TransportError::Handshake("bad".into()),
232            TransportError::UpgradeRejected(429),
233            TransportError::ProxyConnectRejected(503),
234        ] {
235            assert!(err.is_fatal(), "expected fatal: {err:?}");
236            assert!(!err.is_closed(), "expected not closed: {err:?}");
237        }
238    }
239}