polyoxide_binance/usdm/ws/
error.rs1use std::time::Duration;
4
5use thiserror::Error;
6
7use crate::usdm::ws::{stream::StreamName, StreamPath};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Recovery {
13 Reconnect,
15 SkipFrame,
17 Fatal,
19}
20
21#[derive(Error, Debug)]
23#[non_exhaustive]
24pub enum UsdmWsError {
25 #[error("WebSocket transport error: {0}")]
27 Connect(#[from] Box<tokio_tungstenite::tungstenite::Error>),
28
29 #[error("no connection within {0:?}")]
31 ConnectTimeout(Duration),
32
33 #[error("the server closed the connection ({code:?}: {reason})")]
37 Closed {
38 code: Option<u16>,
40 reason: String,
42 },
43
44 #[error("the server refused the request: {code} {msg}")]
46 Refused {
47 code: i64,
49 msg: String,
51 },
52
53 #[error("no answer to request {id} within {timeout:?}")]
56 NoAnswer {
57 id: u64,
59 timeout: Duration,
61 },
62
63 #[error("malformed answer to request {id}: {raw}")]
65 Response {
66 id: u64,
68 raw: String,
70 },
71
72 #[error("a {path} connection carries at most {limit} streams")]
76 TooManyStreams {
77 path: StreamPath,
79 limit: usize,
81 },
82
83 #[error("{stream} rides the {} path, not {path}", stream.path())]
85 WrongPath {
86 stream: StreamName,
88 path: StreamPath,
90 },
91
92 #[error("a frame on {stream:?} did not decode: {reason}")]
94 Frame {
95 stream: String,
97 raw: String,
99 reason: String,
101 },
102
103 #[error("the supervised connection has stopped")]
105 Stopped,
106}
107
108impl From<tokio_tungstenite::tungstenite::Error> for UsdmWsError {
109 fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
110 Self::Connect(Box::new(err))
111 }
112}
113
114impl UsdmWsError {
115 pub fn recovery(&self) -> Recovery {
117 use tokio_tungstenite::tungstenite::Error as Ws;
118 match self {
119 Self::Connect(err) => match &**err {
120 Ws::Http(response) => {
124 let status = response.status();
125 if status.is_server_error() || matches!(status.as_u16(), 408 | 425 | 429) {
126 Recovery::Reconnect
127 } else {
128 Recovery::Fatal
129 }
130 }
131 Ws::Url(_) | Ws::Tls(_) | Ws::HttpFormat(_) | Ws::AttackAttempt => Recovery::Fatal,
132 _ => Recovery::Reconnect,
135 },
136 Self::ConnectTimeout(_)
137 | Self::Closed { .. }
138 | Self::NoAnswer { .. }
139 | Self::Response { .. } => Recovery::Reconnect,
140 Self::Frame { .. } => Recovery::SkipFrame,
141 Self::Refused { .. }
142 | Self::TooManyStreams { .. }
143 | Self::WrongPath { .. }
144 | Self::Stopped => Recovery::Fatal,
145 }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use tokio_tungstenite::tungstenite::{self, http};
153
154 fn http_error(status: u16) -> UsdmWsError {
155 UsdmWsError::from(tungstenite::Error::Http(
156 http::Response::builder().status(status).body(None).unwrap(),
157 ))
158 }
159
160 #[test]
161 fn a_handshake_refused_by_the_host_reconnects_and_one_refused_for_us_is_fatal() {
162 assert_eq!(http_error(503).recovery(), Recovery::Reconnect);
163 assert_eq!(http_error(429).recovery(), Recovery::Reconnect);
164 assert_eq!(http_error(408).recovery(), Recovery::Reconnect);
165 assert_eq!(http_error(425).recovery(), Recovery::Reconnect);
166 assert_eq!(http_error(451).recovery(), Recovery::Fatal);
167 assert_eq!(http_error(404).recovery(), Recovery::Fatal);
168 assert_eq!(http_error(418).recovery(), Recovery::Fatal);
169 }
170
171 #[test]
172 fn losing_the_connection_reconnects_and_a_bad_frame_is_skipped() {
173 for err in [
174 UsdmWsError::from(tungstenite::Error::ConnectionClosed),
175 UsdmWsError::ConnectTimeout(Duration::from_secs(10)),
176 UsdmWsError::Closed {
177 code: Some(1008),
178 reason: "Too many requests".into(),
179 },
180 UsdmWsError::NoAnswer {
181 id: 3,
182 timeout: Duration::from_secs(10),
183 },
184 UsdmWsError::Response {
185 id: 4,
186 raw: "{\"id\":4}".into(),
187 },
188 ] {
189 assert_eq!(err.recovery(), Recovery::Reconnect, "{err}");
190 }
191 let frame = UsdmWsError::Frame {
192 stream: "btcusdt@aggTrade".into(),
193 raw: "{}".into(),
194 reason: "missing field".into(),
195 };
196 assert_eq!(frame.recovery(), Recovery::SkipFrame);
197 }
198
199 #[test]
200 fn a_refusal_and_a_request_the_client_refused_to_send_are_fatal() {
201 let refused = UsdmWsError::Refused {
202 code: 2,
203 msg: "Invalid request".into(),
204 };
205 assert_eq!(refused.recovery(), Recovery::Fatal);
206 let full = UsdmWsError::TooManyStreams {
207 path: StreamPath::Market,
208 limit: 1024,
209 };
210 assert_eq!(full.recovery(), Recovery::Fatal);
211 assert_eq!(
212 full.to_string(),
213 "a market connection carries at most 1024 streams"
214 );
215 let wrong = UsdmWsError::WrongPath {
216 stream: StreamName::BookTicker(crate::usdm::types::Symbol::new("BTCUSDT").unwrap()),
217 path: StreamPath::Market,
218 };
219 assert_eq!(wrong.recovery(), Recovery::Fatal);
220 assert_eq!(
221 wrong.to_string(),
222 "btcusdt@bookTicker rides the public path, not market"
223 );
224 let url = UsdmWsError::from(tungstenite::Error::Url(
225 tungstenite::error::UrlError::NoHostName,
226 ));
227 assert_eq!(url.recovery(), Recovery::Fatal);
228 assert_eq!(UsdmWsError::Stopped.recovery(), Recovery::Fatal);
229 }
230
231 #[test]
232 fn usdm_ws_error_is_send_sync() {
233 fn assert_send_sync<T: Send + Sync>() {}
234 assert_send_sync::<UsdmWsError>();
235 }
236}