Skip to main content

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}