1pub static DYDX_RATE_LIMIT_KEY_SUBSCRIPTION: LazyLock<[Ustr; 1]> =
48 LazyLock::new(|| [Ustr::from("subscription")]);
49
50pub const DYDX_WS_TOPIC_DELIMITER: char = ':';
52
53pub static DYDX_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
55 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant")
56});
57
58pub const DEFAULT_MAX_WS_CONNECTIONS: usize = 8;
60
61pub const DEFAULT_PER_CHANNEL_SUBSCRIPTION_LIMIT: usize = 32;
63
64use std::{
65 fmt::Debug,
66 num::NonZeroU32,
67 sync::{
68 Arc, LazyLock,
69 atomic::{AtomicBool, AtomicU8, Ordering},
70 },
71 time::Duration,
72};
73
74use ahash::{AHashMap, AHashSet};
75use arc_swap::ArcSwap;
76use dashmap::DashMap;
77use nautilus_core::string::secret::SecretString;
78use nautilus_live::{
79 SocketControl, SocketControlFactory,
80 task::{TaskJoinOutcome, TaskSlot, finish_task},
81};
82use nautilus_model::{
83 data::BarType,
84 identifiers::{AccountId, InstrumentId},
85 instruments::InstrumentAny,
86};
87use nautilus_network::{
88 http::create_standard_nautilus_headers,
89 mode::ConnectionMode,
90 ratelimiter::quota::Quota,
91 websocket::{
92 AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
93 channel_message_handler,
94 },
95};
96use parking_lot::Mutex;
97use ustr::Ustr;
98
99use super::{
100 dispatch::DydxWsDispatchState,
101 enums::{DydxWsChannel, DydxWsOperation, DydxWsOutputMessage},
102 error::{DydxWsError, DydxWsResult},
103 handler::{FeedHandler, HandlerCommand},
104 messages::DydxSubscription,
105};
106use crate::{
107 common::{credential::DydxCredential, instrument_cache::InstrumentCache},
108 execution::encoder::ClientOrderIdEncoder,
109};
110
111#[derive(Copy, Clone, Debug)]
113#[repr(u8)]
114enum ChannelKind {
115 Trades = 0,
116 Candles = 1,
117 Orderbook = 2,
118 Markets = 3,
119}
120
121const CHANNEL_KIND_COUNT: usize = 4;
122
123#[derive(Debug)]
125struct ConnectionSlot {
126 cmd_tx: tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
127 topics: AHashMap<String, u32>,
128 channel_counts: [u16; CHANNEL_KIND_COUNT],
129 subscriptions_state: SubscriptionState,
130 handler_task: TaskSlot<()>,
131 connection_mode: Arc<AtomicU8>,
132 socket_control: Option<SocketControl>,
133}
134
135#[derive(Debug)]
156pub struct DydxWebSocketClient {
157 url: String,
158 credential: Option<Arc<DydxCredential>>,
159 requires_auth: bool,
160 auth_tracker: AuthTracker,
161 slots: Arc<ConnectionSlots>,
162 admission: Arc<Mutex<PoolAdmission>>,
163 connect_lock: Arc<tokio::sync::Mutex<()>>,
164 connection_mode: Arc<ArcSwap<AtomicU8>>,
165 signal: Arc<AtomicBool>,
166 instrument_cache: Arc<InstrumentCache>,
167 account_id: Option<AccountId>,
168 heartbeat: Option<u64>,
169 out_tx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedSender<DydxWsOutputMessage>>>>,
170 out_rx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<DydxWsOutputMessage>>>>,
171 encoder: Arc<ClientOrderIdEncoder>,
172 bar_types: Arc<DashMap<String, BarType>>,
173 bars_timestamp_on_close: Arc<AtomicBool>,
174 ws_dispatch_state: Arc<DydxWsDispatchState>,
175 transport_backend: TransportBackend,
176 proxy_url: Option<SecretString>,
177 max_ws_connections: usize,
178 per_channel_limit: usize,
179 socket_factory: Option<SocketControlFactory>,
180}
181
182impl Clone for DydxWebSocketClient {
183 fn clone(&self) -> Self {
184 Self {
185 url: self.url.clone(),
186 credential: self.credential.clone(),
187 requires_auth: self.requires_auth,
188 auth_tracker: self.auth_tracker.clone(),
189 slots: self.slots.clone(),
190 admission: self.admission.clone(),
191 connect_lock: self.connect_lock.clone(),
192 connection_mode: self.connection_mode.clone(),
193 signal: self.signal.clone(),
194 instrument_cache: self.instrument_cache.clone(),
195 account_id: self.account_id,
196 heartbeat: self.heartbeat,
197 out_tx: self.out_tx.clone(),
198 out_rx: self.out_rx.clone(),
199 encoder: self.encoder.clone(),
200 bar_types: self.bar_types.clone(),
201 bars_timestamp_on_close: self.bars_timestamp_on_close.clone(),
202 ws_dispatch_state: self.ws_dispatch_state.clone(),
203 transport_backend: self.transport_backend,
204 proxy_url: self.proxy_url.clone(),
205 max_ws_connections: self.max_ws_connections,
206 per_channel_limit: self.per_channel_limit,
207 socket_factory: self.socket_factory.clone(),
208 }
209 }
210}
211
212impl DydxWebSocketClient {
213 #[must_use]
218 pub fn new_public(url: String, heartbeat: Option<u64>, proxy_url: Option<String>) -> Self {
219 Self::new_public_with_cache(
220 url,
221 Arc::new(InstrumentCache::new()),
222 heartbeat,
223 TransportBackend::default(),
224 proxy_url,
225 )
226 }
227
228 #[must_use]
232 pub fn new_public_with_cache(
233 url: String,
234 instrument_cache: Arc<InstrumentCache>,
235 heartbeat: Option<u64>,
236 transport_backend: TransportBackend,
237 proxy_url: Option<String>,
238 ) -> Self {
239 Self::new_public_with_cache_and_pool(
240 url,
241 instrument_cache,
242 heartbeat,
243 transport_backend,
244 proxy_url,
245 DEFAULT_MAX_WS_CONNECTIONS,
246 DEFAULT_PER_CHANNEL_SUBSCRIPTION_LIMIT,
247 )
248 }
249
250 #[must_use]
252 pub fn new_public_with_cache_and_pool(
253 url: String,
254 instrument_cache: Arc<InstrumentCache>,
255 heartbeat: Option<u64>,
256 transport_backend: TransportBackend,
257 proxy_url: Option<String>,
258 max_ws_connections: usize,
259 per_channel_limit: usize,
260 ) -> Self {
261 Self::new_inner(
262 url,
263 None,
264 false,
265 instrument_cache,
266 None,
267 heartbeat,
268 transport_backend,
269 proxy_url,
270 max_ws_connections,
271 per_channel_limit,
272 )
273 }
274
275 #[must_use]
280 pub fn new_private(
281 url: String,
282 credential: DydxCredential,
283 account_id: AccountId,
284 heartbeat: Option<u64>,
285 proxy_url: Option<String>,
286 ) -> Self {
287 Self::new_private_with_cache(
288 url,
289 credential,
290 account_id,
291 Arc::new(InstrumentCache::new()),
292 heartbeat,
293 TransportBackend::default(),
294 proxy_url,
295 )
296 }
297
298 #[must_use]
302 pub fn new_private_with_cache(
303 url: String,
304 credential: DydxCredential,
305 account_id: AccountId,
306 instrument_cache: Arc<InstrumentCache>,
307 heartbeat: Option<u64>,
308 transport_backend: TransportBackend,
309 proxy_url: Option<String>,
310 ) -> Self {
311 Self::new_inner(
312 url,
313 Some(Arc::new(credential)),
314 true,
315 instrument_cache,
316 Some(account_id),
317 heartbeat,
318 transport_backend,
319 proxy_url,
320 DEFAULT_MAX_WS_CONNECTIONS,
321 DEFAULT_PER_CHANNEL_SUBSCRIPTION_LIMIT,
322 )
323 }
324
325 #[allow(clippy::too_many_arguments)]
326 fn new_inner(
327 url: String,
328 credential: Option<Arc<DydxCredential>>,
329 requires_auth: bool,
330 instrument_cache: Arc<InstrumentCache>,
331 account_id: Option<AccountId>,
332 heartbeat: Option<u64>,
333 transport_backend: TransportBackend,
334 proxy_url: Option<String>,
335 max_ws_connections: usize,
336 per_channel_limit: usize,
337 ) -> Self {
338 Self {
339 url,
340 credential,
341 requires_auth,
342 auth_tracker: AuthTracker::new(),
343 slots: Arc::new(ConnectionSlots::new()),
344 admission: Arc::new(Mutex::new(PoolAdmission {
345 generation: 0,
346 closed: false,
347 })),
348 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
349 connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
350 ConnectionMode::Closed as u8,
351 ))),
352 signal: Arc::new(AtomicBool::new(false)),
353 instrument_cache,
354 account_id,
355 heartbeat,
356 out_tx: Arc::new(Mutex::new(None)),
357 out_rx: Arc::new(Mutex::new(None)),
358 encoder: Arc::new(ClientOrderIdEncoder::new()),
359 bar_types: Arc::new(DashMap::new()),
360 bars_timestamp_on_close: Arc::new(AtomicBool::new(true)),
361 ws_dispatch_state: Arc::new(DydxWsDispatchState::default()),
362 transport_backend,
363 proxy_url: proxy_url.map(SecretString::from),
364 max_ws_connections: max_ws_connections.max(1),
365 per_channel_limit: per_channel_limit.max(1),
366 socket_factory: None,
367 }
368 }
369
370 pub(crate) fn begin_shutdown(&self) {
371 let mut admission = self.admission.lock();
372 admission.generation = admission.generation.wrapping_add(1);
373 admission.closed = true;
374 self.signal.store(true, Ordering::Release);
375
376 for slot in self.slots.lock().iter() {
377 let _ = slot.cmd_tx.send(HandlerCommand::Disconnect);
378 }
379 }
380
381 fn open_generation(&self) -> u64 {
382 let mut admission = self.admission.lock();
383 admission.generation = admission.generation.wrapping_add(1);
384 admission.closed = false;
385 admission.generation
386 }
387
388 fn admission_generation(&self) -> DydxWsResult<u64> {
389 let admission = self.admission.lock();
390 if admission.closed {
391 Err(DydxWsError::Transport(
392 "WebSocket connection pool is closed".to_string(),
393 ))
394 } else {
395 Ok(admission.generation)
396 }
397 }
398
399 #[must_use]
401 pub fn with_socket_factory(mut self, factory: SocketControlFactory) -> Self {
402 self.socket_factory = Some(factory);
403 self
404 }
405
406 #[must_use]
408 pub fn credential(&self) -> Option<&Arc<DydxCredential>> {
409 self.credential.as_ref()
410 }
411
412 #[must_use]
414 pub fn is_connected(&self) -> bool {
415 let slots = self.slots.lock();
416 slots.iter().any(|s| {
417 let mode = s.connection_mode.load(Ordering::Relaxed);
418 mode == ConnectionMode::Active as u8 || mode == ConnectionMode::Reconnect as u8
419 })
420 }
421
422 #[must_use]
424 pub fn url(&self) -> &str {
425 &self.url
426 }
427
428 #[must_use]
433 pub fn connection_mode_atomic(&self) -> Arc<ArcSwap<AtomicU8>> {
434 self.connection_mode.clone()
435 }
436
437 #[must_use]
439 pub fn pool_size(&self) -> usize {
440 self.slots.lock().len()
441 }
442
443 #[must_use]
445 pub const fn max_ws_connections(&self) -> usize {
446 self.max_ws_connections
447 }
448
449 #[must_use]
451 pub const fn per_channel_limit(&self) -> usize {
452 self.per_channel_limit
453 }
454
455 pub fn set_account_id(&mut self, account_id: AccountId) {
457 self.account_id = Some(account_id);
458 }
459
460 #[must_use]
462 pub fn account_id(&self) -> Option<AccountId> {
463 self.account_id
464 }
465
466 pub fn set_instrument_cache(&mut self, cache: Arc<InstrumentCache>) {
472 self.instrument_cache = cache;
473 }
474
475 pub fn cache_instrument(&self, instrument: InstrumentAny) {
479 self.instrument_cache.insert_instrument_only(instrument);
480 }
481
482 pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
486 log::debug!(
487 "Caching {} instruments in WebSocket client",
488 instruments.len()
489 );
490 self.instrument_cache.insert_instruments_only(instruments);
491 }
492
493 #[must_use]
495 pub fn instrument_cache(&self) -> &Arc<InstrumentCache> {
496 &self.instrument_cache
497 }
498
499 #[must_use]
501 pub fn encoder(&self) -> &Arc<ClientOrderIdEncoder> {
502 &self.encoder
503 }
504
505 #[must_use]
507 pub fn bar_types(&self) -> &Arc<DashMap<String, BarType>> {
508 &self.bar_types
509 }
510
511 pub fn ws_dispatch_state(&self) -> &Arc<DydxWsDispatchState> {
513 &self.ws_dispatch_state
514 }
515
516 pub fn set_bars_timestamp_on_close(&self, value: bool) {
518 self.bars_timestamp_on_close.store(value, Ordering::Relaxed);
519 }
520
521 #[must_use]
523 pub fn bars_timestamp_on_close(&self) -> bool {
524 self.bars_timestamp_on_close.load(Ordering::Relaxed)
525 }
526
527 #[must_use]
531 pub fn all_instruments(&self) -> Vec<InstrumentAny> {
532 self.instrument_cache.all_instruments()
533 }
534
535 #[must_use]
537 pub fn cached_instruments_count(&self) -> usize {
538 self.instrument_cache.len()
539 }
540
541 #[must_use]
545 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
546 self.instrument_cache.get(instrument_id)
547 }
548
549 #[must_use]
553 pub fn get_instrument_by_market(&self, ticker: &str) -> Option<InstrumentAny> {
554 self.instrument_cache.get_by_market(ticker)
555 }
556
557 pub fn take_receiver(
560 &mut self,
561 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<DydxWsOutputMessage>> {
562 self.out_rx.lock().take()
563 }
564
565 pub fn stream(
573 &mut self,
574 ) -> impl futures_util::Stream<Item = DydxWsOutputMessage> + Send + 'static {
575 let mut rx = self
576 .out_rx
577 .lock()
578 .take()
579 .expect("Message stream receiver already taken or not connected");
580
581 async_stream::stream! {
582 while let Some(msg) = rx.recv().await {
583 yield msg;
584 }
585 }
586 }
587
588 pub async fn connect(&mut self) -> DydxWsResult<()> {
597 let connect_lock = Arc::clone(&self.connect_lock);
598 let _connect_guard = connect_lock.lock().await;
599
600 let already_connected = {
601 let admission = self.admission.lock();
602 !admission.closed && self.is_connected()
603 };
604
605 if already_connected {
606 return Ok(());
607 }
608
609 if !self.slots.lock().is_empty() {
610 self.disconnect_connections().await?;
611 }
612
613 let generation = self.open_generation();
614 self.signal.store(false, Ordering::Release);
615
616 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<DydxWsOutputMessage>();
617 {
618 let mut guard = self.out_tx.lock();
619 *guard = Some(out_tx);
620 }
621 {
622 let mut guard = self.out_rx.lock();
623 *guard = Some(out_rx);
624 }
625
626 let slot = match self.create_connection(0).await {
627 Ok(slot) => slot,
628 Err(e) => {
629 self.begin_shutdown();
630 *self.out_tx.lock() = None;
631 *self.out_rx.lock() = None;
632 return Err(e);
633 }
634 };
635 let admission = self.admission.lock();
636 let mut slots = self.slots.lock();
637
638 if admission.closed || admission.generation != generation {
639 let _ = slot.cmd_tx.send(HandlerCommand::Disconnect);
640 slots.push(slot);
641 return Err(DydxWsError::Transport(
642 "WebSocket connection was canceled by shutdown".to_string(),
643 ));
644 }
645 self.connection_mode.store(slot.connection_mode.clone());
646 slots.push(slot);
647 drop(slots);
648 drop(admission);
649
650 log::debug!("Connected dYdX WebSocket pool: {}", self.url);
651 Ok(())
652 }
653
654 pub async fn disconnect(&mut self) -> DydxWsResult<()> {
660 self.begin_shutdown();
661 let connect_lock = Arc::clone(&self.connect_lock);
662 let _connect_guard = connect_lock.lock().await;
663 self.disconnect_connections().await
664 }
665
666 async fn disconnect_connections(&self) -> DydxWsResult<()> {
667 self.begin_shutdown();
668
669 let mut slots = ConnectionSlotBatch::take(&self.slots);
670
671 for slot in &mut slots.slots {
672 if let Some(control) = &slot.socket_control {
673 control.deregister();
674 }
675 let _ = slot.cmd_tx.send(HandlerCommand::Disconnect);
676
677 if let Some(outcome) = finish_task(
678 &mut slot.handler_task,
679 Duration::from_secs(2),
680 Duration::from_secs(2),
681 )
682 .await
683 {
684 match outcome {
685 TaskJoinOutcome::Completed(()) => log::debug!("Handler task completed"),
686 TaskJoinOutcome::Aborted => {}
687 TaskJoinOutcome::Failed(error) => {
688 self.slots
689 .push_shutdown_error(format!("handler task failed: {error}"));
690 }
691 TaskJoinOutcome::Incomplete => {
692 self.slots.push_shutdown_error(
693 "handler task did not stop after abort".to_string(),
694 );
695 }
696 }
697 }
698 }
699
700 slots.slots.retain(|slot| slot.handler_task.is_some());
701 let has_incomplete_tasks = !slots.slots.is_empty();
702
703 if !has_incomplete_tasks {
704 self.connection_mode
705 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
706 *self.out_tx.lock() = None;
707 *self.out_rx.lock() = None;
708 }
709
710 let join_errors = self.slots.take_shutdown_errors();
711 if !join_errors.is_empty() {
712 return Err(DydxWsError::Transport(join_errors.join("; ")));
713 }
714
715 self.connection_mode
716 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
717
718 log::debug!("Disconnected dYdX WebSocket pool");
719 Ok(())
720 }
721
722 pub fn send_command(&self, cmd: HandlerCommand) -> DydxWsResult<()> {
728 let admission = self.admission.lock();
729 if admission.closed {
730 return Err(DydxWsError::Transport(
731 "WebSocket connection pool is closed".to_string(),
732 ));
733 }
734 let slots = self.slots.lock();
735 let slot = slots
736 .first()
737 .ok_or_else(|| DydxWsError::Transport("No pool slots available".to_string()))?;
738 slot.cmd_tx.send(cmd).map_err(|e| {
739 DydxWsError::Transport(format!("Failed to send command to slot 0: {e}"))
740 })?;
741 Ok(())
742 }
743
744 async fn create_connection(&self, slot_index: usize) -> DydxWsResult<ConnectionSlot> {
745 let (message_handler, raw_rx) = channel_message_handler();
746 let headers = create_standard_nautilus_headers();
747
748 let cfg = WebSocketConfig {
749 url: self.url.clone(),
750 headers,
751 heartbeat_interval_secs: self.heartbeat,
752 heartbeat_payload: None,
753 connect_timeout_ms: Some(15_000),
754 reconnect_delay_initial_ms: Some(250),
755 reconnect_delay_max_ms: Some(5_000),
756 reconnect_backoff_factor: Some(2.0),
757 reconnect_jitter_ms: Some(200),
758 reconnect_max_attempts: None,
759 heartbeat_timeout_secs: None,
760 idle_timeout_ms: None,
761 backend: self.transport_backend,
762 proxy_url: self
763 .proxy_url
764 .as_ref()
765 .map(|value| value.expose_secret().to_owned()),
766 };
767
768 let socket_control = self.socket_factory.as_ref().map(|factory| {
769 let kind = if self.requires_auth { "user" } else { "data" };
770 let endpoint = format!("dydx-{kind}-streams");
771 if slot_index == 0 {
772 factory.control(endpoint)
773 } else {
774 factory.control(format!("{endpoint}-{slot_index}"))
775 }
776 });
777 let client = WebSocketClient::builder()
778 .config(cfg)
779 .message_handler(message_handler)
780 .default_quota(*DYDX_WS_SUBSCRIPTION_QUOTA)
781 .maybe_state_sink(
782 socket_control
783 .as_ref()
784 .map(nautilus_live::SocketControl::sink),
785 )
786 .connect()
787 .await
788 .map_err(|e| DydxWsError::Transport(e.to_string()))?;
789
790 let connection_mode = client.connection_mode_atomic();
791 let reconnect_handle = client.reconnect_handle();
792 let subscriptions_state = SubscriptionState::new(DYDX_WS_TOPIC_DELIMITER);
793
794 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
795
796 let out_tx =
797 self.out_tx.lock().clone().ok_or_else(|| {
798 DydxWsError::Transport("Output channel not initialized".to_string())
799 })?;
800
801 let signal = self.signal.clone();
802 let subscriptions = subscriptions_state.clone();
803
804 let mut handler_task = TaskSlot::new();
805 if let Err(e) = handler_task.spawn(async move {
806 let mut handler =
807 FeedHandler::new(cmd_rx, out_tx, raw_rx, client, signal, subscriptions);
808 handler.run().await;
809 }) {
810 let shutdown_error = match finish_task(
811 &mut handler_task,
812 std::time::Duration::ZERO,
813 std::time::Duration::from_secs(2),
814 )
815 .await
816 {
817 Some(TaskJoinOutcome::Failed(error)) => {
818 Some(format!("handler task failed: {error}"))
819 }
820 Some(TaskJoinOutcome::Incomplete) => {
821 Some("handler task did not stop after abort".to_string())
822 }
823 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => None,
824 };
825 return Err(DydxWsError::Transport(match shutdown_error {
826 Some(shutdown_error) => format!(
827 "Failed to start handler task: {e}; startup rollback failed: {shutdown_error}"
828 ),
829 None => format!("Failed to start handler task: {e}"),
830 }));
831 }
832
833 if let Some(control) = &socket_control {
834 control.register(move || reconnect_handle.request_reconnect());
835 }
836
837 Ok(ConnectionSlot {
838 cmd_tx,
839 topics: AHashMap::new(),
840 channel_counts: [0; CHANNEL_KIND_COUNT],
841 subscriptions_state,
842 handler_task,
843 connection_mode,
844 socket_control,
845 })
846 }
847
848 fn ticker_from_instrument_id(instrument_id: &InstrumentId) -> String {
849 let mut s = instrument_id.symbol.as_str().to_string();
850 if let Some(stripped) = s.strip_suffix("-PERP") {
851 s = stripped.to_string();
852 }
853 s
854 }
855
856 fn topic(channel: DydxWsChannel, id: Option<&str>) -> String {
857 match id {
858 Some(id) => format!("{}{}{}", channel.as_ref(), DYDX_WS_TOPIC_DELIMITER, id),
859 None => channel.as_ref().to_string(),
860 }
861 }
862
863 async fn subscribe_topic(
864 &self,
865 channel: ChannelKind,
866 topic: String,
867 sub_msg: DydxSubscription,
868 ) -> DydxWsResult<()> {
869 let _connect_guard = self.connect_lock.lock().await;
870 let generation = self.admission_generation()?;
871
872 {
873 let admission = self.admission.lock();
874 if admission.closed || admission.generation != generation {
875 return Err(DydxWsError::Transport(
876 "WebSocket connection pool is closed".to_string(),
877 ));
878 }
879 let mut slots = self.slots.lock();
880 if let Some(slot) = slots.iter_mut().find(|s| s.topics.contains_key(&topic)) {
881 *slot.topics.get_mut(&topic).expect("topic refcount present") += 1;
882 return Ok(());
883 }
884 }
885
886 let target_idx = loop {
887 {
888 let admission = self.admission.lock();
889 if admission.closed || admission.generation != generation {
890 return Err(DydxWsError::Transport(
891 "WebSocket connection pool is closed".to_string(),
892 ));
893 }
894 let slots = self.slots.lock();
895 if let Some(idx) = slots.iter().position(|s| {
896 (s.channel_counts[channel as usize] as usize) < self.per_channel_limit
897 }) {
898 break idx;
899 }
900
901 if slots.len() >= self.max_ws_connections {
902 return Err(DydxWsError::Subscription(format!(
903 "Pool exhausted: {} connections x {} {:?} subscriptions",
904 self.max_ws_connections, self.per_channel_limit, channel,
905 )));
906 }
907 }
908
909 let slot_index = self.slots.lock().len();
910 let new_slot = self.create_connection(slot_index).await?;
911 let new_idx = {
912 let admission = self.admission.lock();
913 let mut slots = self.slots.lock();
914
915 if admission.closed || admission.generation != generation {
916 let _ = new_slot.cmd_tx.send(HandlerCommand::Disconnect);
917 slots.push(new_slot);
918 return Err(DydxWsError::Transport(
919 "WebSocket connection was canceled by shutdown".to_string(),
920 ));
921 }
922 slots.push(new_slot);
923 slots.len() - 1
924 };
925 log::debug!(
926 "dYdX pool slot {new_idx} connected: url={}, channel={:?}",
927 self.url,
928 channel,
929 );
930 };
931
932 let admission = self.admission.lock();
933 if admission.closed || admission.generation != generation {
934 return Err(DydxWsError::Transport(
935 "WebSocket connection pool is closed".to_string(),
936 ));
937 }
938 let mut slots = self.slots.lock();
939 let slot = &mut slots[target_idx];
940
941 slot.subscriptions_state.mark_subscribe(&topic);
942 slot.cmd_tx
943 .send(HandlerCommand::RegisterSubscription {
944 topic: topic.clone(),
945 subscription: sub_msg.clone(),
946 })
947 .map_err(|e| {
948 slot.subscriptions_state.mark_failure(&topic);
949 DydxWsError::Transport(format!("Slot {target_idx} unavailable: {e}"))
950 })?;
951
952 let payload = serde_json::to_string(&sub_msg)?;
953 if let Err(e) = slot.cmd_tx.send(HandlerCommand::SendText(payload)) {
954 slot.subscriptions_state.mark_failure(&topic);
955 let _ = slot.cmd_tx.send(HandlerCommand::UnregisterSubscription {
956 topic: topic.clone(),
957 });
958 return Err(DydxWsError::Transport(format!(
959 "Slot {target_idx} send failed: {e}"
960 )));
961 }
962
963 slot.topics.insert(topic, 1);
964 slot.channel_counts[channel as usize] =
965 slot.channel_counts[channel as usize].saturating_add(1);
966
967 Ok(())
968 }
969
970 async fn unsubscribe_topic(
971 &self,
972 channel: ChannelKind,
973 topic: String,
974 unsub_msg: DydxSubscription,
975 ) -> DydxWsResult<()> {
976 let _connect_guard = self.connect_lock.lock().await;
977 let _generation = self.admission_generation()?;
978 let admission = self.admission.lock();
979 if admission.closed {
980 return Err(DydxWsError::Transport(
981 "WebSocket connection pool is closed".to_string(),
982 ));
983 }
984 let mut slots = self.slots.lock();
985 let Some(slot_idx) = slots.iter().position(|s| s.topics.contains_key(&topic)) else {
986 return Ok(());
987 };
988
989 let slot = &mut slots[slot_idx];
990 let refcount = slot.topics.get_mut(&topic).expect("topic present");
991 if *refcount > 1 {
992 *refcount -= 1;
993 return Ok(());
994 }
995
996 slot.subscriptions_state.mark_unsubscribe(&topic);
997 let payload = serde_json::to_string(&unsub_msg)?;
998 if let Err(e) = slot.cmd_tx.send(HandlerCommand::SendText(payload)) {
999 slot.subscriptions_state.mark_subscribe(&topic);
1000 return Err(DydxWsError::Transport(format!(
1001 "Slot {slot_idx} send failed: {e}"
1002 )));
1003 }
1004 let _ = slot.cmd_tx.send(HandlerCommand::UnregisterSubscription {
1005 topic: topic.clone(),
1006 });
1007
1008 slot.topics.remove(&topic);
1009 slot.channel_counts[channel as usize] =
1010 slot.channel_counts[channel as usize].saturating_sub(1);
1011
1012 Ok(())
1013 }
1014
1015 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> DydxWsResult<()> {
1025 let ticker = Self::ticker_from_instrument_id(&instrument_id);
1026 let topic = Self::topic(DydxWsChannel::Trades, Some(&ticker));
1027 let sub = DydxSubscription {
1028 op: DydxWsOperation::Subscribe,
1029 channel: DydxWsChannel::Trades,
1030 id: Some(ticker),
1031 };
1032 self.subscribe_topic(ChannelKind::Trades, topic, sub).await
1033 }
1034
1035 pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> DydxWsResult<()> {
1041 let ticker = Self::ticker_from_instrument_id(&instrument_id);
1042 let topic = Self::topic(DydxWsChannel::Trades, Some(&ticker));
1043 let sub = DydxSubscription {
1044 op: DydxWsOperation::Unsubscribe,
1045 channel: DydxWsChannel::Trades,
1046 id: Some(ticker),
1047 };
1048 self.unsubscribe_topic(ChannelKind::Trades, topic, sub)
1049 .await
1050 }
1051
1052 pub async fn subscribe_orderbook(&self, instrument_id: InstrumentId) -> DydxWsResult<()> {
1062 let ticker = Self::ticker_from_instrument_id(&instrument_id);
1063 let topic = Self::topic(DydxWsChannel::Orderbook, Some(&ticker));
1064 let sub = DydxSubscription {
1065 op: DydxWsOperation::Subscribe,
1066 channel: DydxWsChannel::Orderbook,
1067 id: Some(ticker),
1068 };
1069 self.subscribe_topic(ChannelKind::Orderbook, topic, sub)
1070 .await
1071 }
1072
1073 pub async fn unsubscribe_orderbook(&self, instrument_id: InstrumentId) -> DydxWsResult<()> {
1079 let ticker = Self::ticker_from_instrument_id(&instrument_id);
1080 let topic = Self::topic(DydxWsChannel::Orderbook, Some(&ticker));
1081 let sub = DydxSubscription {
1082 op: DydxWsOperation::Unsubscribe,
1083 channel: DydxWsChannel::Orderbook,
1084 id: Some(ticker),
1085 };
1086 self.unsubscribe_topic(ChannelKind::Orderbook, topic, sub)
1087 .await
1088 }
1089
1090 pub async fn subscribe_candles(
1100 &self,
1101 instrument_id: InstrumentId,
1102 resolution: &str,
1103 ) -> DydxWsResult<()> {
1104 let ticker = Self::ticker_from_instrument_id(&instrument_id);
1105 let id = format!("{ticker}/{resolution}");
1106 let topic = Self::topic(DydxWsChannel::Candles, Some(&id));
1107 let sub = DydxSubscription {
1108 op: DydxWsOperation::Subscribe,
1109 channel: DydxWsChannel::Candles,
1110 id: Some(id),
1111 };
1112 self.subscribe_topic(ChannelKind::Candles, topic, sub).await
1113 }
1114
1115 pub async fn unsubscribe_candles(
1121 &self,
1122 instrument_id: InstrumentId,
1123 resolution: &str,
1124 ) -> DydxWsResult<()> {
1125 let ticker = Self::ticker_from_instrument_id(&instrument_id);
1126 let id = format!("{ticker}/{resolution}");
1127 let topic = Self::topic(DydxWsChannel::Candles, Some(&id));
1128 let sub = DydxSubscription {
1129 op: DydxWsOperation::Unsubscribe,
1130 channel: DydxWsChannel::Candles,
1131 id: Some(id),
1132 };
1133 self.unsubscribe_topic(ChannelKind::Candles, topic, sub)
1134 .await
1135 }
1136
1137 pub async fn subscribe_markets(&self) -> DydxWsResult<()> {
1147 let topic = Self::topic(DydxWsChannel::Markets, None);
1148 let sub = DydxSubscription {
1149 op: DydxWsOperation::Subscribe,
1150 channel: DydxWsChannel::Markets,
1151 id: None,
1152 };
1153 self.subscribe_topic(ChannelKind::Markets, topic, sub).await
1154 }
1155
1156 pub async fn unsubscribe_markets(&self) -> DydxWsResult<()> {
1162 let topic = Self::topic(DydxWsChannel::Markets, None);
1163 let sub = DydxSubscription {
1164 op: DydxWsOperation::Unsubscribe,
1165 channel: DydxWsChannel::Markets,
1166 id: None,
1167 };
1168 self.unsubscribe_topic(ChannelKind::Markets, topic, sub)
1169 .await
1170 }
1171
1172 pub async fn subscribe_subaccount(
1189 &self,
1190 address: &str,
1191 subaccount_number: u32,
1192 ) -> DydxWsResult<()> {
1193 if !self.requires_auth {
1194 return Err(DydxWsError::Authentication(
1195 "Subaccount subscriptions require authentication. Use new_private() to create an authenticated client".to_string(),
1196 ));
1197 }
1198 let id = format!("{address}/{subaccount_number}");
1199 let topic = Self::topic(DydxWsChannel::Subaccounts, Some(&id));
1200 let sub = DydxSubscription {
1201 op: DydxWsOperation::Subscribe,
1202 channel: DydxWsChannel::Subaccounts,
1203 id: Some(id),
1204 };
1205 self.subscribe_pinned(topic, sub).await
1206 }
1207
1208 pub async fn unsubscribe_subaccount(
1214 &self,
1215 address: &str,
1216 subaccount_number: u32,
1217 ) -> DydxWsResult<()> {
1218 let id = format!("{address}/{subaccount_number}");
1219 let topic = Self::topic(DydxWsChannel::Subaccounts, Some(&id));
1220 let sub = DydxSubscription {
1221 op: DydxWsOperation::Unsubscribe,
1222 channel: DydxWsChannel::Subaccounts,
1223 id: Some(id),
1224 };
1225 self.unsubscribe_pinned(topic, sub).await
1226 }
1227
1228 pub async fn subscribe_block_height(&self) -> DydxWsResult<()> {
1238 let topic = Self::topic(DydxWsChannel::BlockHeight, None);
1239 let sub = DydxSubscription {
1240 op: DydxWsOperation::Subscribe,
1241 channel: DydxWsChannel::BlockHeight,
1242 id: None,
1243 };
1244 self.subscribe_pinned(topic, sub).await
1245 }
1246
1247 pub async fn unsubscribe_block_height(&self) -> DydxWsResult<()> {
1253 let topic = Self::topic(DydxWsChannel::BlockHeight, None);
1254 let sub = DydxSubscription {
1255 op: DydxWsOperation::Unsubscribe,
1256 channel: DydxWsChannel::BlockHeight,
1257 id: None,
1258 };
1259 self.unsubscribe_pinned(topic, sub).await
1260 }
1261
1262 async fn subscribe_pinned(&self, topic: String, sub_msg: DydxSubscription) -> DydxWsResult<()> {
1263 let _connect_guard = self.connect_lock.lock().await;
1264 let generation = self.admission_generation()?;
1265
1266 {
1267 let admission = self.admission.lock();
1268 if admission.closed || admission.generation != generation {
1269 return Err(DydxWsError::Transport(
1270 "WebSocket connection pool is closed".to_string(),
1271 ));
1272 }
1273 let mut slots = self.slots.lock();
1274 if let Some(slot) = slots.iter_mut().find(|s| s.topics.contains_key(&topic)) {
1275 *slot.topics.get_mut(&topic).expect("topic refcount present") += 1;
1276 return Ok(());
1277 }
1278 }
1279
1280 if self.slots.lock().is_empty() {
1281 let new_slot = self.create_connection(0).await?;
1282 let admission = self.admission.lock();
1283 let mut slots = self.slots.lock();
1284
1285 if admission.closed || admission.generation != generation {
1286 let _ = new_slot.cmd_tx.send(HandlerCommand::Disconnect);
1287 slots.push(new_slot);
1288 return Err(DydxWsError::Transport(
1289 "WebSocket connection was canceled by shutdown".to_string(),
1290 ));
1291 }
1292 self.connection_mode.store(new_slot.connection_mode.clone());
1293 slots.push(new_slot);
1294 }
1295
1296 let admission = self.admission.lock();
1297 if admission.closed || admission.generation != generation {
1298 return Err(DydxWsError::Transport(
1299 "WebSocket connection pool is closed".to_string(),
1300 ));
1301 }
1302 let mut slots = self.slots.lock();
1303 let slot = slots.first_mut().expect("primary slot exists");
1304 slot.subscriptions_state.mark_subscribe(&topic);
1305 slot.cmd_tx
1306 .send(HandlerCommand::RegisterSubscription {
1307 topic: topic.clone(),
1308 subscription: sub_msg.clone(),
1309 })
1310 .map_err(|e| {
1311 slot.subscriptions_state.mark_failure(&topic);
1312 DydxWsError::Transport(format!("Primary slot unavailable: {e}"))
1313 })?;
1314 let payload = serde_json::to_string(&sub_msg)?;
1315 if let Err(e) = slot.cmd_tx.send(HandlerCommand::SendText(payload)) {
1316 slot.subscriptions_state.mark_failure(&topic);
1317 let _ = slot.cmd_tx.send(HandlerCommand::UnregisterSubscription {
1318 topic: topic.clone(),
1319 });
1320 return Err(DydxWsError::Transport(format!(
1321 "Primary slot send failed: {e}"
1322 )));
1323 }
1324 slot.topics.insert(topic, 1);
1325 Ok(())
1326 }
1327
1328 async fn unsubscribe_pinned(
1329 &self,
1330 topic: String,
1331 unsub_msg: DydxSubscription,
1332 ) -> DydxWsResult<()> {
1333 let _connect_guard = self.connect_lock.lock().await;
1334 let _generation = self.admission_generation()?;
1335 let admission = self.admission.lock();
1336 if admission.closed {
1337 return Err(DydxWsError::Transport(
1338 "WebSocket connection pool is closed".to_string(),
1339 ));
1340 }
1341 let mut slots = self.slots.lock();
1342 let Some(slot) = slots.first_mut() else {
1343 return Ok(());
1344 };
1345 let Some(refcount) = slot.topics.get_mut(&topic) else {
1346 return Ok(());
1347 };
1348
1349 if *refcount > 1 {
1350 *refcount -= 1;
1351 return Ok(());
1352 }
1353 slot.subscriptions_state.mark_unsubscribe(&topic);
1354 let payload = serde_json::to_string(&unsub_msg)?;
1355 if let Err(e) = slot.cmd_tx.send(HandlerCommand::SendText(payload)) {
1356 slot.subscriptions_state.mark_subscribe(&topic);
1357 return Err(DydxWsError::Transport(format!(
1358 "Primary slot send failed: {e}"
1359 )));
1360 }
1361 let _ = slot.cmd_tx.send(HandlerCommand::UnregisterSubscription {
1362 topic: topic.clone(),
1363 });
1364 slot.topics.remove(&topic);
1365 Ok(())
1366 }
1367}
1368
1369#[derive(Debug)]
1370struct ConnectionSlots {
1371 slots: Mutex<Vec<ConnectionSlot>>,
1372 shutdown_errors: Mutex<Vec<String>>,
1373}
1374
1375impl ConnectionSlots {
1376 fn new() -> Self {
1377 Self {
1378 slots: Mutex::new(Vec::new()),
1379 shutdown_errors: Mutex::new(Vec::new()),
1380 }
1381 }
1382
1383 fn push_shutdown_error(&self, error: String) {
1384 self.shutdown_errors.lock().push(error);
1385 }
1386
1387 fn take_shutdown_errors(&self) -> Vec<String> {
1388 std::mem::take(&mut *self.shutdown_errors.lock())
1389 }
1390}
1391
1392impl std::ops::Deref for ConnectionSlots {
1393 type Target = Mutex<Vec<ConnectionSlot>>;
1394
1395 fn deref(&self) -> &Self::Target {
1396 &self.slots
1397 }
1398}
1399
1400impl Drop for ConnectionSlots {
1401 fn drop(&mut self) {
1402 for slot in self.slots.get_mut().iter() {
1403 if let Some(handle) = slot.handler_task.as_ref() {
1404 handle.abort();
1405 }
1406
1407 if let Some(control) = &slot.socket_control {
1408 control.deregister();
1409 }
1410 }
1411 }
1412}
1413
1414#[derive(Debug)]
1415struct PoolAdmission {
1416 generation: u64,
1417 closed: bool,
1418}
1419
1420struct ConnectionSlotBatch<'a> {
1421 owner: &'a Mutex<Vec<ConnectionSlot>>,
1422 slots: Vec<ConnectionSlot>,
1423}
1424
1425impl<'a> ConnectionSlotBatch<'a> {
1426 fn take(owner: &'a Mutex<Vec<ConnectionSlot>>) -> Self {
1427 let slots = std::mem::take(&mut *owner.lock());
1428 Self { owner, slots }
1429 }
1430}
1431
1432impl Drop for ConnectionSlotBatch<'_> {
1433 fn drop(&mut self) {
1434 self.owner.lock().extend(self.slots.drain(..));
1435 }
1436}
1437
1438pub(crate) fn candle_ids_from_topics(topics: &[String]) -> AHashSet<String> {
1442 let prefix = format!(
1443 "{}{}",
1444 DydxWsChannel::Candles.as_ref(),
1445 DYDX_WS_TOPIC_DELIMITER
1446 );
1447 topics
1448 .iter()
1449 .filter_map(|topic| topic.strip_prefix(&prefix).map(ToString::to_string))
1450 .collect()
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use nautilus_core::string::secret::REDACTED;
1456 use rstest::rstest;
1457
1458 use super::*;
1459
1460 #[rstest]
1461 fn test_debug_redacts_proxy_url() {
1462 let proxy_url = "http://user:password@proxy.example:8080";
1463 let client = DydxWebSocketClient::new_public(
1464 "wss://test".to_string(),
1465 None,
1466 Some(proxy_url.to_string()),
1467 );
1468
1469 let debug = format!("{client:?}");
1470
1471 assert!(debug.contains(REDACTED));
1472 assert!(!debug.contains(proxy_url));
1473 }
1474
1475 #[rstest]
1476 fn test_candle_ids_from_topics_extracts_only_candle_ids() {
1477 let topics = vec![
1478 "v4_candles:BTC-USD/1MIN".to_string(),
1479 "v4_trades:BTC-USD".to_string(),
1480 "v4_orderbook:ETH-USD".to_string(),
1481 "v4_candles:ETH-USD/5MINS".to_string(),
1482 ];
1483
1484 let ids = candle_ids_from_topics(&topics);
1485
1486 assert_eq!(ids.len(), 2);
1487 assert!(ids.contains("BTC-USD/1MIN"));
1488 assert!(ids.contains("ETH-USD/5MINS"));
1489 assert!(!ids.contains("BTC-USD"));
1490 }
1491
1492 #[tokio::test]
1493 async fn test_drop_clone_does_not_stop_connection_pool() {
1494 let client = DydxWebSocketClient::new_public("wss://test".to_string(), None, None);
1495 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1496 client.slots.lock().push(ConnectionSlot {
1497 cmd_tx,
1498 topics: AHashMap::new(),
1499 channel_counts: [0; CHANNEL_KIND_COUNT],
1500 subscriptions_state: SubscriptionState::new(DYDX_WS_TOPIC_DELIMITER),
1501 handler_task: TaskSlot::from_handle(tokio::spawn(std::future::pending())),
1502 connection_mode: Arc::new(AtomicU8::new(ConnectionMode::Active as u8)),
1503 socket_control: None,
1504 });
1505 let clone = client.clone();
1506
1507 drop(clone);
1508
1509 let slots = client.slots.lock();
1510 assert_eq!(slots.len(), 1);
1511 assert!(
1512 !slots[0]
1513 .handler_task
1514 .as_ref()
1515 .expect("handler task")
1516 .is_finished()
1517 );
1518 }
1519
1520 #[tokio::test]
1521 async fn test_cancelled_disconnect_retains_connection_slot() {
1522 let mut client = DydxWebSocketClient::new_public("wss://test".to_string(), None, None);
1523 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1524 client.slots.lock().push(ConnectionSlot {
1525 cmd_tx,
1526 topics: AHashMap::new(),
1527 channel_counts: [0; CHANNEL_KIND_COUNT],
1528 subscriptions_state: SubscriptionState::new(DYDX_WS_TOPIC_DELIMITER),
1529 handler_task: TaskSlot::from_handle(tokio::spawn(std::future::pending())),
1530 connection_mode: Arc::new(AtomicU8::new(ConnectionMode::Active as u8)),
1531 socket_control: None,
1532 });
1533
1534 {
1535 let disconnect = client.disconnect();
1536 tokio::pin!(disconnect);
1537 tokio::select! {
1538 result = &mut disconnect => panic!("disconnect completed unexpectedly: {result:?}"),
1539 command = cmd_rx.recv() => assert!(command.is_some()),
1540 }
1541 }
1542
1543 let slots = client.slots.lock();
1544 assert_eq!(slots.len(), 1);
1545 assert!(slots[0].handler_task.is_some());
1546 }
1547}