1use 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
52pub(super) const POLYMARKET_HEARTBEAT_SECS: u64 = 10;
56pub(super) const POLYMARKET_HEARTBEAT_PAYLOAD: &str = "PING";
57
58const POLYMARKET_HEARTBEAT_TIMEOUT_SECS: u64 = POLYMARKET_HEARTBEAT_SECS * 3;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum WsChannel {
66 Market,
67 User,
68}
69
70#[derive(Clone, Debug)]
74pub struct WsSubscriptionHandle {
75 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
76}
77
78impl WsSubscriptionHandle {
79 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 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 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 #[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#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 #[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 #[must_use]
541 pub fn url(&self) -> &str {
542 self.url.expose()
543 }
544
545 #[must_use]
547 pub fn subscription_count(&self) -> usize {
548 self.subscriptions.all_topics().len()
549 }
550
551 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 #[must_use]
564 pub fn is_authenticated(&self) -> bool {
565 self.auth_tracker.is_authenticated()
566 }
567
568 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 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 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 self.user_subscribed.store(true, Ordering::Relaxed);
635 Ok(())
636 }
637
638 #[must_use]
640 pub fn clone_subscription_handle(&self) -> WsSubscriptionHandle {
641 WsSubscriptionHandle {
642 cmd_tx: Arc::clone(&self.cmd_tx),
643 }
644 }
645
646 #[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 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 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}