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 `true` if the error indicates the connection is no longer usable.
93 ///
94 /// `InvalidUrl` is the only non-fatal variant: a bad URL is a caller-side
95 /// configuration mistake that does not damage an existing connection.
96 /// Everything else (including `Io` and the catch-all `Other`) implies the
97 /// underlying transport cannot be reused.
98 #[must_use]
99 pub fn is_fatal(&self) -> bool {
100 !matches!(self, Self::InvalidUrl(_))
101 }
102
103 /// Returns `true` for connection-closed style errors.
104 #[must_use]
105 pub fn is_closed(&self) -> bool {
106 matches!(
107 self,
108 Self::ConnectionClosed | Self::ConnectionReset | Self::ClosedByPeer(_)
109 )
110 }
111}
112
113// Keep initial-connect retry policy and handshake log severity consistent
114pub(crate) const fn retryable_status(status: u16) -> bool {
115 matches!(status, 408 | 425 | 429 | 500..=599)
116}
117
118#[cfg(test)]
119mod tests {
120 use rstest::rstest;
121
122 use super::*;
123
124 #[rstest]
125 fn io_error_is_fatal() {
126 let err = TransportError::Io(io::Error::other("boom"));
127 assert!(err.is_fatal());
128 assert!(!err.is_closed());
129 }
130
131 #[rstest]
132 fn other_error_is_fatal() {
133 let err = TransportError::Other("unexpected".into());
134 assert!(err.is_fatal());
135 assert!(!err.is_closed());
136 }
137
138 #[rstest]
139 fn invalid_url_is_not_fatal() {
140 let err = TransportError::InvalidUrl("ws://".into());
141 assert!(!err.is_fatal());
142 assert!(!err.is_closed());
143 }
144
145 #[rstest]
146 fn closed_variants_are_closed_and_fatal() {
147 let err = TransportError::ConnectionClosed;
148 assert!(err.is_fatal());
149 assert!(err.is_closed());
150
151 let err = TransportError::ConnectionReset;
152 assert!(err.is_fatal());
153 assert!(err.is_closed());
154
155 let err = TransportError::ClosedByPeer(Some(CloseFrame::new(1000, "bye")));
156 assert!(err.is_fatal());
157 assert!(err.is_closed());
158 }
159
160 #[rstest]
161 fn protocol_error_is_fatal() {
162 let err = TransportError::Protocol("bad opcode".into());
163 assert!(err.is_fatal());
164 assert!(!err.is_closed());
165 }
166
167 #[rstest]
168 fn capacity_and_handshake_variants_are_fatal() {
169 for err in [
170 TransportError::MessageTooLarge,
171 TransportError::FrameTooLarge,
172 TransportError::InvalidUtf8,
173 TransportError::Tls("bad".into()),
174 TransportError::Handshake("bad".into()),
175 TransportError::UpgradeRejected(429),
176 TransportError::ProxyConnectRejected(503),
177 ] {
178 assert!(err.is_fatal(), "expected fatal: {err:?}");
179 assert!(!err.is_closed(), "expected not closed: {err:?}");
180 }
181 }
182}