Skip to main content

nautilus_polymarket/websocket/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides the WebSocket client for the Polymarket CLOB API.
17
18use std::{
19    num::NonZeroU32,
20    sync::{
21        Arc,
22        atomic::{AtomicBool, AtomicU8, Ordering},
23    },
24    time::Duration,
25};
26
27use nautilus_core::datetime::MILLISECONDS_IN_SECOND;
28use nautilus_live::{
29    SocketControl,
30    book::snapshot::SnapshotGate,
31    task::{TaskJoinOutcome, TaskSlot, finish_task},
32};
33use nautilus_network::{
34    ConnectionUrl, SocketStateSink,
35    http::create_standard_nautilus_headers,
36    mode::ConnectionMode,
37    websocket::{
38        AuthTracker, InitialConnectRetryPolicy, SubscriptionState, TransportBackend,
39        WebSocketClient, WebSocketConfig, channel_epoch_message_handler, proxy::ProxyUrl,
40    },
41};
42
43use super::{
44    handler::{FeedHandler, HandlerCommand},
45    messages::PolymarketWsMessage,
46};
47use crate::common::{
48    credential::Credential,
49    urls::{clob_ws_market_url, clob_ws_user_url},
50};
51
52// The venue counts only the `PING` text frame, not protocol ping frames, and
53// closes with `1008 no ping received` otherwise. Cadence per venue docs:
54// https://docs.polymarket.com/api-reference/wss/market
55pub(super) const POLYMARKET_HEARTBEAT_SECS: u64 = 10;
56pub(super) const POLYMARKET_HEARTBEAT_PAYLOAD: &str = "PING";
57
58// Prediction markets go quiet for long stretches, so liveness is the venue
59// still sending frames, not data arriving. A data-silence timer cannot serve:
60// `PONG` is a text frame and refreshes it. Tear down after three cycles.
61const POLYMARKET_HEARTBEAT_TIMEOUT_SECS: u64 = POLYMARKET_HEARTBEAT_SECS * 3;
62
63/// Polymarket WebSocket channel: market data or authenticated user data.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum WsChannel {
66    Market,
67    User,
68}
69
70/// Lightweight handle for subscribing/unsubscribing to market data.
71///
72/// `Clone` + `Send` safe for use in spawned async tasks.
73#[derive(Clone, Debug)]
74pub struct WsSubscriptionHandle {
75    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
76}
77
78impl WsSubscriptionHandle {
79    /// Sends a market subscribe command to the handler.
80    pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
81        self.cmd_tx
82            .read()
83            .await
84            .send(HandlerCommand::SubscribeMarket(asset_ids))
85            .map_err(|e| anyhow::anyhow!("Failed to send SubscribeMarket: {e}"))
86    }
87
88    /// Sends a market unsubscribe command to the handler.
89    pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
90        self.cmd_tx
91            .read()
92            .await
93            .send(HandlerCommand::UnsubscribeMarket(asset_ids))
94            .map_err(|e| anyhow::anyhow!("Failed to send UnsubscribeMarket: {e}"))
95    }
96
97    /// Sends a recovery subscription-cycle command to the handler.
98    pub async fn cycle_market_subscription(
99        &self,
100        asset_ids: Vec<String>,
101        cancel: tokio_util::sync::CancellationToken,
102        responder: tokio::sync::oneshot::Sender<super::handler::CycleMarketOutcome>,
103        gate: SnapshotGate,
104    ) -> anyhow::Result<()> {
105        self.cmd_tx
106            .read()
107            .await
108            .send(HandlerCommand::CycleMarketSubscription {
109                asset_ids,
110                cancel,
111                responder,
112                gate,
113            })
114            .map_err(|e| anyhow::anyhow!("Failed to send CycleMarketSubscription: {e}"))
115    }
116
117    // Constructs a handle around a raw command sender. Test-only: lets unit
118    // tests observe the commands the handle emits without spinning up the real
119    // feed handler.
120    #[cfg(test)]
121    pub(crate) fn from_sender(sender: tokio::sync::mpsc::UnboundedSender<HandlerCommand>) -> Self {
122        Self {
123            cmd_tx: Arc::new(tokio::sync::RwLock::new(sender)),
124        }
125    }
126}
127
128/// Provides a WebSocket client for the Polymarket CLOB API.
129///
130/// A single instance targets one channel (market or user). Use
131/// [`PolymarketWebSocketClient::new_market`] for public market data and
132/// [`PolymarketWebSocketClient::new_user`] for authenticated order/trade streams.
133#[derive(Debug)]
134pub struct PolymarketWebSocketClient {
135    channel: WsChannel,
136    url: ConnectionUrl,
137    connection_mode: Arc<AtomicU8>,
138    signal: Arc<AtomicBool>,
139    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
140    out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>>,
141    credential: Option<Credential>,
142    subscriptions: SubscriptionState,
143    discovery_subscribed: Arc<AtomicBool>,
144    auth_tracker: AuthTracker,
145    // Survives disconnect() so that connect() can replay a prior subscribe_user() call.
146    // Arc<AtomicBool> allows mutation from &self in subscribe_user().
147    user_subscribed: Arc<AtomicBool>,
148    task_handle: TaskSlot<()>,
149    subscribe_new_markets: bool,
150    transport_backend: TransportBackend,
151    connect_timeout_ms: u64,
152    proxy_url: Option<ProxyUrl>,
153    socket_sink: Option<SocketStateSink>,
154    socket_control: Option<SocketControl>,
155}
156
157#[derive(Clone, Debug)]
158pub(crate) struct PolymarketWebSocketShutdownHandle {
159    signal: Arc<AtomicBool>,
160}
161
162impl PolymarketWebSocketShutdownHandle {
163    pub(crate) fn begin_shutdown(&self) {
164        self.signal.store(true, Ordering::Relaxed);
165    }
166}
167
168impl PolymarketWebSocketClient {
169    /// Creates a new market-channel client (unauthenticated).
170    ///
171    /// If `base_url` is `None`, the default production URL is used.
172    #[must_use]
173    pub fn new_market(
174        base_url: Option<String>,
175        subscribe_new_markets: bool,
176        transport_backend: TransportBackend,
177    ) -> Self {
178        Self::new_market_with_proxy(base_url, subscribe_new_markets, transport_backend, None)
179    }
180
181    /// Creates a new market-channel client with an optional validated proxy URL.
182    #[must_use]
183    pub fn new_market_with_proxy(
184        base_url: Option<String>,
185        subscribe_new_markets: bool,
186        transport_backend: TransportBackend,
187        proxy_url: Option<ProxyUrl>,
188    ) -> Self {
189        let url = base_url.unwrap_or_else(|| clob_ws_market_url().to_string());
190        Self::new_inner(
191            WsChannel::Market,
192            url,
193            None,
194            subscribe_new_markets,
195            transport_backend,
196            proxy_url,
197        )
198    }
199
200    /// Creates a new user-channel client (authenticated).
201    ///
202    /// If `base_url` is `None`, the default production URL is used.
203    #[must_use]
204    pub fn new_user(
205        base_url: Option<String>,
206        credential: Credential,
207        transport_backend: TransportBackend,
208    ) -> Self {
209        Self::new_user_with_proxy(base_url, credential, transport_backend, None)
210    }
211
212    /// Creates a new user-channel client with an optional validated proxy URL.
213    #[must_use]
214    pub fn new_user_with_proxy(
215        base_url: Option<String>,
216        credential: Credential,
217        transport_backend: TransportBackend,
218        proxy_url: Option<ProxyUrl>,
219    ) -> Self {
220        let url = base_url.unwrap_or_else(|| clob_ws_user_url().to_string());
221        Self::new_inner(
222            WsChannel::User,
223            url,
224            Some(credential),
225            false,
226            transport_backend,
227            proxy_url,
228        )
229    }
230
231    fn new_inner(
232        channel: WsChannel,
233        url: String,
234        credential: Option<Credential>,
235        subscribe_new_markets: bool,
236        transport_backend: TransportBackend,
237        proxy_url: Option<ProxyUrl>,
238    ) -> Self {
239        let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
240        Self {
241            channel,
242            url: url.into(),
243            connection_mode: Arc::new(AtomicU8::new(ConnectionMode::Closed.as_u8())),
244            signal: Arc::new(AtomicBool::new(false)),
245            cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
246            out_rx: None,
247            credential,
248            subscriptions: SubscriptionState::new(':'),
249            discovery_subscribed: Arc::new(AtomicBool::new(false)),
250            auth_tracker: AuthTracker::new(),
251            user_subscribed: Arc::new(AtomicBool::new(false)),
252            task_handle: TaskSlot::new(),
253            subscribe_new_markets,
254            transport_backend,
255            connect_timeout_ms: 15_000,
256            proxy_url,
257            socket_sink: None,
258            socket_control: None,
259        }
260    }
261
262    pub(crate) fn with_connect_timeout(mut self, timeout_secs: u64) -> Self {
263        self.connect_timeout_ms = timeout_secs.saturating_mul(MILLISECONDS_IN_SECOND);
264        self
265    }
266
267    /// Configures socket state reporting for the underlying transport.
268    #[must_use]
269    pub fn with_state_sink(mut self, state_sink: SocketStateSink) -> Self {
270        self.socket_sink = Some(state_sink);
271        self
272    }
273
274    /// Configures state reporting and reconnect control for the underlying transport.
275    #[must_use]
276    pub(crate) fn with_socket_control(mut self, control: SocketControl) -> Self {
277        self.socket_control = Some(control);
278        self
279    }
280
281    #[cfg(test)]
282    pub(crate) fn proxy_url(&self) -> Option<&ProxyUrl> {
283        self.proxy_url.as_ref()
284    }
285
286    /// Establishes the WebSocket connection and spawns the message handler.
287    pub async fn connect(&mut self) -> anyhow::Result<()> {
288        let mode = ConnectionMode::from_atomic(&self.connection_mode);
289        if mode.is_active() || mode.is_reconnect() {
290            log::warn!("Polymarket WebSocket already connected or reconnecting");
291            return Ok(());
292        }
293
294        if self.task_handle.is_some() {
295            self.disconnect().await?;
296        }
297
298        let (message_handler, raw_rx) = channel_epoch_message_handler();
299        let cfg = self.websocket_config();
300
301        let client = WebSocketClient::epoch_builder()
302            .config(cfg)
303            .epoch_handler(message_handler)
304            .maybe_initial_connect_retry_policy(
305                (self.channel == WsChannel::Market).then(Self::initial_connect_retry_policy),
306            )
307            .maybe_state_sink(
308                self.socket_control
309                    .as_ref()
310                    .map(SocketControl::sink)
311                    .or_else(|| self.socket_sink.clone()),
312            )
313            .connect()
314            .await?;
315
316        if let Some(control) = &self.socket_control {
317            let handle = client.reconnect_handle();
318            control.register(move || handle.request_reconnect());
319        }
320        let connection_epoch = client.connection_epoch();
321
322        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
323        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<PolymarketWsMessage>();
324
325        *self.cmd_tx.write().await = cmd_tx.clone();
326        self.out_rx = Some(out_rx);
327
328        let client_mode = client.connection_mode_atomic();
329        self.connection_mode = client_mode;
330
331        log::debug!("Polymarket WebSocket connected: {}", self.url.origin());
332
333        // Replay retained state onto the new session. Unlike the RECONNECTED sentinel
334        // path, a fresh connect() never fires resubscribe_all() inside the handler.
335        let initial_market_replay = match self.channel {
336            WsChannel::Market => {
337                let topics = self.subscriptions.reset_after_reconnect();
338                if !topics.is_empty() || self.discovery_subscribed.load(Ordering::Relaxed) {
339                    log::debug!(
340                        "Replaying market subscription state onto new session: assets={}, discovery={}",
341                        topics.len(),
342                        self.discovery_subscribed.load(Ordering::Relaxed),
343                    );
344                    Some((topics, connection_epoch))
345                } else {
346                    None
347                }
348            }
349            WsChannel::User => {
350                if self.user_subscribed.load(Ordering::Relaxed) {
351                    log::debug!("Replaying user subscribe onto new session");
352                    cmd_tx
353                        .send(HandlerCommand::SubscribeUser)
354                        .map_err(|e| anyhow::anyhow!("Failed to replay SubscribeUser: {e}"))?;
355                }
356                None
357            }
358        };
359
360        let signal = Arc::clone(&self.signal);
361        let channel = self.channel;
362        let credential = self.credential.clone();
363        let subscriptions = self.subscriptions.clone();
364        let discovery_subscribed = Arc::clone(&self.discovery_subscribed);
365        let auth_tracker = self.auth_tracker.clone();
366        let user_subscribed = self.user_subscribed.load(Ordering::Relaxed);
367        let subscribe_new_markets = self.subscribe_new_markets;
368
369        if let Err(e) = self.task_handle.spawn(async move {
370            let mut handler = FeedHandler::new(
371                signal,
372                channel,
373                Some(client),
374                cmd_rx,
375                raw_rx,
376                out_tx,
377                credential,
378                subscriptions,
379                discovery_subscribed,
380                initial_market_replay,
381                auth_tracker,
382                user_subscribed,
383                subscribe_new_markets,
384            );
385
386            loop {
387                match handler.next().await {
388                    Some(PolymarketWsMessage::Reconnected { .. }) => {
389                        log::info!("Polymarket WebSocket reconnected");
390
391                        if handler
392                            .send(PolymarketWsMessage::Reconnected { shard_id: None })
393                            .is_err()
394                        {
395                            if handler.is_stopped() {
396                                log::debug!("Output channel closed, stopping handler");
397                            } else {
398                                log::error!("Output channel closed, stopping handler");
399                            }
400                            break;
401                        }
402                    }
403                    Some(msg) => {
404                        if handler.send(msg).is_err() {
405                            if handler.is_stopped() {
406                                log::debug!("Output channel closed, stopping handler");
407                            } else {
408                                log::error!("Output channel closed, stopping handler");
409                            }
410                            break;
411                        }
412                    }
413                    None => {
414                        if handler.is_stopped() {
415                            log::debug!("Stop signal received, ending handler task");
416                        } else {
417                            log::warn!("Polymarket WebSocket stream ended unexpectedly");
418                        }
419                        break;
420                    }
421                }
422            }
423            log::debug!("Polymarket WebSocket handler task completed");
424        }) {
425            self.out_rx = None;
426            anyhow::bail!("Failed to start Polymarket WebSocket handler task: {e}");
427        }
428        Ok(())
429    }
430
431    fn initial_connect_retry_policy() -> InitialConnectRetryPolicy {
432        InitialConnectRetryPolicy {
433            max_attempts: NonZeroU32::new(5).expect("initial connect attempts must be non-zero"),
434            delay_initial: Duration::from_millis(500),
435            delay_max: Duration::from_secs(5),
436            backoff_factor: 2.0,
437            jitter_ms: 250,
438        }
439    }
440
441    fn websocket_config(&self) -> WebSocketConfig {
442        // The market endpoint rejects text PING before its initial subscription. Protocol pings
443        // keep an idle socket alive until FeedHandler starts the required text heartbeat.
444        let heartbeat_payload = match self.channel {
445            WsChannel::Market => None,
446            WsChannel::User => Some(POLYMARKET_HEARTBEAT_PAYLOAD.to_string()),
447        };
448
449        let headers = create_standard_nautilus_headers();
450
451        WebSocketConfig {
452            url: self.url.expose().to_owned(),
453            headers,
454            heartbeat_interval_secs: Some(POLYMARKET_HEARTBEAT_SECS),
455            heartbeat_payload,
456            connect_timeout_ms: Some(self.connect_timeout_ms),
457            reconnect_delay_initial_ms: Some(250),
458            reconnect_delay_max_ms: Some(5_000),
459            reconnect_backoff_factor: Some(2.0),
460            reconnect_jitter_ms: Some(200),
461            reconnect_max_attempts: None,
462            heartbeat_timeout_secs: Some(POLYMARKET_HEARTBEAT_TIMEOUT_SECS),
463            idle_timeout_ms: None,
464            writer_capacity: None,
465            backend: self.transport_backend,
466            proxy_url: self.proxy_url.as_ref().map(|url| url.expose().to_string()),
467            max_message_size_bytes: None,
468            max_frame_size_bytes: None,
469        }
470    }
471
472    pub(crate) fn begin_shutdown(&self) {
473        self.signal.store(true, Ordering::Relaxed);
474    }
475
476    pub(crate) fn shutdown_handle(&self) -> PolymarketWebSocketShutdownHandle {
477        PolymarketWebSocketShutdownHandle {
478            signal: Arc::clone(&self.signal),
479        }
480    }
481
482    pub(crate) fn abort(&mut self) {
483        self.begin_shutdown();
484        self.connection_mode
485            .store(ConnectionMode::Closed.as_u8(), Ordering::SeqCst);
486        self.task_handle.abort();
487        self.auth_tracker.invalidate();
488
489        if let Some(control) = &self.socket_control {
490            control.deregister();
491        }
492    }
493
494    /// Disconnects the WebSocket connection.
495    pub async fn disconnect(&mut self) -> anyhow::Result<()> {
496        log::debug!("Disconnecting Polymarket WebSocket");
497        self.signal.store(true, Ordering::Relaxed);
498
499        if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
500            log::debug!("Failed to send disconnect (handler may already be shut down): {e}");
501        }
502
503        let task_result = match finish_task(
504            &mut self.task_handle,
505            std::time::Duration::from_secs(2),
506            std::time::Duration::from_secs(2),
507        )
508        .await
509        {
510            None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
511            Some(TaskJoinOutcome::Failed(error)) => Err(anyhow::anyhow!(
512                "Polymarket WebSocket handler failed: {error}"
513            )),
514            Some(TaskJoinOutcome::Incomplete) => Err(anyhow::anyhow!(
515                "Polymarket WebSocket handler did not stop after abort"
516            )),
517        };
518        // Invalidate after the task has stopped so any in-flight auth_tracker.succeed()
519        // calls from the handler cannot race with and survive the invalidation.
520        self.auth_tracker.invalidate();
521
522        if let Some(control) = &self.socket_control {
523            control.deregister();
524        }
525        log::debug!("Polymarket WebSocket disconnected");
526        task_result
527    }
528
529    /// Returns `true` if the WebSocket is actively connected.
530    #[must_use]
531    pub fn is_active(&self) -> bool {
532        ConnectionMode::from_atomic(&self.connection_mode).is_active()
533    }
534
535    pub(crate) fn has_task(&self) -> bool {
536        self.task_handle.is_some()
537    }
538
539    /// Returns the URL this client connects to.
540    #[must_use]
541    pub fn url(&self) -> &str {
542        self.url.expose()
543    }
544
545    /// Returns the number of active market asset subscriptions (pending + confirmed).
546    #[must_use]
547    pub fn subscription_count(&self) -> usize {
548        self.subscriptions.all_topics().len()
549    }
550
551    /// Clears retained subscription/auth replay state.
552    ///
553    /// Useful for hard resets where the caller wants reconnect to start from a
554    /// clean slate rather than replaying a previous generation's topics.
555    pub(crate) fn clear_reconnect_state(&self) {
556        self.subscriptions.clear();
557        self.discovery_subscribed.store(false, Ordering::Relaxed);
558        self.user_subscribed.store(false, Ordering::Relaxed);
559        self.auth_tracker.invalidate();
560    }
561
562    /// Returns `true` if the user channel has been authenticated.
563    #[must_use]
564    pub fn is_authenticated(&self) -> bool {
565        self.auth_tracker.is_authenticated()
566    }
567
568    /// Subscribe to market data for the given asset IDs.
569    ///
570    /// Sends a subscribe message immediately if connected; the IDs are also
571    /// retained so they are re-sent automatically on reconnect.
572    ///
573    /// # Errors
574    ///
575    /// Returns an error if called on a user-channel client (incompatible channel).
576    pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
577        if self.channel != WsChannel::Market {
578            anyhow::bail!(
579                "subscribe_market() requires a market-channel client (created with new_market())"
580            );
581        }
582        self.cmd_tx
583            .read()
584            .await
585            .send(HandlerCommand::SubscribeMarket(asset_ids))
586            .map_err(|e| anyhow::anyhow!("Failed to send SubscribeMarket: {e}"))
587    }
588
589    /// Queues removal of the given market asset subscriptions.
590    ///
591    /// The handler removes the IDs from reconnect replay and attempts a wire unsubscribe
592    /// on the current connection. If that connection changes before the write, the
593    /// unsubscribe is dropped rather than sent on the replacement connection.
594    ///
595    /// Success means the command was queued, not acknowledged by the venue. The handler
596    /// logs wire-send failures rather than returning them to the caller.
597    ///
598    /// # Errors
599    ///
600    /// Returns an error if:
601    /// - Called on a user-channel client.
602    /// - The command cannot be queued.
603    pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
604        if self.channel != WsChannel::Market {
605            anyhow::bail!(
606                "unsubscribe_market() requires a market-channel client (created with new_market())"
607            );
608        }
609        self.cmd_tx
610            .read()
611            .await
612            .send(HandlerCommand::UnsubscribeMarket(asset_ids))
613            .map_err(|e| anyhow::anyhow!("Failed to send UnsubscribeMarket: {e}"))
614    }
615
616    /// Authenticate and subscribe to the user channel.
617    ///
618    /// # Errors
619    ///
620    /// Returns an error if called on a market-channel client (no credentials available).
621    pub async fn subscribe_user(&self) -> anyhow::Result<()> {
622        if self.channel != WsChannel::User {
623            anyhow::bail!(
624                "subscribe_user() requires a user-channel client (created with new_user())"
625            );
626        }
627        self.cmd_tx
628            .read()
629            .await
630            .send(HandlerCommand::SubscribeUser)
631            .map_err(|e| anyhow::anyhow!("Failed to send SubscribeUser: {e}"))?;
632        // Set only after the command is successfully enqueued so a failed send does not
633        // leave user_subscribed=true and cause an unintended replay on the next connect().
634        self.user_subscribed.store(true, Ordering::Relaxed);
635        Ok(())
636    }
637
638    /// Returns a cloneable subscription handle for use in spawned tasks.
639    #[must_use]
640    pub fn clone_subscription_handle(&self) -> WsSubscriptionHandle {
641        WsSubscriptionHandle {
642            cmd_tx: Arc::clone(&self.cmd_tx),
643        }
644    }
645
646    /// Takes the message receiver, leaving `None` in its place.
647    ///
648    /// This is useful when the data client needs to spawn its own handler
649    /// task that reads messages independently of the WS client.
650    /// Subscription methods (`subscribe_market`, etc.) remain usable on `&self`.
651    #[must_use]
652    pub fn take_message_receiver(
653        &mut self,
654    ) -> Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>> {
655        self.out_rx.take()
656    }
657
658    /// Receives the next message from the WebSocket handler.
659    ///
660    /// Returns `None` when the handler has disconnected or the receiver
661    /// was not yet initialized (call `connect` first).
662    pub async fn next_message(&mut self) -> Option<PolymarketWsMessage> {
663        if let Some(ref mut rx) = self.out_rx {
664            rx.recv().await
665        } else {
666            None
667        }
668    }
669}
670
671impl Drop for PolymarketWebSocketClient {
672    fn drop(&mut self) {
673        self.signal.store(true, Ordering::Relaxed);
674
675        if let Some(handle) = self.task_handle.as_ref() {
676            handle.abort();
677        }
678
679        if let Some(control) = &self.socket_control {
680            control.deregister();
681        }
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use std::net::SocketAddr;
688
689    use axum::{
690        Router,
691        extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade},
692        response::Response,
693        routing::get,
694    };
695    use nautilus_network::{
696        RECONNECTED,
697        websocket::{TransportBackend, WebSocketConfig, proxy::ProxyUrl},
698    };
699    use rstest::rstest;
700
701    use super::*;
702
703    async fn handle_upgrade(ws: WebSocketUpgrade) -> Response {
704        ws.on_upgrade(handle_socket)
705    }
706
707    async fn handle_socket(mut socket: WebSocket) {
708        let _ = socket
709            .send(AxumWsMessage::Text(RECONNECTED.to_string().into()))
710            .await;
711    }
712
713    async fn start_test_server() -> SocketAddr {
714        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
715            .await
716            .expect("bind test websocket server");
717        let addr = listener.local_addr().expect("test websocket address");
718        let router = Router::new().route("/ws", get(handle_upgrade));
719
720        tokio::spawn(async move {
721            axum::serve(listener, router)
722                .await
723                .expect("test websocket server failed");
724        });
725
726        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
727        addr
728    }
729
730    #[rstest]
731    #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
732    #[case(
733        "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
734        "wss://stream.example.com:9443"
735    )]
736    fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
737        let client = PolymarketWebSocketClient::new_market(
738            Some(url.to_string()),
739            false,
740            TransportBackend::default(),
741        );
742        let debug = format!("{client:?}");
743        assert!(debug.contains(&format!("url: {origin:?}")));
744        assert!(!debug.contains("SYN-"));
745        assert_eq!(client.url.expose(), url);
746    }
747
748    #[tokio::test]
749    async fn cancelled_disconnect_retains_handler_task() {
750        let mut client = PolymarketWebSocketClient::new_market(
751            Some("ws://127.0.0.1:0".to_string()),
752            false,
753            TransportBackend::default(),
754        );
755        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
756        client.cmd_tx = Arc::new(tokio::sync::RwLock::new(cmd_tx));
757        client
758            .task_handle
759            .insert(tokio::spawn(std::future::pending()));
760
761        {
762            let disconnect = client.disconnect();
763            tokio::pin!(disconnect);
764            tokio::select! {
765                result = &mut disconnect => panic!("disconnect completed unexpectedly: {result:?}"),
766                command = cmd_rx.recv() => assert!(command.is_some()),
767            }
768        }
769
770        assert!(client.task_handle.is_some());
771    }
772
773    #[rstest]
774    #[tokio::test]
775    async fn connect_forwards_reconnected_message_to_receiver() {
776        let addr = start_test_server().await;
777        let mut client = PolymarketWebSocketClient::new_market(
778            Some(format!("ws://{addr}/ws")),
779            false,
780            TransportBackend::default(),
781        );
782
783        client.connect().await.expect("connect websocket client");
784
785        let message =
786            tokio::time::timeout(tokio::time::Duration::from_secs(2), client.next_message())
787                .await
788                .expect("wait for websocket message");
789
790        assert!(matches!(
791            message,
792            Some(super::super::messages::PolymarketWsMessage::Reconnected { .. })
793        ));
794
795        client
796            .disconnect()
797            .await
798            .expect("disconnect websocket client");
799    }
800
801    #[rstest]
802    #[case::default(30, 30_000)]
803    #[case::custom(41, 41_000)]
804    #[case::large(u64::MAX, u64::MAX)]
805    fn market_connection_timeout(#[case] timeout_secs: u64, #[case] expected_ms: u64) {
806        let client =
807            PolymarketWebSocketClient::new_market(None, false, TransportBackend::default())
808                .with_connect_timeout(timeout_secs);
809
810        assert_eq!(
811            client.websocket_config().connect_timeout_ms,
812            Some(expected_ms)
813        );
814    }
815
816    #[rstest]
817    fn proxy_url_is_retained_for_market_and_user_clients() {
818        const MARKET_PROXY: &str = "http://market-user:market-proxy-secret@127.0.0.1:18086";
819        const USER_PROXY: &str = "https://user-user:user-proxy-secret@127.0.0.1:18087";
820        let market = PolymarketWebSocketClient::new_market_with_proxy(
821            Some("ws://market.example/ws".to_string()),
822            false,
823            TransportBackend::Tungstenite,
824            Some(ProxyUrl::parse(MARKET_PROXY).unwrap()),
825        );
826        let credential = crate::common::credential::Credential::new(
827            "fixture-key".into(),
828            "Zml4dHVyZQ==".into(),
829            "fixture-passphrase".into(),
830        )
831        .unwrap();
832        let user = PolymarketWebSocketClient::new_user_with_proxy(
833            Some("ws://user.example/ws".to_string()),
834            credential,
835            TransportBackend::Tungstenite,
836            Some(ProxyUrl::parse(USER_PROXY).unwrap()),
837        );
838        let market_config = market.websocket_config();
839        let user_config = user.websocket_config();
840        let market_debug = format!("{market:?}");
841        let user_debug = format!("{user:?}");
842        let assert_common = |config: &WebSocketConfig| {
843            assert_eq!(config.heartbeat_interval_secs, Some(10));
844            assert_eq!(config.connect_timeout_ms, Some(15_000));
845            assert_eq!(config.reconnect_delay_initial_ms, Some(250));
846            assert_eq!(config.reconnect_delay_max_ms, Some(5_000));
847            assert_eq!(config.reconnect_backoff_factor, Some(2.0));
848            assert_eq!(config.reconnect_jitter_ms, Some(200));
849            assert_eq!(config.reconnect_max_attempts, None);
850            // No data-silence timer: `PONG` arrives as a text frame and would
851            // refresh it, so liveness rests on the heartbeat timeout instead.
852            assert_eq!(config.idle_timeout_ms, None);
853            assert_eq!(config.backend, TransportBackend::Tungstenite);
854        };
855
856        assert_eq!(market.proxy_url.as_ref().unwrap().expose(), MARKET_PROXY);
857        assert_eq!(user.proxy_url.as_ref().unwrap().expose(), USER_PROXY);
858        assert_eq!(market_config.url, "ws://market.example/ws");
859        assert_eq!(user_config.url, "ws://user.example/ws");
860        assert_eq!(market_config.proxy_url.as_deref(), Some(MARKET_PROXY));
861        assert_eq!(user_config.proxy_url.as_deref(), Some(USER_PROXY));
862        assert_eq!(market_config.heartbeat_payload, None);
863        assert_eq!(user_config.heartbeat_payload.as_deref(), Some("PING"));
864        assert_common(&market_config);
865        assert_common(&user_config);
866        assert!(!market_debug.contains("market-proxy-secret"));
867        assert!(!user_debug.contains("user-proxy-secret"));
868    }
869}