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