1use 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
47pub(super) const POLYMARKET_HEARTBEAT_SECS: u64 = 10;
51pub(super) const POLYMARKET_HEARTBEAT_PAYLOAD: &str = "PING";
52
53const POLYMARKET_HEARTBEAT_TIMEOUT_SECS: u64 = POLYMARKET_HEARTBEAT_SECS * 3;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum WsChannel {
61 Market,
62 User,
63}
64
65#[derive(Clone, Debug)]
69pub struct WsSubscriptionHandle {
70 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
71}
72
73impl WsSubscriptionHandle {
74 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 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 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 #[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#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 #[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 #[must_use]
513 pub fn url(&self) -> &str {
514 &self.url
515 }
516
517 #[must_use]
519 pub fn subscription_count(&self) -> usize {
520 self.subscriptions.all_topics().len()
521 }
522
523 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 #[must_use]
536 pub fn is_authenticated(&self) -> bool {
537 self.auth_tracker.is_authenticated()
538 }
539
540 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 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 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 self.user_subscribed.store(true, Ordering::Relaxed);
601 Ok(())
602 }
603
604 #[must_use]
606 pub fn clone_subscription_handle(&self) -> WsSubscriptionHandle {
607 WsSubscriptionHandle {
608 cmd_tx: Arc::clone(&self.cmd_tx),
609 }
610 }
611
612 #[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 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 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}