1use futures_util::{SinkExt, StreamExt};
11use std::time::Duration;
12use tokio::sync::mpsc;
13use tokio::task::JoinHandle;
14use tokio_tungstenite::{connect_async, tungstenite::Message};
15use url::Url;
16
17use crate::rest::error::AsterDexError;
18use crate::ws::reconnect::{ReconnectState, DEFAULT_INITIAL_BACKOFF, DEFAULT_MAX_BACKOFF};
19
20const MAX_STREAMS_PER_CONNECTION: usize = 200;
22
23const CHANNEL_BUFFER: usize = 8_192;
26
27const WS_READ_TIMEOUT: Duration = Duration::from_secs(60);
30
31const WS_PING_INTERVAL: Duration = Duration::from_secs(20);
35
36type RawWsStream = tokio_tungstenite::WebSocketStream<
41 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
42>;
43type WsSink = futures_util::stream::SplitSink<RawWsStream, Message>;
44type WsSource = futures_util::stream::SplitStream<RawWsStream>;
45
46#[derive(Clone, Copy)]
55pub struct ReconnectConfig {
56 pub initial_backoff: Duration,
58 pub max_backoff: Duration,
60 pub max_attempts: Option<u32>,
62}
63
64impl Default for ReconnectConfig {
65 fn default() -> Self {
66 Self {
67 initial_backoff: DEFAULT_INITIAL_BACKOFF,
68 max_backoff: DEFAULT_MAX_BACKOFF,
69 max_attempts: None,
70 }
71 }
72}
73
74#[derive(PartialEq, Eq)]
80enum LoopExit {
81 Reconnect,
83 ReceiverDropped,
85}
86
87enum StreamMode {
89 Market {
92 streams: Vec<String>,
93 stream_path: String,
94 },
95 UserData { listen_key: String },
98}
99
100impl StreamMode {
101 fn ws_url(&self, base_url: &Url) -> String {
102 match self {
103 StreamMode::Market { stream_path, .. } => {
104 format!("{base_url}stream?streams={stream_path}")
105 }
106 StreamMode::UserData { listen_key } => {
107 format!("{base_url}ws/{listen_key}")
108 }
109 }
110 }
111}
112
113pub struct WebSocketClient {
119 base_url: Url,
120 reconnect_config: ReconnectConfig,
121}
122
123impl std::fmt::Debug for WebSocketClient {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.debug_struct("WebSocketClient")
126 .field("base_url", &self.base_url.as_str())
127 .finish()
128 }
129}
130
131pub struct WebSocketStream {
136 receiver: mpsc::Receiver<Result<serde_json::Value, AsterDexError>>,
137 task_handle: JoinHandle<()>,
138}
139
140impl std::fmt::Debug for WebSocketStream {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.debug_struct("WebSocketStream").finish_non_exhaustive()
143 }
144}
145
146impl WebSocketStream {
147 pub async fn recv(&mut self) -> Option<Result<serde_json::Value, AsterDexError>> {
151 self.receiver.recv().await
152 }
153}
154
155impl Drop for WebSocketStream {
156 fn drop(&mut self) {
157 self.task_handle.abort();
158 }
159}
160
161pub struct UserDataStream {
165 receiver: mpsc::Receiver<Result<serde_json::Value, AsterDexError>>,
166 task_handle: JoinHandle<()>,
167}
168
169impl std::fmt::Debug for UserDataStream {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 f.debug_struct("UserDataStream").finish_non_exhaustive()
172 }
173}
174
175impl UserDataStream {
176 pub async fn recv(&mut self) -> Option<Result<serde_json::Value, AsterDexError>> {
180 self.receiver.recv().await
181 }
182
183 pub async fn next_event(
185 &mut self,
186 ) -> Option<Result<crate::models::user_data::UserDataEvent, crate::rest::error::AsterDexError>>
187 {
188 let raw = self.receiver.recv().await?;
189 match raw {
190 Err(e) => Some(Err(e)),
191 Ok(v) => Some(
192 serde_json::from_value::<crate::models::user_data::UserDataEvent>(v).map_err(
193 |e| crate::rest::error::AsterDexError::SerdeError {
194 message: format!("failed to parse user data event: {e}"),
195 },
196 ),
197 ),
198 }
199 }
200}
201
202impl Drop for UserDataStream {
203 fn drop(&mut self) {
204 self.task_handle.abort();
205 }
206}
207
208impl WebSocketClient {
213 pub fn new(base_url: &str) -> Result<Self, AsterDexError> {
215 let url = Self::normalize_ws_url(base_url)?;
216 Ok(Self {
217 base_url: url,
218 reconnect_config: ReconnectConfig::default(),
219 })
220 }
221
222 pub fn with_reconnect_config(mut self, cfg: ReconnectConfig) -> Self {
224 self.reconnect_config = cfg;
225 self
226 }
227
228 pub async fn subscribe(
235 &self,
236 streams: Vec<&str>,
237 ) -> Result<WebSocketStream, AsterDexError> {
238 if streams.is_empty() {
239 return Err(AsterDexError::WebSocketError {
240 message: "at least one stream name required".to_string(),
241 });
242 }
243 if streams.len() > MAX_STREAMS_PER_CONNECTION {
244 return Err(AsterDexError::WebSocketError {
245 message: format!("max {} streams per connection", MAX_STREAMS_PER_CONNECTION),
246 });
247 }
248
249 let stream_path = streams.join("/");
250 let ws_url = format!("{}stream?streams={}", self.base_url, stream_path);
251
252 let (ws_stream, _response) =
253 connect_async(&ws_url)
254 .await
255 .map_err(|e| AsterDexError::WebSocketError {
256 message: format!("connection failed: {e}"),
257 })?;
258
259 let (tx, rx) = mpsc::channel(CHANNEL_BUFFER);
260 let owned_streams: Vec<String> = streams.iter().map(|s| s.to_string()).collect();
261 let mode = StreamMode::Market {
262 streams: owned_streams,
263 stream_path,
264 };
265 let base_url = self.base_url.clone();
266 let config = self.reconnect_config; let task_handle = tokio::spawn(async move {
269 Self::run_stream_loop(base_url, mode, tx, config, Some(ws_stream)).await;
270 });
271
272 Ok(WebSocketStream {
273 receiver: rx,
274 task_handle,
275 })
276 }
277
278 pub async fn unsubscribe(
282 _stream: &mut WebSocketStream,
283 _streams: Vec<&str>,
284 ) -> Result<(), AsterDexError> {
285 Err(AsterDexError::WebSocketError {
286 message: "unsubscribe not yet supported -- drop stream to close".to_string(),
287 })
288 }
289
290 pub async fn subscribe_user_data(
295 &self,
296 listen_key: &str,
297 ) -> Result<UserDataStream, AsterDexError> {
298 let ws_url = format!("{}ws/{}", self.base_url, listen_key);
299
300 let (ws_stream, _response) =
301 connect_async(&ws_url)
302 .await
303 .map_err(|e| AsterDexError::WebSocketError {
304 message: format!("user data stream connection failed: {e}"),
305 })?;
306
307 let (tx, rx) = mpsc::channel(CHANNEL_BUFFER);
308 let mode = StreamMode::UserData {
309 listen_key: listen_key.to_string(),
310 };
311 let base_url = self.base_url.clone();
312 let config = self.reconnect_config; let task_handle = tokio::spawn(async move {
315 Self::run_stream_loop(base_url, mode, tx, config, Some(ws_stream)).await;
316 });
317
318 Ok(UserDataStream {
319 receiver: rx,
320 task_handle,
321 })
322 }
323
324 async fn run_stream_loop(
329 base_url: Url,
330 mode: StreamMode,
331 tx: mpsc::Sender<Result<serde_json::Value, AsterDexError>>,
332 config: ReconnectConfig,
333 initial_connection: Option<RawWsStream>,
334 ) {
335 let mut reconnect = ReconnectState::new(
336 config.initial_backoff,
337 config.max_backoff,
338 config.max_attempts,
339 );
340 let mut cached_conn = initial_connection;
341
342 loop {
343 let ws_url = mode.ws_url(&base_url);
344
345 let ws_result = if let Some(ws) = cached_conn.take() {
346 Ok(ws)
347 } else {
348 connect_async(&ws_url).await.map(|(ws, _)| ws)
349 };
350
351 match ws_result {
352 Ok(ws_stream) => {
353 reconnect.reset();
354 tracing::info!(url = %ws_url, "WebSocket connected");
355
356 let (mut sink, mut source) = ws_stream.split();
357
358 let setup_ok = match &mode {
360 StreamMode::Market { streams, .. } => {
361 let sub_msg = serde_json::json!({
362 "method": "SUBSCRIBE",
363 "params": streams,
364 "id": 1,
365 });
366 if sink.send(Message::Text(sub_msg.to_string())).await.is_err() {
367 tracing::warn!("Failed to send SUBSCRIBE — reconnecting");
368 false
369 } else {
370 true
371 }
372 }
373 StreamMode::UserData { .. } => true,
374 };
375
376 if setup_ok {
377 let exit =
378 Self::receive_loop(&mode, &mut sink, &mut source, &tx).await;
379 let _ = sink.close().await;
380 if exit == LoopExit::ReceiverDropped {
381 return;
382 }
383 } else {
384 let _ = sink.close().await;
385 }
386 }
387 Err(e) => {
388 tracing::warn!(error = ?e, "WebSocket connection failed");
389 }
390 }
391
392 match reconnect.next_backoff() {
394 Some(delay) => {
395 tracing::info!(
396 attempt = reconnect.current_attempt,
397 backoff_secs = delay.as_secs(),
398 "WebSocket reconnect attempt",
399 );
400 tokio::time::sleep(delay).await;
401 }
402 None => {
403 tracing::warn!("WebSocket max reconnect attempts reached");
404 let _ = tx
405 .send(Err(AsterDexError::WebSocketError {
406 message: "max reconnect attempts reached".to_string(),
407 }))
408 .await;
409 return;
410 }
411 }
412 }
413 }
414
415 async fn receive_loop(
432 mode: &StreamMode,
433 sink: &mut WsSink,
434 source: &mut WsSource,
435 tx: &mpsc::Sender<Result<serde_json::Value, AsterDexError>>,
436 ) -> LoopExit {
437 use tokio::sync::mpsc::error::TrySendError;
438
439 let mut ping_interval = tokio::time::interval_at(
441 tokio::time::Instant::now() + WS_PING_INTERVAL,
442 WS_PING_INTERVAL,
443 );
444 ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
445
446 let deadline = tokio::time::sleep(WS_READ_TIMEOUT);
448 tokio::pin!(deadline);
449
450 loop {
451 tokio::select! {
452 _ = &mut deadline => {
454 tracing::warn!(
455 timeout_secs = WS_READ_TIMEOUT.as_secs(),
456 "WebSocket read timeout — reconnecting"
457 );
458 return LoopExit::Reconnect;
459 }
460
461 _ = ping_interval.tick() => {
463 tracing::trace!("Sending heartbeat Ping");
464 if sink.send(Message::Ping(vec![])).await.is_err() {
465 tracing::warn!("Heartbeat Ping failed — reconnecting");
466 return LoopExit::Reconnect;
467 }
468 }
469
470 msg = source.next() => {
472 deadline
474 .as_mut()
475 .reset(tokio::time::Instant::now() + WS_READ_TIMEOUT);
476
477 match msg {
478 Some(Ok(Message::Text(text))) => {
479 match serde_json::from_str::<serde_json::Value>(&text) {
480 Err(e) => {
481 tracing::warn!(error = ?e, "Failed to parse WS message");
482 }
483 Ok(mut v) => {
484 let data = match mode {
485 StreamMode::Market { .. } => {
486 if let Some(d) = v.get_mut("data") {
489 d.take()
490 } else {
491 v
492 }
493 }
494 StreamMode::UserData { .. } => v,
495 };
496 match tx.try_send(Ok(data)) {
497 Ok(()) => {}
498 Err(TrySendError::Full(_)) => {
499 tracing::warn!(
500 "WS receive channel full — dropping message"
501 );
502 }
503 Err(TrySendError::Closed(_)) => {
504 return LoopExit::ReceiverDropped;
505 }
506 }
507 }
508 }
509 }
510
511 Some(Ok(Message::Ping(data))) => {
512 tracing::trace!("Received server Ping — sending Pong");
516 let _ = tokio::time::timeout(
517 Duration::from_secs(5),
518 sink.send(Message::Pong(data)),
519 )
520 .await;
521 }
522
523 Some(Ok(Message::Close(_))) | None => {
524 tracing::warn!("WebSocket disconnected (close frame or EOF)");
525 return LoopExit::Reconnect;
526 }
527
528 Some(Err(e)) => {
529 tracing::warn!(error = ?e, "WebSocket error");
530 return LoopExit::Reconnect;
531 }
532
533 _ => {
534 }
536 }
537 }
538 }
539 }
540 }
541
542 fn normalize_ws_url(url: &str) -> Result<Url, AsterDexError> {
544 let url = if url.ends_with('/') {
545 url.to_string()
546 } else {
547 format!("{url}/")
548 };
549 Url::parse(&url).map_err(|e| AsterDexError::ConfigError {
550 message: format!("invalid WebSocket base URL: {e}"),
551 })
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[tokio::test]
561 async fn max_streams_exceeded_returns_error() {
562 let client =
563 WebSocketClient::new("wss://fstream.asterdex-testnet.com").expect("valid URL");
564 let streams: Vec<&str> = (0..201).map(|_| "btcusdt@aggTrade").collect();
565 let result = client.subscribe(streams).await;
566 assert!(
567 matches!(result, Err(AsterDexError::WebSocketError { ref message }) if message.contains("max 200 streams")),
568 "expected WebSocketError about max streams, got: {result:?}"
569 );
570 }
571
572 #[tokio::test]
574 async fn empty_streams_returns_error() {
575 let client =
576 WebSocketClient::new("wss://fstream.asterdex-testnet.com").expect("valid URL");
577 let result = client.subscribe(vec![]).await;
578 assert!(
579 matches!(result, Err(AsterDexError::WebSocketError { ref message }) if message.contains("at least one stream")),
580 "expected WebSocketError about empty streams, got: {result:?}"
581 );
582 }
583
584 #[tokio::test]
586 async fn new_invalid_url_returns_config_error() {
587 let result = WebSocketClient::new("not a url :::");
588 assert!(
589 matches!(result, Err(AsterDexError::ConfigError { .. })),
590 "expected ConfigError, got: {result:?}"
591 );
592 }
593
594 #[test]
596 fn reconnect_config_is_copy() {
597 let cfg = ReconnectConfig::default();
598 let _copy = cfg; let _another = cfg; }
601}