use std::time::Duration;
use polyoxide_ws::Outage;
use crate::{
shared::ws::{Shape, SocketError, StreamSet},
spot::ws::{
error::{SpotWsError, StopCause},
event::Update,
stream::StreamName,
supervised::{DisconnectReason, Event},
StreamPath, MAX_NAMES_PER_REQUEST, MIN_REQUEST_INTERVAL,
},
};
pub(crate) struct SpotStreams;
#[cfg(test)]
pub(crate) type SpotProtocol = crate::shared::ws::protocol::BinanceProtocol<SpotStreams>;
impl StreamSet for SpotStreams {
type Name = StreamName;
type Path = StreamPath;
type Update = Update;
type Error = SpotWsError;
type Event = Event;
const PATHS: &'static [StreamPath] = StreamPath::ALL;
const MAX_NAMES_PER_REQUEST: usize = MAX_NAMES_PER_REQUEST;
const MIN_REQUEST_INTERVAL: Duration = MIN_REQUEST_INTERVAL;
fn path(name: &StreamName) -> StreamPath {
name.path()
}
fn url(base: &str, path: StreamPath) -> String {
path.url(base)
}
fn decode(text: &str) -> Result<Update, SpotWsError> {
Update::from_json(text)
}
fn update(update: Update) -> Event {
Event::Update(Box::new(update))
}
fn disconnected(path: StreamPath, outage: Outage<SpotWsError>) -> Event {
let reason = match outage {
Outage::Closed { code, reason } => DisconnectReason::Closed { code, reason },
Outage::Error(err) => DisconnectReason::Error(err),
Outage::Rotation => DisconnectReason::Rotation,
_ => DisconnectReason::Stale,
};
Event::Disconnected { path, reason }
}
fn reconnected(path: StreamPath) -> Event {
Event::Reconnected { path }
}
}
impl SocketError for SpotWsError {
type Path = StreamPath;
type Cause = StopCause;
fn refused(code: i64, msg: String) -> Self {
Self::Refused { code, msg }
}
fn no_answer(id: u64, timeout: Duration) -> Self {
Self::NoAnswer { id, timeout }
}
fn response(id: u64, raw: String) -> Self {
Self::Response { id, raw }
}
fn too_many_streams(path: StreamPath, limit: usize) -> Self {
Self::TooManyStreams { path, limit }
}
fn stopped(cause: Option<StopCause>) -> Self {
Self::Stopped { cause }
}
fn cause(&self) -> StopCause {
StopCause::of(self)
}
fn shape(&self) -> Shape<'_> {
match self {
Self::Connect(err) => Shape::Connect(err),
Self::ConnectTimeout(_) => Shape::ConnectTimeout,
Self::Closed { .. } => Shape::Closed,
Self::Refused { .. } => Shape::Refused,
Self::NoAnswer { .. } => Shape::NoAnswer,
Self::Response { .. } => Shape::Response,
Self::TooManyStreams { .. } => Shape::TooManyStreams,
Self::Frame { .. } => Shape::Frame,
Self::Stopped { .. } => Shape::Stopped,
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use polyoxide_venue::Classify;
use polyoxide_ws::{tungstenite, Outage, Protocol, WsError};
use super::*;
use crate::spot::{types::Symbol, ws::fixtures};
fn btc() -> Symbol {
Symbol::new("BTCUSDT").unwrap()
}
#[test]
fn the_stream_set_is_one_path_with_the_measured_limits() {
assert_eq!(<SpotStreams as StreamSet>::PATHS, [StreamPath::Stream]);
assert_eq!(<SpotStreams as StreamSet>::MAX_NAMES_PER_REQUEST, 1024);
assert_eq!(
<SpotStreams as StreamSet>::MIN_REQUEST_INTERVAL,
Duration::from_millis(500)
);
}
#[test]
fn every_name_is_on_the_one_path() {
for name in [
StreamName::AggTrade(btc()),
StreamName::AllMiniTickers,
StreamName::BookTicker(btc()),
] {
assert_eq!(SpotStreams::path(&name), StreamPath::Stream, "{name}");
}
}
#[test]
fn the_url_appends_the_one_path_to_the_base() {
assert_eq!(
SpotStreams::url("ws://127.0.0.1:9", StreamPath::Stream),
"ws://127.0.0.1:9/stream"
);
assert_eq!(
SpotStreams::url("ws://127.0.0.1:9/", StreamPath::Stream),
"ws://127.0.0.1:9/stream"
);
}
#[test]
fn the_protocol_dials_the_stream_url() {
let mut protocol = SpotProtocol::new("ws://127.0.0.1:9".to_owned(), StreamPath::Stream);
let handshake = protocol.handshake().unwrap();
assert_eq!(handshake.uri().to_string(), "ws://127.0.0.1:9/stream");
}
#[test]
fn a_frame_decodes_as_an_update_and_a_bad_one_is_a_frame_error() {
let update = SpotStreams::decode(fixtures::AGG_TRADE).unwrap();
assert_eq!(update.stream.to_string(), "btcusdt@aggTrade");
let err = SpotStreams::decode("not json").unwrap_err();
assert!(matches!(err, SpotWsError::Frame { .. }), "{err:?}");
}
#[test]
fn an_update_is_boxed_into_the_event() {
let update = SpotStreams::decode(fixtures::AGG_TRADE).unwrap();
let Event::Update(boxed) = SpotStreams::update(update) else {
panic!("expected Event::Update");
};
assert_eq!(boxed.stream.to_string(), "btcusdt@aggTrade");
}
#[test]
fn each_outage_reads_as_its_disconnect_reason() {
let reason_of = |outage: Outage<SpotWsError>| {
let Event::Disconnected { path, reason } =
SpotStreams::disconnected(StreamPath::Stream, outage)
else {
panic!("expected Event::Disconnected");
};
assert_eq!(path, StreamPath::Stream);
reason
};
assert!(matches!(
reason_of(Outage::Closed {
code: Some(1008),
reason: "Too many requests".to_owned()
}),
DisconnectReason::Closed { code: Some(1008), reason } if reason == "Too many requests"
));
assert!(matches!(
reason_of(Outage::Error(SpotWsError::Refused {
code: 2,
msg: "Invalid request".to_owned()
})),
DisconnectReason::Error(SpotWsError::Refused { code: 2, .. })
));
assert!(matches!(
reason_of(Outage::Stale {
silent: Duration::from_secs(30)
}),
DisconnectReason::Stale
));
assert!(matches!(
reason_of(Outage::Rotation),
DisconnectReason::Rotation
));
}
#[test]
fn a_return_names_its_path() {
assert!(matches!(
SpotStreams::reconnected(StreamPath::Stream),
Event::Reconnected {
path: StreamPath::Stream
}
));
}
#[test]
fn the_error_constructors_build_their_variants_and_the_shape_reads_them_back() {
let connect = SpotWsError::from(WsError::from(tungstenite::Error::AlreadyClosed));
assert!(matches!(connect.shape(), Shape::Connect(_)), "{connect:?}");
assert!(matches!(
SpotWsError::ConnectTimeout(Duration::from_secs(10)).shape(),
Shape::ConnectTimeout
));
assert!(matches!(
SpotWsError::Closed {
code: Some(1008),
reason: String::new()
}
.shape(),
Shape::Closed
));
let refused = SpotWsError::refused(2, "Invalid request".to_owned());
assert!(matches!(refused, SpotWsError::Refused { code: 2, .. }));
assert!(matches!(refused.shape(), Shape::Refused));
let no_answer = SpotWsError::no_answer(7, Duration::from_secs(10));
assert!(matches!(no_answer, SpotWsError::NoAnswer { id: 7, .. }));
assert!(matches!(no_answer.shape(), Shape::NoAnswer));
let response = SpotWsError::response(8, "[]".to_owned());
assert!(matches!(response, SpotWsError::Response { id: 8, .. }));
assert!(matches!(response.shape(), Shape::Response));
let many = SpotWsError::too_many_streams(StreamPath::Stream, 1024);
assert!(matches!(
many,
SpotWsError::TooManyStreams {
path: StreamPath::Stream,
limit: 1024
}
));
assert!(matches!(many.shape(), Shape::TooManyStreams));
let frame = SpotWsError::Frame {
stream: String::new(),
raw: String::new(),
reason: String::new(),
};
assert!(matches!(frame.shape(), Shape::Frame));
}
#[test]
fn a_stop_carries_the_cause_it_was_given_and_the_cause_reads_the_error() {
let refused = SpotWsError::refused(2, "Invalid request".to_owned());
let cause = <SpotWsError as SocketError>::cause(&refused);
assert_eq!(cause, StopCause::of(&refused));
assert_eq!(cause.message, refused.to_string());
let stopped = SpotWsError::stopped(Some(cause.clone()));
assert!(matches!(stopped.shape(), Shape::Stopped));
assert_eq!(stopped.class(), refused.class());
assert!(matches!(
SpotWsError::stopped(None),
SpotWsError::Stopped { cause: None }
));
}
}