Skip to main content

asterdex_sdk/ws/
client.rs

1// US-010: WebSocketClient — manages WS connections with automatic reconnect
2//
3// Public API:
4//   WebSocketClient::new(base_url) -> Result<Self, AsterDexError>
5//   WebSocketClient::with_reconnect_config(self, cfg) -> Self
6//   WebSocketClient::subscribe(&self, streams) -> Result<WebSocketStream, AsterDexError>
7//   WebSocketClient::unsubscribe(stream, streams) -> Result<(), AsterDexError>
8//   WebSocketClient::subscribe_user_data(&self, listen_key) -> Result<UserDataStream, AsterDexError>
9
10use 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
20/// Maximum number of streams allowed per single WebSocket connection (BR-006).
21const MAX_STREAMS_PER_CONNECTION: usize = 200;
22
23/// Channel buffer size. Large enough to absorb message bursts without backpressure
24/// stalling the receive loop (which would delay pong responses and miss frames).
25const CHANNEL_BUFFER: usize = 8_192;
26
27/// Reconnect if no frame is received within this window.
28/// Detects TCP half-open / silent dead connections that never send a close frame.
29const WS_READ_TIMEOUT: Duration = Duration::from_secs(60);
30
31/// Interval at which the client sends proactive ping frames.
32/// Keeps the connection alive through idle-dropping proxies / NATs and provides
33/// an independent liveness check even when the server sends no pings.
34const WS_PING_INTERVAL: Duration = Duration::from_secs(20);
35
36// ---------------------------------------------------------------------------
37// Type aliases
38// ---------------------------------------------------------------------------
39
40type 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// ---------------------------------------------------------------------------
47// ReconnectConfig — now Copy so it can be captured by spawned tasks without a
48// manual field-by-field copy.
49// ---------------------------------------------------------------------------
50
51/// Configuration for the automatic reconnect engine (BR-007).
52///
53/// Default: 1 s initial backoff, 30 s max backoff, unlimited attempts.
54#[derive(Clone, Copy)]
55pub struct ReconnectConfig {
56    /// Delay before the first reconnect attempt.
57    pub initial_backoff: Duration,
58    /// Upper bound on backoff delay.
59    pub max_backoff: Duration,
60    /// Maximum number of reconnect attempts. `None` means unlimited.
61    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// ---------------------------------------------------------------------------
75// Internal helpers
76// ---------------------------------------------------------------------------
77
78/// Reason the inner receive loop exited.
79#[derive(PartialEq, Eq)]
80enum LoopExit {
81    /// Connection lost or timed out — outer loop should reconnect.
82    Reconnect,
83    /// The `mpsc` receiver was dropped — the caller no longer needs this stream.
84    ReceiverDropped,
85}
86
87/// Distinguishes market-data streams from user-data streams inside the unified loop.
88enum StreamMode {
89    /// Combined-stream endpoint — sends a SUBSCRIBE message after connect and
90    /// unwraps the `{ "stream": "…", "data": {…} }` envelope.
91    Market {
92        streams: Vec<String>,
93        stream_path: String,
94    },
95    /// User-data stream — server begins sending immediately, no SUBSCRIBE needed,
96    /// raw JSON forwarded as-is.
97    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
113// ---------------------------------------------------------------------------
114// Public structs
115// ---------------------------------------------------------------------------
116
117/// Async WebSocket client — manages connection lifecycle and reconnection (BR-006, BR-007).
118pub 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
131/// Handle for a market data stream.
132///
133/// Yields `Result<serde_json::Value, AsterDexError>` messages from the background receive loop.
134/// Dropping this handle aborts the background task.
135pub 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    /// Receive the next message from the stream.
148    ///
149    /// Returns `None` when the stream is closed (background task exited).
150    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
161/// Handle for a user data stream. Same shape as [`WebSocketStream`] but for private events.
162///
163/// Dropping this handle aborts the background task.
164pub 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    /// Receive the next user data event from the stream.
177    ///
178    /// Returns `None` when the stream is closed (background task ended).
179    pub async fn recv(&mut self) -> Option<Result<serde_json::Value, AsterDexError>> {
180        self.receiver.recv().await
181    }
182
183    /// Receive the next user data event as a typed `UserDataEvent`.
184    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
208// ---------------------------------------------------------------------------
209// WebSocketClient implementation
210// ---------------------------------------------------------------------------
211
212impl WebSocketClient {
213    /// Construct a new WebSocket client.
214    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    /// Builder method: set a custom reconnect configuration.
223    pub fn with_reconnect_config(mut self, cfg: ReconnectConfig) -> Self {
224        self.reconnect_config = cfg;
225        self
226    }
227
228    /// Subscribe to market data streams (combined stream endpoint).
229    ///
230    /// # Errors
231    /// - `WebSocketError("at least one stream name required")` if `streams` is empty.
232    /// - `WebSocketError("max 200 streams per connection")` if > 200 streams.
233    /// - `WebSocketError("connection failed: …")` if the initial connection fails.
234    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; // Copy
267
268        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    /// Unsubscribe from specific streams on an active connection.
279    ///
280    /// Not yet supported — drop the [`WebSocketStream`] to close the connection.
281    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    /// Subscribe to the user data stream using a listen key.
291    ///
292    /// # Errors
293    /// - `WebSocketError("user data stream connection failed: …")` if the initial connection fails.
294    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; // Copy
313
314        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    // -------------------------------------------------------------------------
325    // Internal: unified reconnect loop (replaces separate market / user-data loops)
326    // -------------------------------------------------------------------------
327
328    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                    // Post-connect setup: market streams must send a SUBSCRIBE frame.
359                    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            // Exponential backoff before next reconnect attempt.
393            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    // -------------------------------------------------------------------------
416    // Internal: inner receive loop — runs while the connection is healthy.
417    //
418    // Uses tokio::select! with three arms:
419    //   1. Incoming WS frame (source.next()) — resets the read-timeout deadline.
420    //   2. Periodic heartbeat tick — sends a proactive Ping to keep the
421    //      connection alive and detect silent dead connections.
422    //   3. Read-timeout deadline — fires when no frame arrives within
423    //      WS_READ_TIMEOUT; triggers a reconnect.
424    //
425    // Data messages use try_send (non-blocking): if the consumer is slow and
426    // the channel is full, the message is dropped with a warning rather than
427    // blocking this loop — which would delay Pong responses and starve the
428    // timeout deadline.
429    // -------------------------------------------------------------------------
430
431    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        // First heartbeat fires after WS_PING_INTERVAL, not immediately.
440        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        // Read-timeout future — reset each time any frame arrives.
447        let deadline = tokio::time::sleep(WS_READ_TIMEOUT);
448        tokio::pin!(deadline);
449
450        loop {
451            tokio::select! {
452                // Arm 1: read timeout — no frame received in WS_READ_TIMEOUT seconds.
453                _ = &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                // Arm 2: heartbeat — send a proactive Ping.
462                _ = 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                // Arm 3: incoming frame from the server.
471                msg = source.next() => {
472                    // Any received frame resets the read-timeout deadline.
473                    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                                            // Unwrap combined-stream envelope:
487                                            // { "stream": "…", "data": {…} }
488                                            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                            // Respond immediately — never blocked by the data channel.
513                            // A short timeout prevents this arm from stalling the loop
514                            // if the TCP send buffer is momentarily full.
515                            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                            // Pong, Binary, Frame — ignored
535                        }
536                    }
537                }
538            }
539        }
540    }
541
542    /// Normalize a WebSocket base URL: ensure trailing slash so path concatenation works.
543    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    // US-010: subscribe with 201 streams returns WebSocketError (validation before connect)
560    #[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    // US-010: subscribe with empty vec returns WebSocketError
573    #[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    // US-010: invalid URL returns ConfigError
585    #[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    // ReconnectConfig is now Copy — verify it can be copied without a manual field clone.
595    #[test]
596    fn reconnect_config_is_copy() {
597        let cfg = ReconnectConfig::default();
598        let _copy = cfg; // moves by copy
599        let _another = cfg; // still accessible — proves Copy
600    }
601}