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    ConnectionUrl, 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: ConnectionUrl,
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: url.into(),
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.origin());
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.expose().to_owned(),
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            writer_capacity: None,
440            backend: self.transport_backend,
441            proxy_url: self.proxy_url.as_ref().map(|url| url.expose().to_string()),
442            max_message_size_bytes: None,
443            max_frame_size_bytes: None,
444        }
445    }
446
447    pub(crate) fn begin_shutdown(&self) {
448        self.signal.store(true, Ordering::Relaxed);
449    }
450
451    pub(crate) fn shutdown_handle(&self) -> PolymarketWebSocketShutdownHandle {
452        PolymarketWebSocketShutdownHandle {
453            signal: Arc::clone(&self.signal),
454        }
455    }
456
457    pub(crate) fn abort(&mut self) {
458        self.begin_shutdown();
459        self.connection_mode
460            .store(ConnectionMode::Closed.as_u8(), Ordering::SeqCst);
461        self.task_handle.abort();
462        self.auth_tracker.invalidate();
463
464        if let Some(control) = &self.socket_control {
465            control.deregister();
466        }
467    }
468
469    /// Disconnects the WebSocket connection.
470    pub async fn disconnect(&mut self) -> anyhow::Result<()> {
471        log::debug!("Disconnecting Polymarket WebSocket");
472        self.signal.store(true, Ordering::Relaxed);
473
474        if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
475            log::debug!("Failed to send disconnect (handler may already be shut down): {e}");
476        }
477
478        let task_result = match finish_task(
479            &mut self.task_handle,
480            std::time::Duration::from_secs(2),
481            std::time::Duration::from_secs(2),
482        )
483        .await
484        {
485            None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
486            Some(TaskJoinOutcome::Failed(error)) => Err(anyhow::anyhow!(
487                "Polymarket WebSocket handler failed: {error}"
488            )),
489            Some(TaskJoinOutcome::Incomplete) => Err(anyhow::anyhow!(
490                "Polymarket WebSocket handler did not stop after abort"
491            )),
492        };
493        // Invalidate after the task has stopped so any in-flight auth_tracker.succeed()
494        // calls from the handler cannot race with and survive the invalidation.
495        self.auth_tracker.invalidate();
496
497        if let Some(control) = &self.socket_control {
498            control.deregister();
499        }
500        log::debug!("Polymarket WebSocket disconnected");
501        task_result
502    }
503
504    /// Returns `true` if the WebSocket is actively connected.
505    #[must_use]
506    pub fn is_active(&self) -> bool {
507        ConnectionMode::from_atomic(&self.connection_mode).is_active()
508    }
509
510    pub(crate) fn has_task(&self) -> bool {
511        self.task_handle.is_some()
512    }
513
514    /// Returns the URL this client connects to.
515    #[must_use]
516    pub fn url(&self) -> &str {
517        self.url.expose()
518    }
519
520    /// Returns the number of active market asset subscriptions (pending + confirmed).
521    #[must_use]
522    pub fn subscription_count(&self) -> usize {
523        self.subscriptions.all_topics().len()
524    }
525
526    /// Clears retained subscription/auth replay state.
527    ///
528    /// Useful for hard resets where the caller wants reconnect to start from a
529    /// clean slate rather than replaying a previous generation's topics.
530    pub(crate) fn clear_reconnect_state(&self) {
531        self.subscriptions.clear();
532        self.discovery_subscribed.store(false, Ordering::Relaxed);
533        self.user_subscribed.store(false, Ordering::Relaxed);
534        self.auth_tracker.invalidate();
535    }
536
537    /// Returns `true` if the user channel has been authenticated.
538    #[must_use]
539    pub fn is_authenticated(&self) -> bool {
540        self.auth_tracker.is_authenticated()
541    }
542
543    /// Subscribe to market data for the given asset IDs.
544    ///
545    /// Sends a subscribe message immediately if connected; the IDs are also
546    /// retained so they are re-sent automatically on reconnect.
547    ///
548    /// # Errors
549    ///
550    /// Returns an error if called on a user-channel client (incompatible channel).
551    pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
552        if self.channel != WsChannel::Market {
553            anyhow::bail!(
554                "subscribe_market() requires a market-channel client (created with new_market())"
555            );
556        }
557        self.cmd_tx
558            .read()
559            .await
560            .send(HandlerCommand::SubscribeMarket(asset_ids))
561            .map_err(|e| anyhow::anyhow!("Failed to send SubscribeMarket: {e}"))
562    }
563
564    /// Queues removal of the given market asset subscriptions.
565    ///
566    /// The handler removes the IDs from reconnect replay and attempts a wire unsubscribe
567    /// on the current connection. If that connection changes before the write, the
568    /// unsubscribe is dropped rather than sent on the replacement connection.
569    ///
570    /// Success means the command was queued, not acknowledged by the venue. The handler
571    /// logs wire-send failures rather than returning them to the caller.
572    ///
573    /// # Errors
574    ///
575    /// Returns an error if:
576    /// - Called on a user-channel client.
577    /// - The command cannot be queued.
578    pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
579        if self.channel != WsChannel::Market {
580            anyhow::bail!(
581                "unsubscribe_market() requires a market-channel client (created with new_market())"
582            );
583        }
584        self.cmd_tx
585            .read()
586            .await
587            .send(HandlerCommand::UnsubscribeMarket(asset_ids))
588            .map_err(|e| anyhow::anyhow!("Failed to send UnsubscribeMarket: {e}"))
589    }
590
591    /// Authenticate and subscribe to the user channel.
592    ///
593    /// # Errors
594    ///
595    /// Returns an error if called on a market-channel client (no credentials available).
596    pub async fn subscribe_user(&self) -> anyhow::Result<()> {
597        if self.channel != WsChannel::User {
598            anyhow::bail!(
599                "subscribe_user() requires a user-channel client (created with new_user())"
600            );
601        }
602        self.cmd_tx
603            .read()
604            .await
605            .send(HandlerCommand::SubscribeUser)
606            .map_err(|e| anyhow::anyhow!("Failed to send SubscribeUser: {e}"))?;
607        // Set only after the command is successfully enqueued so a failed send does not
608        // leave user_subscribed=true and cause an unintended replay on the next connect().
609        self.user_subscribed.store(true, Ordering::Relaxed);
610        Ok(())
611    }
612
613    /// Returns a cloneable subscription handle for use in spawned tasks.
614    #[must_use]
615    pub fn clone_subscription_handle(&self) -> WsSubscriptionHandle {
616        WsSubscriptionHandle {
617            cmd_tx: Arc::clone(&self.cmd_tx),
618        }
619    }
620
621    /// Takes the message receiver, leaving `None` in its place.
622    ///
623    /// This is useful when the data client needs to spawn its own handler
624    /// task that reads messages independently of the WS client.
625    /// Subscription methods (`subscribe_market`, etc.) remain usable on `&self`.
626    #[must_use]
627    pub fn take_message_receiver(
628        &mut self,
629    ) -> Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>> {
630        self.out_rx.take()
631    }
632
633    /// Receives the next message from the WebSocket handler.
634    ///
635    /// Returns `None` when the handler has disconnected or the receiver
636    /// was not yet initialized (call `connect` first).
637    pub async fn next_message(&mut self) -> Option<PolymarketWsMessage> {
638        if let Some(ref mut rx) = self.out_rx {
639            rx.recv().await
640        } else {
641            None
642        }
643    }
644}
645
646impl Drop for PolymarketWebSocketClient {
647    fn drop(&mut self) {
648        self.signal.store(true, Ordering::Relaxed);
649
650        if let Some(handle) = self.task_handle.as_ref() {
651            handle.abort();
652        }
653
654        if let Some(control) = &self.socket_control {
655            control.deregister();
656        }
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use std::net::SocketAddr;
663
664    use axum::{
665        Router,
666        extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade},
667        response::Response,
668        routing::get,
669    };
670    use nautilus_network::{
671        RECONNECTED,
672        websocket::{TransportBackend, WebSocketConfig, proxy::ProxyUrl},
673    };
674    use rstest::rstest;
675
676    use super::*;
677
678    async fn handle_upgrade(ws: WebSocketUpgrade) -> Response {
679        ws.on_upgrade(handle_socket)
680    }
681
682    async fn handle_socket(mut socket: WebSocket) {
683        let _ = socket
684            .send(AxumWsMessage::Text(RECONNECTED.to_string().into()))
685            .await;
686    }
687
688    async fn start_test_server() -> SocketAddr {
689        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
690            .await
691            .expect("bind test websocket server");
692        let addr = listener.local_addr().expect("test websocket address");
693        let router = Router::new().route("/ws", get(handle_upgrade));
694
695        tokio::spawn(async move {
696            axum::serve(listener, router)
697                .await
698                .expect("test websocket server failed");
699        });
700
701        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
702        addr
703    }
704
705    #[rstest]
706    #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
707    #[case(
708        "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
709        "wss://stream.example.com:9443"
710    )]
711    fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
712        let client = PolymarketWebSocketClient::new_market(
713            Some(url.to_string()),
714            false,
715            TransportBackend::default(),
716        );
717        let debug = format!("{client:?}");
718        assert!(debug.contains(&format!("url: {origin:?}")));
719        assert!(!debug.contains("SYN-"));
720        assert_eq!(client.url.expose(), url);
721    }
722
723    #[tokio::test]
724    async fn cancelled_disconnect_retains_handler_task() {
725        let mut client = PolymarketWebSocketClient::new_market(
726            Some("ws://127.0.0.1:0".to_string()),
727            false,
728            TransportBackend::default(),
729        );
730        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
731        client.cmd_tx = Arc::new(tokio::sync::RwLock::new(cmd_tx));
732        client
733            .task_handle
734            .insert(tokio::spawn(std::future::pending()));
735
736        {
737            let disconnect = client.disconnect();
738            tokio::pin!(disconnect);
739            tokio::select! {
740                result = &mut disconnect => panic!("disconnect completed unexpectedly: {result:?}"),
741                command = cmd_rx.recv() => assert!(command.is_some()),
742            }
743        }
744
745        assert!(client.task_handle.is_some());
746    }
747
748    #[rstest]
749    #[tokio::test]
750    async fn connect_forwards_reconnected_message_to_receiver() {
751        let addr = start_test_server().await;
752        let mut client = PolymarketWebSocketClient::new_market(
753            Some(format!("ws://{addr}/ws")),
754            false,
755            TransportBackend::default(),
756        );
757
758        client.connect().await.expect("connect websocket client");
759
760        let message =
761            tokio::time::timeout(tokio::time::Duration::from_secs(2), client.next_message())
762                .await
763                .expect("wait for websocket message");
764
765        assert!(matches!(
766            message,
767            Some(super::super::messages::PolymarketWsMessage::Reconnected { .. })
768        ));
769
770        client
771            .disconnect()
772            .await
773            .expect("disconnect websocket client");
774    }
775
776    #[rstest]
777    fn proxy_url_is_retained_for_market_and_user_clients() {
778        const MARKET_PROXY: &str = "http://market-user:market-proxy-secret@127.0.0.1:18086";
779        const USER_PROXY: &str = "https://user-user:user-proxy-secret@127.0.0.1:18087";
780        let market = PolymarketWebSocketClient::new_market_with_proxy(
781            Some("ws://market.example/ws".to_string()),
782            false,
783            TransportBackend::Tungstenite,
784            Some(ProxyUrl::parse(MARKET_PROXY).unwrap()),
785        );
786        let credential = crate::common::credential::Credential::new(
787            "fixture-key".into(),
788            "Zml4dHVyZQ==".into(),
789            "fixture-passphrase".into(),
790        )
791        .unwrap();
792        let user = PolymarketWebSocketClient::new_user_with_proxy(
793            Some("ws://user.example/ws".to_string()),
794            credential,
795            TransportBackend::Tungstenite,
796            Some(ProxyUrl::parse(USER_PROXY).unwrap()),
797        );
798        let market_config = market.websocket_config();
799        let user_config = user.websocket_config();
800        let market_debug = format!("{market:?}");
801        let user_debug = format!("{user:?}");
802        let assert_common = |config: &WebSocketConfig| {
803            assert_eq!(config.heartbeat_interval_secs, Some(10));
804            assert_eq!(config.connect_timeout_ms, Some(15_000));
805            assert_eq!(config.reconnect_delay_initial_ms, Some(250));
806            assert_eq!(config.reconnect_delay_max_ms, Some(5_000));
807            assert_eq!(config.reconnect_backoff_factor, Some(2.0));
808            assert_eq!(config.reconnect_jitter_ms, Some(200));
809            assert_eq!(config.reconnect_max_attempts, None);
810            // No data-silence timer: `PONG` arrives as a text frame and would
811            // refresh it, so liveness rests on the heartbeat timeout instead.
812            assert_eq!(config.idle_timeout_ms, None);
813            assert_eq!(config.backend, TransportBackend::Tungstenite);
814        };
815
816        assert_eq!(market.proxy_url.as_ref().unwrap().expose(), MARKET_PROXY);
817        assert_eq!(user.proxy_url.as_ref().unwrap().expose(), USER_PROXY);
818        assert_eq!(market_config.url, "ws://market.example/ws");
819        assert_eq!(user_config.url, "ws://user.example/ws");
820        assert_eq!(market_config.proxy_url.as_deref(), Some(MARKET_PROXY));
821        assert_eq!(user_config.proxy_url.as_deref(), Some(USER_PROXY));
822        assert_eq!(market_config.heartbeat_payload, None);
823        assert_eq!(user_config.heartbeat_payload.as_deref(), Some("PING"));
824        assert_common(&market_config);
825        assert_common(&user_config);
826        assert!(!market_debug.contains("market-proxy-secret"));
827        assert!(!user_debug.contains("user-proxy-secret"));
828    }
829}