use std::time::Duration;
use thiserror::Error;
use crate::usdm::ws::{stream::StreamName, StreamPath};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Recovery {
Reconnect,
SkipFrame,
Fatal,
}
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum UsdmWsError {
#[error("WebSocket transport error: {0}")]
Connect(#[from] Box<tokio_tungstenite::tungstenite::Error>),
#[error("no connection within {0:?}")]
ConnectTimeout(Duration),
#[error("the server closed the connection ({code:?}: {reason})")]
Closed {
code: Option<u16>,
reason: String,
},
#[error("the server refused the request: {code} {msg}")]
Refused {
code: i64,
msg: String,
},
#[error("no answer to request {id} within {timeout:?}")]
NoAnswer {
id: u64,
timeout: Duration,
},
#[error("malformed answer to request {id}: {raw}")]
Response {
id: u64,
raw: String,
},
#[error("a {path} connection carries at most {limit} streams")]
TooManyStreams {
path: StreamPath,
limit: usize,
},
#[error("{stream} rides the {} path, not {path}", stream.path())]
WrongPath {
stream: StreamName,
path: StreamPath,
},
#[error("a frame on {stream:?} did not decode: {reason}")]
Frame {
stream: String,
raw: String,
reason: String,
},
#[error("the supervised connection has stopped")]
Stopped,
}
impl From<tokio_tungstenite::tungstenite::Error> for UsdmWsError {
fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
Self::Connect(Box::new(err))
}
}
impl UsdmWsError {
pub fn recovery(&self) -> Recovery {
use tokio_tungstenite::tungstenite::Error as Ws;
match self {
Self::Connect(err) => match &**err {
Ws::Http(response) => {
let status = response.status();
if status.is_server_error() || matches!(status.as_u16(), 408 | 425 | 429) {
Recovery::Reconnect
} else {
Recovery::Fatal
}
}
Ws::Url(_) | Ws::Tls(_) | Ws::HttpFormat(_) | Ws::AttackAttempt => Recovery::Fatal,
_ => Recovery::Reconnect,
},
Self::ConnectTimeout(_)
| Self::Closed { .. }
| Self::NoAnswer { .. }
| Self::Response { .. } => Recovery::Reconnect,
Self::Frame { .. } => Recovery::SkipFrame,
Self::Refused { .. }
| Self::TooManyStreams { .. }
| Self::WrongPath { .. }
| Self::Stopped => Recovery::Fatal,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio_tungstenite::tungstenite::{self, http};
fn http_error(status: u16) -> UsdmWsError {
UsdmWsError::from(tungstenite::Error::Http(
http::Response::builder().status(status).body(None).unwrap(),
))
}
#[test]
fn a_handshake_refused_by_the_host_reconnects_and_one_refused_for_us_is_fatal() {
assert_eq!(http_error(503).recovery(), Recovery::Reconnect);
assert_eq!(http_error(429).recovery(), Recovery::Reconnect);
assert_eq!(http_error(408).recovery(), Recovery::Reconnect);
assert_eq!(http_error(425).recovery(), Recovery::Reconnect);
assert_eq!(http_error(451).recovery(), Recovery::Fatal);
assert_eq!(http_error(404).recovery(), Recovery::Fatal);
assert_eq!(http_error(418).recovery(), Recovery::Fatal);
}
#[test]
fn losing_the_connection_reconnects_and_a_bad_frame_is_skipped() {
for err in [
UsdmWsError::from(tungstenite::Error::ConnectionClosed),
UsdmWsError::ConnectTimeout(Duration::from_secs(10)),
UsdmWsError::Closed {
code: Some(1008),
reason: "Too many requests".into(),
},
UsdmWsError::NoAnswer {
id: 3,
timeout: Duration::from_secs(10),
},
UsdmWsError::Response {
id: 4,
raw: "{\"id\":4}".into(),
},
] {
assert_eq!(err.recovery(), Recovery::Reconnect, "{err}");
}
let frame = UsdmWsError::Frame {
stream: "btcusdt@aggTrade".into(),
raw: "{}".into(),
reason: "missing field".into(),
};
assert_eq!(frame.recovery(), Recovery::SkipFrame);
}
#[test]
fn a_refusal_and_a_request_the_client_refused_to_send_are_fatal() {
let refused = UsdmWsError::Refused {
code: 2,
msg: "Invalid request".into(),
};
assert_eq!(refused.recovery(), Recovery::Fatal);
let full = UsdmWsError::TooManyStreams {
path: StreamPath::Market,
limit: 1024,
};
assert_eq!(full.recovery(), Recovery::Fatal);
assert_eq!(
full.to_string(),
"a market connection carries at most 1024 streams"
);
let wrong = UsdmWsError::WrongPath {
stream: StreamName::BookTicker(crate::usdm::types::Symbol::new("BTCUSDT").unwrap()),
path: StreamPath::Market,
};
assert_eq!(wrong.recovery(), Recovery::Fatal);
assert_eq!(
wrong.to_string(),
"btcusdt@bookTicker rides the public path, not market"
);
let url = UsdmWsError::from(tungstenite::Error::Url(
tungstenite::error::UrlError::NoHostName,
));
assert_eq!(url.recovery(), Recovery::Fatal);
assert_eq!(UsdmWsError::Stopped.recovery(), Recovery::Fatal);
}
#[test]
fn usdm_ws_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<UsdmWsError>();
}
}