use std::{
pin::Pin,
task::{Context, Poll},
time::Duration,
};
use futures_util::{Stream, StreamExt};
use polyoxide_ws::{connect, tungstenite::Message, Connection, Correlated, Kind, Mode, WsError};
use serde_json::{json, Value};
use tokio::time::Instant;
use crate::usdm::ws::{
error::UsdmWsError, event::Update, stream::StreamName, StreamPath, MAX_NAMES_PER_REQUEST,
MAX_STREAMS_PER_CONNECTION, MIN_REQUEST_INTERVAL, USDM_WS_BASE,
};
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub const ANSWER_TIMEOUT: Duration = Duration::from_secs(10);
pub struct UsdmWs {
connection: Connection,
path: StreamPath,
streams: Vec<StreamName>,
}
impl std::fmt::Debug for UsdmWs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UsdmWs")
.field("path", &self.path)
.field("streams", &self.streams.len())
.field("connection", &self.connection)
.finish_non_exhaustive()
}
}
pub(crate) enum Answer {
Result { id: Option<u64>, result: Value },
Error {
id: Option<u64>,
code: i64,
msg: String,
},
}
impl Answer {
pub(crate) fn parse(text: &str) -> Option<Self> {
let value: Value = serde_json::from_str(text).ok()?;
let object = value.as_object()?;
if object.contains_key("stream") {
return None;
}
let id = object.get("id").and_then(Value::as_u64);
if let Some(error) = object.get("error") {
return Some(Self::Error {
id,
code: error
.get("code")
.and_then(Value::as_i64)
.unwrap_or_default(),
msg: error
.get("msg")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
});
}
if object.contains_key("result") || object.contains_key("id") {
return Some(Self::Result {
id,
result: object.get("result").cloned().unwrap_or(Value::Null),
});
}
None
}
fn id(&self) -> Option<u64> {
match self {
Self::Result { id, .. } | Self::Error { id, .. } => *id,
}
}
}
fn request_frame(method: &str, params: Option<&[StreamName]>, id: u64) -> Message {
let mut body = json!({ "method": method, "id": id });
if let Some(params) = params {
let names: Vec<String> = params.iter().map(ToString::to_string).collect();
body["params"] = json!(names);
}
Message::Text(body.to_string().into())
}
fn correlate(id: u64, message: &Message) -> Correlated<Answer> {
let Message::Text(text) = message else {
return Correlated::Push;
};
match Answer::parse(text) {
Some(answer) if answer.id() == Some(id) => Correlated::Answer(answer),
Some(other) => {
tracing::debug!(id = ?other.id(), "uncorrelated answer while awaiting {id}");
Correlated::Uncorrelated
}
None => Correlated::Push,
}
}
pub(crate) async fn request(
connection: &mut Connection,
method: &str,
params: Option<&[StreamName]>,
) -> Result<Value, UsdmWsError> {
let id = connection.next_id();
let answer = connection
.request(|id| request_frame(method, params, id), correlate)
.await
.map_err(|err| no_answer(err, id))?;
match answer {
Answer::Result { result, .. } => Ok(result),
Answer::Error { code, msg, .. } => Err(UsdmWsError::Refused { code, msg }),
}
}
fn no_answer(err: WsError, id: u64) -> UsdmWsError {
match err.kind() {
Kind::NoAnswer { after } => UsdmWsError::NoAnswer { id, timeout: after },
_ => UsdmWsError::from(err),
}
}
impl UsdmWs {
pub async fn connect(
path: StreamPath,
streams: impl IntoIterator<Item = StreamName>,
) -> Result<Self, UsdmWsError> {
Self::connect_to(USDM_WS_BASE, path, streams).await
}
pub async fn connect_to(
base_url: &str,
path: StreamPath,
streams: impl IntoIterator<Item = StreamName>,
) -> Result<Self, UsdmWsError> {
let streams: Vec<StreamName> = streams.into_iter().collect();
let mut ws = Self::open(base_url, path, CONNECT_TIMEOUT, ANSWER_TIMEOUT).await?;
ws.subscribe(&streams).await?;
Ok(ws)
}
pub(crate) async fn open(
base_url: &str,
path: StreamPath,
connect_timeout: Duration,
answer_timeout: Duration,
) -> Result<Self, UsdmWsError> {
let socket = connect(path.url(base_url), connect_timeout).await?;
let connection = Connection::new(socket, Mode::Data)
.min_request_interval(MIN_REQUEST_INTERVAL)
.answer_timeout(answer_timeout);
Ok(Self {
connection,
path,
streams: Vec::new(),
})
}
pub async fn subscribe(&mut self, streams: &[StreamName]) -> Result<(), UsdmWsError> {
if let Some(stream) = streams.iter().find(|s| s.path() != self.path) {
return Err(UsdmWsError::WrongPath {
stream: stream.clone(),
path: self.path,
});
}
let mut new: Vec<StreamName> = Vec::new();
for stream in streams {
if !self.streams.contains(stream) && !new.contains(stream) {
new.push(stream.clone());
}
}
if self.streams.len() + new.len() > MAX_STREAMS_PER_CONNECTION {
return Err(UsdmWsError::TooManyStreams {
path: self.path,
limit: MAX_STREAMS_PER_CONNECTION,
});
}
for batch in new.chunks(MAX_NAMES_PER_REQUEST) {
request(&mut self.connection, "SUBSCRIBE", Some(batch)).await?;
self.streams.extend(batch.iter().cloned());
}
Ok(())
}
pub async fn unsubscribe(&mut self, streams: &[StreamName]) -> Result<(), UsdmWsError> {
let mut gone: Vec<StreamName> = Vec::new();
for stream in streams {
if self.streams.contains(stream) && !gone.contains(stream) {
gone.push(stream.clone());
}
}
for batch in gone.chunks(MAX_NAMES_PER_REQUEST) {
request(&mut self.connection, "UNSUBSCRIBE", Some(batch)).await?;
self.streams.retain(|s| !batch.contains(s));
}
Ok(())
}
pub async fn list_subscriptions(&mut self) -> Result<Vec<String>, UsdmWsError> {
let id = self.connection.next_id();
let result = request(&mut self.connection, "LIST_SUBSCRIPTIONS", None).await?;
serde_json::from_value(result.clone()).map_err(|_| UsdmWsError::Response {
id,
raw: result.to_string(),
})
}
pub async fn ping(&mut self) -> Result<Duration, UsdmWsError> {
let start = Instant::now();
self.send_ping().await?;
self.connection
.await_answer(|message| match message {
Message::Pong(_) => Correlated::Answer(()),
_ => Correlated::Push,
})
.await
.map_err(|err| no_answer(err, 0))?;
Ok(start.elapsed())
}
pub async fn close(&mut self) -> Result<(), UsdmWsError> {
self.connection.close().await?;
Ok(())
}
pub fn streams(&self) -> &[StreamName] {
&self.streams
}
pub fn path(&self) -> StreamPath {
self.path
}
async fn send_ping(&mut self) -> Result<(), UsdmWsError> {
self.connection
.send(Message::Ping(Vec::new().into()))
.await?;
Ok(())
}
#[cfg(test)]
fn close_frame(&self) -> Option<(Option<u16>, String)> {
let closed = self.connection.closed()?;
Some(match closed.kind() {
Kind::Closed { code, reason } => (code, reason.to_owned()),
_ => (None, String::new()),
})
}
}
impl Stream for UsdmWs {
type Item = Result<Update, UsdmWsError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
loop {
let message = match self.connection.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(message))) => message,
Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err.into()))),
Poll::Ready(None) => return Poll::Ready(None),
Poll::Pending => return Poll::Pending,
};
let Message::Text(text) = message else {
continue;
};
match Update::from_json(&text) {
Ok(update) => return Poll::Ready(Some(Ok(update))),
Err(_) if Answer::parse(&text).is_some() => continue,
Err(err) => return Poll::Ready(Some(Err(err))),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::usdm::{
types::Symbol,
ws::{
event::Payload,
fixtures,
scripted::{Script, ScriptedServer},
stream::{DepthLevels, DepthSpeed},
},
};
fn agg(symbol: &str) -> StreamName {
StreamName::AggTrade(Symbol::new(symbol).unwrap())
}
#[tokio::test]
async fn connect_subscribes_on_its_path_and_yields_updates() {
let server = ScriptedServer::start(vec![Script {
pushes: vec![fixtures::AGG_TRADE.into()],
..Default::default()
}])
.await;
let mut ws = UsdmWs::connect_to(&server.url, StreamPath::Market, [agg("BTCUSDT")])
.await
.unwrap();
let update = ws.next().await.unwrap().unwrap();
assert!(matches!(update.payload, Payload::AggTrade(_)));
assert_eq!(server.paths(), ["/market/stream"]);
assert_eq!(
server.requests()[0]["params"],
serde_json::json!(["btcusdt@aggTrade"])
);
}
#[tokio::test]
async fn a_stream_for_the_other_path_is_refused_before_sending() {
let server = ScriptedServer::start(vec![Script::default()]).await;
let mut ws = UsdmWs::connect_to(&server.url, StreamPath::Market, [agg("BTCUSDT")])
.await
.unwrap();
let depth = StreamName::PartialDepth(
Symbol::new("BTCUSDT").unwrap(),
DepthLevels::Five,
DepthSpeed::Ms100,
);
let err = ws.subscribe(&[depth]).await.unwrap_err();
assert!(matches!(
err,
UsdmWsError::WrongPath {
path: StreamPath::Market,
..
}
));
assert_eq!(server.requests().len(), 1);
}
#[tokio::test]
async fn requests_carry_at_most_200_names_and_are_paced() {
let server = ScriptedServer::start(vec![Script::default()]).await;
let streams: Vec<StreamName> = (0..450).map(|i| agg(&format!("ZZ{i:04}USDT"))).collect();
let start = Instant::now();
let ws = UsdmWs::connect_to(&server.url, StreamPath::Market, streams.clone())
.await
.unwrap();
assert_eq!(ws.streams().len(), 450);
let sizes: Vec<usize> = server
.requests()
.iter()
.map(|r| r["params"].as_array().unwrap().len())
.collect();
assert_eq!(sizes, [200, 200, 50]);
assert!(
start.elapsed() >= MIN_REQUEST_INTERVAL * 2,
"three requests in {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn the_1025th_stream_is_refused_and_nothing_is_sent() {
let server = ScriptedServer::start(vec![Script::default()]).await;
let streams: Vec<StreamName> = (0..1024).map(|i| agg(&format!("ZZ{i:04}USDT"))).collect();
let mut ws = UsdmWs::connect_to(&server.url, StreamPath::Market, streams)
.await
.unwrap();
let sent = server.requests().len();
let err = ws.subscribe(&[agg("BTCUSDT")]).await.unwrap_err();
assert!(matches!(
err,
UsdmWsError::TooManyStreams { limit: 1024, .. }
));
assert_eq!(server.requests().len(), sent);
ws.subscribe(&[agg("ZZ0000USDT")]).await.unwrap();
}
#[tokio::test]
async fn an_error_answer_is_refused_and_the_connection_kept() {
let server = ScriptedServer::start(vec![Script {
refuse: vec![("ethusdt@aggTrade".into(), 2, "Invalid request".into())],
..Default::default()
}])
.await;
let mut ws = UsdmWs::connect_to(&server.url, StreamPath::Market, [agg("BTCUSDT")])
.await
.unwrap();
let err = ws.subscribe(&[agg("ETHUSDT")]).await.unwrap_err();
assert!(
matches!(err, UsdmWsError::Refused { code: 2, .. }),
"{err:?}"
);
assert_eq!(ws.streams(), &[agg("BTCUSDT")]);
assert_eq!(ws.list_subscriptions().await.unwrap(), ["btcusdt@aggTrade"]);
}
#[tokio::test]
async fn a_ping_is_answered_and_unsubscribe_leaves_the_rest() {
let server = ScriptedServer::start(vec![Script::default()]).await;
let mut ws = UsdmWs::connect_to(
&server.url,
StreamPath::Market,
[agg("BTCUSDT"), agg("ETHUSDT")],
)
.await
.unwrap();
assert!(ws.ping().await.unwrap() < Duration::from_secs(1));
ws.unsubscribe(&[agg("BTCUSDT")]).await.unwrap();
assert_eq!(ws.list_subscriptions().await.unwrap(), ["ethusdt@aggTrade"]);
assert_eq!(ws.streams(), &[agg("ETHUSDT")]);
}
#[tokio::test]
async fn the_server_s_close_code_is_reported() {
let server = ScriptedServer::start(vec![Script {
close_after: true,
close_code: Some((1008, "Too many requests".into())),
..Default::default()
}])
.await;
let mut ws = UsdmWs::connect_to(&server.url, StreamPath::Market, [agg("BTCUSDT")])
.await
.unwrap();
assert!(ws.next().await.is_none());
assert_eq!(
ws.close_frame(),
Some((Some(1008), "Too many requests".to_owned()))
);
let err = ws.subscribe(&[agg("ETHUSDT")]).await.unwrap_err();
assert!(
matches!(err, UsdmWsError::Connect(_) | UsdmWsError::Closed { .. }),
"{err:?}"
);
}
#[tokio::test]
async fn a_server_close_is_answered_with_the_close_reply_drift_r3() {
let server = ScriptedServer::start(vec![Script {
close_after: true,
close_code: Some((1011, "Internal error".into())),
..Default::default()
}])
.await;
let mut ws = UsdmWs::connect_to(&server.url, StreamPath::Market, [agg("BTCUSDT")])
.await
.unwrap();
assert!(ws.next().await.is_none());
server
.wait_for("the close reply", |s| s.close_reply_count() == 1)
.await;
drop(ws);
}
#[tokio::test]
async fn a_late_answer_is_not_taken_for_the_next_and_updates_read_meanwhile_are_kept() {
let server = ScriptedServer::start(vec![Script {
refuse: vec![("bbbusdt@aggTrade".into(), 2, "Invalid request".into())],
hold_answer: Some((2, fixtures::AGG_TRADE.into())),
..Default::default()
}])
.await;
let mut ws = UsdmWs::open(
&server.url,
StreamPath::Market,
CONNECT_TIMEOUT,
Duration::from_millis(200),
)
.await
.unwrap();
ws.subscribe(&[agg("AAAUSDT")]).await.unwrap();
let err = ws.subscribe(&[agg("BBBUSDT")]).await.unwrap_err();
assert!(
matches!(err, UsdmWsError::NoAnswer { id: 2, .. }),
"{err:?}"
);
ws.subscribe(&[agg("CCCUSDT")]).await.unwrap();
assert_eq!(ws.streams(), &[agg("AAAUSDT"), agg("CCCUSDT")]);
let update = tokio::time::timeout(Duration::from_secs(2), ws.next())
.await
.expect("the update read during request 3 was dropped")
.unwrap()
.unwrap();
assert!(matches!(update.payload, Payload::AggTrade(_)));
}
#[tokio::test]
async fn an_answer_nobody_awaits_is_not_yielded() {
let server = ScriptedServer::start(vec![Script {
pushes: vec![
r#"{"result":null,"id":99}"#.into(),
fixtures::AGG_TRADE.into(),
],
..Default::default()
}])
.await;
let mut ws = UsdmWs::connect_to(&server.url, StreamPath::Market, [agg("BTCUSDT")])
.await
.unwrap();
let update = ws.next().await.unwrap().unwrap();
assert!(matches!(update.payload, Payload::AggTrade(_)));
}
}