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