bullet_rust_sdk/ws/managed.rs
1//! Auto-reconnecting WebSocket client.
2//!
3//! [`ManagedWebsocket`] wraps the raw [`WebsocketHandle`](super::client::WebsocketHandle)
4//! in a background task that handles reconnection with exponential backoff and
5//! replays subscriptions after each reconnect.
6//!
7//! Unlike `WebsocketHandle`, `ManagedWebsocket` is `Send + Sync` — it communicates
8//! with the background task via channels, so it can be shared across async tasks
9//! without a `Mutex`.
10//!
11//! This module is portable across native and wasm32 targets: the background
12//! task is spawned via [`tokio::spawn`] on native and
13//! [`wasm_bindgen_futures::spawn_local`] on wasm, and all time/channel
14//! primitives come from the `futures` crate.
15//!
16//! # Example
17//!
18//! ```ignore
19//! use bullet_rust_sdk::{Client, Topic, OrderbookDepth};
20//! use bullet_rust_sdk::ws::managed::{ManagedWebsocket, WsEvent};
21//!
22//! let client = Client::mainnet().await?;
23//! let mut ws = ManagedWebsocket::connect(&client).call().await?;
24//!
25//! ws.subscribe([Topic::depth("BTC-USD", OrderbookDepth::D20)], None)?;
26//!
27//! while let Some(event) = ws.recv().await {
28//! match event {
29//! WsEvent::Message(msg) => { /* process msg */ }
30//! WsEvent::Reconnecting => { /* log reconnect */ }
31//! WsEvent::Disconnected(err) => { /* permanent failure */ break; }
32//! }
33//! }
34//! ```
35
36use std::collections::HashSet;
37use std::time::Duration;
38
39use bon::bon;
40use futures::channel::{mpsc, oneshot};
41use futures::future::{self, Either, pending};
42use futures::{FutureExt, StreamExt};
43use futures_timer::Delay;
44use thiserror::Error;
45use tracing::{debug, info, warn};
46use web_time::Instant;
47
48use super::client::{WebsocketConfig, WebsocketHandle};
49use super::models::ServerMessage;
50use super::topics::Topic;
51use crate::Client;
52use crate::errors::WSErrors;
53use crate::types::{ClientMessage, OrderParams, RequestId};
54
55/// Errors from [`ManagedWebsocket`] operations.
56#[derive(Debug, Error)]
57pub enum ManagedWsError {
58 /// The background task has stopped (disconnected or the handle was dropped).
59 #[error("managed websocket is stopped")]
60 Stopped,
61 /// The command channel is full — the background task is not draining fast
62 /// enough. Indicates a stuck task or a pathological caller; treat as
63 /// backpressure.
64 #[error("managed websocket command channel is full")]
65 Busy,
66}
67
68/// Why a reconnect attempt gave up.
69#[derive(Debug, Error)]
70enum ReconnectError {
71 /// The user-facing handle was dropped while reconnecting.
72 #[error("managed websocket handle dropped")]
73 HandleDropped,
74 /// Ran out of retry attempts.
75 #[error("exhausted {0} reconnect attempts")]
76 RetriesExhausted(u32),
77 /// Subscription replay failed after reconnect with a non-transport error.
78 /// The underlying [`WSErrors`] is preserved so callers can distinguish
79 /// transient network failures from protocol-level problems (bad topic,
80 /// too many topics).
81 #[error("subscription replay failed: {0}")]
82 ReplayFailed(#[source] WSErrors),
83}
84
85/// Events delivered to the user from the managed WebSocket.
86#[derive(Debug)]
87pub enum WsEvent {
88 /// A message from the server.
89 Message(Box<ServerMessage>),
90 /// The connection was lost and a reconnect is in progress.
91 /// Subscriptions will be replayed automatically.
92 Reconnecting,
93 /// The connection was permanently lost after exhausting retries.
94 Disconnected(String),
95}
96
97/// Minimum backoff floor. A zero `initial_backoff` would otherwise make
98/// `backoff * 2` stay zero forever, producing a tight reconnect spin loop.
99const MIN_BACKOFF: Duration = Duration::from_millis(10);
100
101/// Default command channel capacity. Commands (subscribe, unsubscribe, order
102/// send) are intrinsically low-rate; if you're queueing more than this, the
103/// background task is stuck and the right answer is to surface
104/// [`ManagedWsError::Busy`] rather than silently buffer.
105const CMD_CHANNEL_CAPACITY: usize = 256;
106
107/// Configuration for managed WebSocket reconnection behavior.
108///
109/// # Example
110///
111/// ```ignore
112/// use bullet_rust_sdk::ManagedWsConfig;
113/// use std::time::Duration;
114///
115/// let config = ManagedWsConfig::builder()
116/// .max_retries(10)
117/// .initial_backoff(Duration::from_millis(500))
118/// .build();
119/// ```
120#[derive(bon::Builder, Clone, Debug)]
121pub struct ManagedWsConfig {
122 /// Initial delay before the first reconnect attempt.
123 ///
124 /// Default: 1 second
125 #[builder(default = Duration::from_secs(1))]
126 pub initial_backoff: Duration,
127
128 /// Maximum delay between reconnect attempts.
129 ///
130 /// Default: 30 seconds
131 #[builder(default = Duration::from_secs(30))]
132 pub max_backoff: Duration,
133
134 /// Maximum number of consecutive reconnect attempts before giving up.
135 /// `None` means retry forever.
136 ///
137 /// Default: `None` (infinite retries)
138 pub max_retries: Option<u32>,
139
140 /// Event channel buffer size. When the buffer is full and the consumer
141 /// isn't keeping up, new events are dropped to keep the WebSocket
142 /// connection alive. A warning is logged when this happens.
143 ///
144 /// Default: 10_000
145 #[builder(default = 10_000)]
146 pub channel_capacity: usize,
147
148 /// Underlying WebSocket connection config (e.g. handshake timeout).
149 pub ws_config: Option<WebsocketConfig>,
150
151 /// Force a reconnect if no server message arrives within this window.
152 ///
153 /// Protects against zombie connections — TCP keepalives and WebSocket
154 /// ping/pong keep the socket nominally alive, but the server can stop
155 /// sending data without closing. Without this, the handle sits on a dead
156 /// stream indefinitely.
157 ///
158 /// `Duration::ZERO` disables the timer. Cmd-path acks (subscribe, order
159 /// responses) DO count as server-pushed messages and reset the clock.
160 ///
161 /// Default: 60 seconds
162 #[builder(default = Duration::from_secs(60))]
163 pub idle_timeout: Duration,
164
165 /// How long a connection must stay up before the backoff state is
166 /// considered "stable" and the next disconnect starts from
167 /// [`initial_backoff`](Self::initial_backoff) again.
168 ///
169 /// Without this, a zombie that accepts connections and immediately drops
170 /// would be hammered at `initial_backoff` forever — the server never gets
171 /// the exponential-backoff relief.
172 ///
173 /// Default: 30 seconds
174 #[builder(default = Duration::from_secs(30))]
175 pub backoff_reset_after: Duration,
176}
177
178impl Default for ManagedWsConfig {
179 fn default() -> Self {
180 Self::builder().build()
181 }
182}
183
184/// Command sent from the user handle to the background task.
185///
186/// Subscribe/unsubscribe commands carry already-serialized topic strings so
187/// the background task doesn't need to re-serialize, and callers that already
188/// have string topics (notably the WASM bindings) don't need to round-trip
189/// through a typed [`Topic`].
190enum WsCommand {
191 Subscribe(Vec<String>, Option<RequestId>),
192 Unsubscribe(Vec<String>, Option<RequestId>),
193 Send(ClientMessage),
194}
195
196/// Auto-reconnecting WebSocket handle.
197///
198/// `Send + Sync` — safe to share across async tasks without a `Mutex`.
199///
200/// Subscribe/unsubscribe/order sends are fire-and-forget: the call queues a
201/// command to the background task and returns. Server acknowledgements arrive
202/// as [`WsEvent::Message`] on the event stream, matching standard CEX WS
203/// conventions.
204///
205/// Dropping the handle (or calling [`stop`](Self::stop)) terminates the
206/// background task immediately — even if it is mid-reconnect — via a separate
207/// cancellation signal that bypasses the command queue.
208pub struct ManagedWebsocket {
209 event_rx: mpsc::Receiver<WsEvent>,
210 cmd_tx: mpsc::Sender<WsCommand>,
211 /// Held, never sent on. Dropping signals shutdown to the background task.
212 _shutdown_tx: oneshot::Sender<()>,
213}
214
215impl ManagedWebsocket {
216 /// Open a managed (auto-reconnecting) WebSocket connection for the given
217 /// [`Client`].
218 ///
219 /// The returned handle manages reconnection with exponential backoff and
220 /// replays subscriptions automatically.
221 ///
222 /// # Example
223 ///
224 /// ```ignore
225 /// let mut ws = ManagedWebsocket::connect(&client).call().await?;
226 /// ws.subscribe([Topic::agg_trade("BTC-USD")], None)?;
227 /// ```
228 #[cfg_attr(not(target_arch = "wasm32"), doc = "Uses [`tokio::spawn`] on native targets.")]
229 #[cfg_attr(
230 target_arch = "wasm32",
231 doc = "Uses [`wasm_bindgen_futures::spawn_local`] on wasm targets."
232 )]
233 pub async fn connect(client: &Client) -> Result<ManagedWebsocket, WSErrors> {
234 Self::connect_with(client, ManagedWsConfig::default()).await
235 }
236
237 /// Like [`connect`](Self::connect) but takes an explicit [`ManagedWsConfig`].
238 pub async fn connect_with(
239 client: &Client,
240 config: ManagedWsConfig,
241 ) -> Result<ManagedWebsocket, WSErrors> {
242 let ws = client.connect_ws().maybe_config(config.ws_config.clone()).call().await?;
243
244 let (event_tx, event_rx) = mpsc::channel(config.channel_capacity);
245 let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
246 let (shutdown_tx, shutdown_rx) = oneshot::channel();
247
248 let inner = ManagedWsClient::from_client(client);
249
250 spawn(async move {
251 run_managed_ws(inner, ws, config, event_tx, cmd_rx, shutdown_rx).await;
252 });
253
254 Ok(ManagedWebsocket { event_rx, cmd_tx, _shutdown_tx: shutdown_tx })
255 }
256
257 /// Receive the next event from the WebSocket.
258 ///
259 /// Returns `None` when the background task has stopped (after permanent
260 /// disconnection or [`stop`](Self::stop)).
261 pub async fn recv(&mut self) -> Option<WsEvent> {
262 self.event_rx.next().await
263 }
264
265 /// Subscribe to topics. The subscription is tracked and replayed on reconnect.
266 ///
267 /// This is fire-and-forget — it queues the command to the background task.
268 /// The server's subscribe acknowledgement arrives as a [`WsEvent::Message`].
269 pub fn subscribe(
270 &self,
271 topics: impl IntoIterator<Item = Topic>,
272 id: Option<RequestId>,
273 ) -> Result<(), ManagedWsError> {
274 let params: Vec<String> = topics.into_iter().map(|t| t.to_string()).collect();
275 self.try_send_cmd(WsCommand::Subscribe(params, id))
276 }
277
278 /// Subscribe using pre-serialized topic strings (e.g. `"BTC-USD@aggTrade"`).
279 ///
280 /// Prefer [`subscribe`](Self::subscribe) with typed [`Topic`] values from
281 /// native Rust — this overload exists for binding layers (WASM/JS) that
282 /// already hold string topics.
283 pub fn subscribe_raw(
284 &self,
285 topics: impl IntoIterator<Item = String>,
286 id: Option<RequestId>,
287 ) -> Result<(), ManagedWsError> {
288 let params: Vec<String> = topics.into_iter().collect();
289 self.try_send_cmd(WsCommand::Subscribe(params, id))
290 }
291
292 /// Unsubscribe from topics. Removes them from the replay list.
293 pub fn unsubscribe(
294 &self,
295 topics: impl IntoIterator<Item = Topic>,
296 id: Option<RequestId>,
297 ) -> Result<(), ManagedWsError> {
298 let params: Vec<String> = topics.into_iter().map(|t| t.to_string()).collect();
299 self.try_send_cmd(WsCommand::Unsubscribe(params, id))
300 }
301
302 /// Raw-string counterpart of [`unsubscribe`](Self::unsubscribe).
303 pub fn unsubscribe_raw(
304 &self,
305 topics: impl IntoIterator<Item = String>,
306 id: Option<RequestId>,
307 ) -> Result<(), ManagedWsError> {
308 let params: Vec<String> = topics.into_iter().collect();
309 self.try_send_cmd(WsCommand::Unsubscribe(params, id))
310 }
311
312 /// Place an order via WebSocket.
313 pub fn order_place(
314 &self,
315 tx: impl Into<String>,
316 id: Option<RequestId>,
317 ) -> Result<(), ManagedWsError> {
318 self.try_send_cmd(WsCommand::Send(ClientMessage::OrderPlace {
319 id,
320 params: OrderParams { tx: tx.into() },
321 }))
322 }
323
324 /// Cancel an order via WebSocket.
325 pub fn order_cancel(
326 &self,
327 tx: impl Into<String>,
328 id: Option<RequestId>,
329 ) -> Result<(), ManagedWsError> {
330 self.try_send_cmd(WsCommand::Send(ClientMessage::OrderCancel {
331 id,
332 params: OrderParams { tx: tx.into() },
333 }))
334 }
335
336 /// Amend an order via WebSocket.
337 pub fn order_amend(
338 &self,
339 tx: impl Into<String>,
340 id: Option<RequestId>,
341 ) -> Result<(), ManagedWsError> {
342 self.try_send_cmd(WsCommand::Send(ClientMessage::OrderAmend {
343 id,
344 params: OrderParams { tx: tx.into() },
345 }))
346 }
347
348 /// Cancel all open orders for a market via WebSocket.
349 pub fn order_cancel_all(
350 &self,
351 tx: impl Into<String>,
352 id: Option<RequestId>,
353 ) -> Result<(), ManagedWsError> {
354 self.try_send_cmd(WsCommand::Send(ClientMessage::OrderCancelAll {
355 id,
356 params: OrderParams { tx: tx.into() },
357 }))
358 }
359
360 /// Place an order using a signed [`Transaction`]. Base64-encodes internally.
361 ///
362 /// Returns a `SDKResult`-style error instead of `ManagedWsError` because
363 /// encoding can fail independently of the channel state.
364 ///
365 /// [`Transaction`]: bullet_exchange_interface::transaction::Transaction
366 pub fn place_order(
367 &self,
368 signed: &bullet_exchange_interface::transaction::Transaction,
369 id: Option<RequestId>,
370 ) -> Result<(), WSErrors> {
371 let base64 =
372 crate::Transaction::to_base64(signed).map_err(|e| WSErrors::WsError(e.to_string()))?;
373 self.order_place(base64, id).map_err(|e| WSErrors::WsError(e.to_string()))
374 }
375
376 /// Cancel an order using a signed [`Transaction`]. Base64-encodes internally.
377 ///
378 /// [`Transaction`]: bullet_exchange_interface::transaction::Transaction
379 pub fn cancel_order(
380 &self,
381 signed: &bullet_exchange_interface::transaction::Transaction,
382 id: Option<RequestId>,
383 ) -> Result<(), WSErrors> {
384 let base64 =
385 crate::Transaction::to_base64(signed).map_err(|e| WSErrors::WsError(e.to_string()))?;
386 self.order_cancel(base64, id).map_err(|e| WSErrors::WsError(e.to_string()))
387 }
388
389 /// Amend an order using a signed [`Transaction`]. Base64-encodes internally.
390 ///
391 /// [`Transaction`]: bullet_exchange_interface::transaction::Transaction
392 pub fn amend_order(
393 &self,
394 signed: &bullet_exchange_interface::transaction::Transaction,
395 id: Option<RequestId>,
396 ) -> Result<(), WSErrors> {
397 let base64 =
398 crate::Transaction::to_base64(signed).map_err(|e| WSErrors::WsError(e.to_string()))?;
399 self.order_amend(base64, id).map_err(|e| WSErrors::WsError(e.to_string()))
400 }
401
402 /// Cancel all open orders using a signed [`Transaction`]. Base64-encodes internally.
403 ///
404 /// [`Transaction`]: bullet_exchange_interface::transaction::Transaction
405 pub fn cancel_all_orders(
406 &self,
407 signed: &bullet_exchange_interface::transaction::Transaction,
408 id: Option<RequestId>,
409 ) -> Result<(), WSErrors> {
410 let base64 =
411 crate::Transaction::to_base64(signed).map_err(|e| WSErrors::WsError(e.to_string()))?;
412 self.order_cancel_all(base64, id).map_err(|e| WSErrors::WsError(e.to_string()))
413 }
414
415 /// Stop the managed WebSocket and its background task.
416 ///
417 /// After this returns the background task has been signaled; it will
418 /// terminate at its next await point without draining pending commands.
419 /// The event stream will end (`recv()` returns `None`) shortly after.
420 pub fn stop(self) {
421 // Drop self — `_shutdown_tx` is dropped, closing the oneshot. Task sees
422 // the signal and exits without going through the cmd queue.
423 }
424
425 fn try_send_cmd(&self, cmd: WsCommand) -> Result<(), ManagedWsError> {
426 // `Sender::clone` is an Arc bump, so try_send (which needs &mut self)
427 // can be called without requiring `&mut self` on the public API.
428 let mut tx = self.cmd_tx.clone();
429 tx.try_send(cmd)
430 .map_err(|e| if e.is_full() { ManagedWsError::Busy } else { ManagedWsError::Stopped })
431 }
432}
433
434// `mpsc::Sender`/`Receiver` are `Send`, and `oneshot::Sender<()>` is `Send + Sync`.
435// The handle is explicitly `Send + Sync` on all targets so callers can share it
436// across async tasks without a `Mutex`.
437
438/// Convenience wrapper on [`Client`] that forwards to [`ManagedWebsocket::connect`].
439///
440/// Kept as a thin helper so the common case (`client.connect_ws_managed()`) is
441/// discoverable; the real dependency still flows `managed → client`.
442#[bon]
443impl Client {
444 #[builder]
445 pub async fn connect_ws_managed(
446 &self,
447 config: Option<ManagedWsConfig>,
448 ) -> Result<ManagedWebsocket, WSErrors> {
449 match config {
450 Some(c) => ManagedWebsocket::connect_with(self, c).await,
451 None => ManagedWebsocket::connect(self).await,
452 }
453 }
454}
455
456/// Minimal client data needed by the background task for reconnection.
457///
458/// Constructed via [`ManagedWsClient::from_client`] so `Client` has no
459/// compile-time dependency on this struct.
460struct ManagedWsClient {
461 ws_client: reqwest::Client,
462 ws_url: String,
463}
464
465impl ManagedWsClient {
466 fn from_client(client: &Client) -> Self {
467 Self { ws_client: client.ws_client.clone(), ws_url: client.ws_url().to_string() }
468 }
469
470 async fn connect(
471 &self,
472 ws_config: &Option<WebsocketConfig>,
473 ) -> Result<WebsocketHandle, WSErrors> {
474 let timeout = ws_config
475 .as_ref()
476 .map(|c| c.connection_timeout)
477 .unwrap_or(web_time::Duration::from_secs(10));
478 WebsocketHandle::connect(&self.ws_client, &self.ws_url, timeout).await
479 }
480}
481
482/// Spawn a background future on the target's executor.
483///
484/// Native uses [`tokio::spawn`] (requires `Send`); wasm uses
485/// [`wasm_bindgen_futures::spawn_local`] (no `Send` required since JS is
486/// single-threaded).
487#[cfg(not(target_arch = "wasm32"))]
488fn spawn<F>(fut: F)
489where
490 F: std::future::Future<Output = ()> + Send + 'static,
491{
492 tokio::spawn(fut);
493}
494
495#[cfg(target_arch = "wasm32")]
496fn spawn<F>(fut: F)
497where
498 F: std::future::Future<Output = ()> + 'static,
499{
500 wasm_bindgen_futures::spawn_local(fut);
501}
502
503/// Persistent state carried across reconnect cycles.
504///
505/// The backoff duration persists across disconnect cycles so a zombie that
506/// accepts connections and immediately drops gets proper exponential relief
507/// instead of being hammered at `initial_backoff` forever. It resets to
508/// `initial_backoff` only after a connection has stayed up for
509/// `backoff_reset_after`.
510struct ReconnectState {
511 backoff: Duration,
512 /// When the *current* connection was established. `None` only before the
513 /// very first successful reconnect (the initial connect sets this on
514 /// entry to [`run_managed_ws`]).
515 connected_since: Option<Instant>,
516}
517
518/// Background task that manages the WebSocket lifecycle.
519async fn run_managed_ws(
520 client: ManagedWsClient,
521 mut ws: WebsocketHandle,
522 config: ManagedWsConfig,
523 mut event_tx: mpsc::Sender<WsEvent>,
524 mut cmd_rx: mpsc::Receiver<WsCommand>,
525 mut shutdown_rx: oneshot::Receiver<()>,
526) {
527 let mut active_topics: HashSet<String> = HashSet::new();
528 let mut last_msg = Instant::now();
529 let mut state = ReconnectState {
530 backoff: config.initial_backoff.max(MIN_BACKOFF),
531 connected_since: Some(Instant::now()),
532 };
533
534 /// One completed branch of the per-iteration select.
535 ///
536 /// `Recv` boxes the server message so this enum stays small — `ServerMessage`
537 /// is ~340 bytes and sits on the stack every iteration otherwise.
538 enum Branch {
539 Shutdown,
540 Recv(Result<Box<ServerMessage>, WSErrors>),
541 Cmd(Option<WsCommand>),
542 Idle,
543 }
544
545 loop {
546 // Idle-timeout future: fires if no server-pushed message arrives within
547 // the window. `Duration::ZERO` disables the timer.
548 let idle_remaining = if config.idle_timeout.is_zero() {
549 None
550 } else {
551 Some(config.idle_timeout.saturating_sub(last_msg.elapsed()))
552 };
553
554 // Run the select in its own scope so the fused recv/cmd futures (which
555 // hold `&mut ws` / `&mut cmd_rx`) are dropped before we touch those
556 // receivers again in the match arms below.
557 let branch = {
558 let recv_fut = ws.recv().fuse();
559 let cmd_fut = cmd_rx.next().fuse();
560 let idle_fut = match idle_remaining {
561 Some(d) => Either::Left(Delay::new(d)),
562 None => Either::Right(pending::<()>()),
563 }
564 .fuse();
565 futures::pin_mut!(recv_fut, cmd_fut, idle_fut);
566
567 futures::select! {
568 _ = (&mut shutdown_rx).fuse() => Branch::Shutdown,
569 r = recv_fut => Branch::Recv(r.map(Box::new)),
570 c = cmd_fut => Branch::Cmd(c),
571 _ = idle_fut => Branch::Idle,
572 }
573 };
574
575 match branch {
576 Branch::Shutdown => {
577 debug!("shutdown signaled, stopping managed ws");
578 return;
579 }
580 Branch::Recv(Ok(msg)) => {
581 // Server proved it's alive — reset the idle timer.
582 last_msg = Instant::now();
583 match event_tx.try_send(WsEvent::Message(msg)) {
584 Ok(()) => {}
585 Err(e) if e.is_full() => {
586 warn!("event channel full, dropping message — consumer too slow");
587 }
588 Err(_) => {
589 debug!("event receiver dropped, stopping managed ws");
590 return;
591 }
592 }
593 }
594 Branch::Recv(Err(e)) => {
595 match &e {
596 WSErrors::WsClosed { code, reason } => {
597 warn!(?code, %reason, "WebSocket disconnected, reconnecting");
598 }
599 WSErrors::WsStreamEnded => {
600 warn!("WebSocket stream ended, reconnecting");
601 }
602 _ => {
603 warn!(?e, "WebSocket error, reconnecting");
604 }
605 }
606 if do_reconnect(
607 &client,
608 &config,
609 &active_topics,
610 &mut event_tx,
611 &mut ws,
612 &mut shutdown_rx,
613 &mut state,
614 )
615 .await
616 {
617 return;
618 }
619 last_msg = Instant::now();
620 }
621 Branch::Idle => {
622 let elapsed = last_msg.elapsed();
623 warn!(?elapsed, "no server messages within idle timeout, forcing reconnect");
624 if do_reconnect(
625 &client,
626 &config,
627 &active_topics,
628 &mut event_tx,
629 &mut ws,
630 &mut shutdown_rx,
631 &mut state,
632 )
633 .await
634 {
635 return;
636 }
637 last_msg = Instant::now();
638 }
639 Branch::Cmd(Some(WsCommand::Subscribe(params, id))) => {
640 // Dedup: only send for topics we aren't already subscribed to.
641 // The server may reject duplicates with an unhelpful error, and
642 // the topic set is the source of truth for replay.
643 let new_params: Vec<String> =
644 params.into_iter().filter(|p| active_topics.insert(p.clone())).collect();
645 if new_params.is_empty() {
646 debug!("subscribe: all topics already active, skipping wire send");
647 } else if let Err(e) =
648 ws.send(ClientMessage::Subscribe { id, params: new_params }).await
649 {
650 debug!(?e, "subscribe send failed, will replay after reconnect");
651 }
652 }
653 Branch::Cmd(Some(WsCommand::Unsubscribe(params, id))) => {
654 // Dedup: only send for topics we're actually subscribed to.
655 let to_send: Vec<String> =
656 params.into_iter().filter(|p| active_topics.remove(p)).collect();
657 if to_send.is_empty() {
658 debug!("unsubscribe: no matching active topics, skipping wire send");
659 } else if let Err(e) =
660 ws.send(ClientMessage::Unsubscribe { id, params: to_send }).await
661 {
662 debug!(?e, "unsubscribe send failed");
663 }
664 }
665 Branch::Cmd(Some(WsCommand::Send(msg))) => {
666 if let Err(e) = ws.send(msg.clone()).await {
667 warn!(?e, "failed to send order message, reconnecting");
668 if do_reconnect(
669 &client,
670 &config,
671 &active_topics,
672 &mut event_tx,
673 &mut ws,
674 &mut shutdown_rx,
675 &mut state,
676 )
677 .await
678 {
679 return;
680 }
681 // Retry the message once on the new connection. If it fails
682 // again the connection is still broken and do_reconnect will
683 // run again on the next loop iteration.
684 if let Err(e) = ws.send(msg).await {
685 warn!(?e, "retry after reconnect also failed");
686 }
687 last_msg = Instant::now();
688 }
689 }
690 Branch::Cmd(None) => {
691 debug!("command channel closed, stopping managed ws");
692 return;
693 }
694 }
695 }
696}
697
698/// Handle reconnection. Returns `true` if the task should stop.
699///
700/// Emits `WsEvent::Reconnecting`, reuses/resets backoff per
701/// [`ReconnectState`], and on success updates `state.connected_since` and
702/// replaces `*ws` with the new handle.
703async fn do_reconnect(
704 client: &ManagedWsClient,
705 config: &ManagedWsConfig,
706 active_topics: &HashSet<String>,
707 event_tx: &mut mpsc::Sender<WsEvent>,
708 ws: &mut WebsocketHandle,
709 shutdown_rx: &mut oneshot::Receiver<()>,
710 state: &mut ReconnectState,
711) -> bool {
712 match event_tx.try_send(WsEvent::Reconnecting) {
713 Ok(()) => {}
714 Err(e) if e.is_full() => {}
715 Err(_) => return true,
716 }
717
718 // If the previous connection was stable for long enough, reset the
719 // exponential backoff. Otherwise carry it forward so we don't hammer a
720 // zombie that accepts-then-drops at `initial_backoff` forever.
721 if let Some(t) = state.connected_since
722 && t.elapsed() >= config.backoff_reset_after
723 {
724 debug!(
725 uptime = ?t.elapsed(),
726 "previous connection was stable; resetting backoff"
727 );
728 state.backoff = config.initial_backoff.max(MIN_BACKOFF);
729 }
730 state.connected_since = None;
731
732 match reconnect(client, config, active_topics, event_tx, shutdown_rx, state).await {
733 Ok(new_ws) => {
734 *ws = new_ws;
735 state.connected_since = Some(Instant::now());
736 info!("reconnected successfully");
737 false
738 }
739 Err(ReconnectError::HandleDropped) => true,
740 Err(err) => {
741 let _ = event_tx.try_send(WsEvent::Disconnected(err.to_string()));
742 true
743 }
744 }
745}
746
747/// Reconnect with exponential backoff + jitter and replay subscriptions.
748///
749/// Observes `shutdown_rx` during every sleep and between connect attempts so
750/// dropping the handle terminates the loop promptly. `state.backoff` is
751/// mutated in place so growth persists across calls (see [`do_reconnect`]).
752async fn reconnect(
753 client: &ManagedWsClient,
754 config: &ManagedWsConfig,
755 active_topics: &HashSet<String>,
756 event_tx: &mpsc::Sender<WsEvent>,
757 shutdown_rx: &mut oneshot::Receiver<()>,
758 state: &mut ReconnectState,
759) -> Result<WebsocketHandle, ReconnectError> {
760 let max_backoff = config.max_backoff.max(MIN_BACKOFF);
761 let mut attempts = 0u32;
762
763 loop {
764 if shutdown_observed(shutdown_rx) || event_tx.is_closed() {
765 return Err(ReconnectError::HandleDropped);
766 }
767
768 attempts += 1;
769 if let Some(max) = config.max_retries
770 && attempts > max
771 {
772 return Err(ReconnectError::RetriesExhausted(max));
773 }
774
775 // Jitter: add 0..50% of backoff to avoid thundering herd.
776 let jitter_ms = rand::random::<u64>() % (state.backoff.as_millis() as u64 / 2 + 1);
777 let delay = state.backoff + Duration::from_millis(jitter_ms);
778
779 info!(attempt = attempts, delay = ?delay, backoff = ?state.backoff, "attempting reconnect");
780
781 match future::select(Delay::new(delay), &mut *shutdown_rx).await {
782 Either::Left(_) => {}
783 Either::Right(_) => return Err(ReconnectError::HandleDropped),
784 }
785
786 let connect_fut = client.connect(&config.ws_config);
787 let connect_result = match future::select(Box::pin(connect_fut), &mut *shutdown_rx).await {
788 Either::Left((r, _)) => r,
789 Either::Right(_) => return Err(ReconnectError::HandleDropped),
790 };
791
792 match connect_result {
793 Ok(mut ws) => {
794 if !active_topics.is_empty() {
795 let params: Vec<String> = active_topics.iter().cloned().collect();
796 debug!(count = params.len(), "replaying subscriptions");
797 if let Err(e) = ws.send(ClientMessage::Subscribe { id: None, params }).await {
798 // Distinguish protocol errors (bad topic, oversize) from
799 // transport errors (connection died mid-replay).
800 if matches!(&e, WSErrors::WsStreamEnded | WSErrors::WsClosed { .. }) {
801 warn!(?e, "replay send lost connection, retrying");
802 state.backoff = (state.backoff * 2).min(max_backoff);
803 continue;
804 }
805 return Err(ReconnectError::ReplayFailed(e));
806 }
807 }
808 return Ok(ws);
809 }
810 Err(e) => {
811 warn!(?e, attempt = attempts, "reconnect failed");
812 state.backoff = (state.backoff * 2).min(max_backoff);
813 }
814 }
815 }
816}
817
818/// Returns `true` if shutdown has been signaled (sender sent, dropped, or the
819/// receiver is otherwise resolved).
820fn shutdown_observed(rx: &mut oneshot::Receiver<()>) -> bool {
821 !matches!(rx.try_recv(), Ok(None))
822}