1use std::{
22 num::NonZeroU32,
23 str::FromStr,
24 sync::{
25 Arc, LazyLock,
26 atomic::{AtomicBool, AtomicU8, Ordering},
27 },
28 time::Duration,
29};
30
31use arc_swap::ArcSwap;
32#[cfg(test)]
33use nautilus_core::string::secret::REDACTED;
34use nautilus_core::{AtomicMap, string::secret::SecretString};
35use nautilus_live::{
36 SocketControl,
37 task::{TaskJoinOutcome, TaskSlot, finish_task},
38};
39use nautilus_model::{
40 data::BarType,
41 identifiers::{AccountId, InstrumentId},
42 instruments::{Instrument, InstrumentAny},
43};
44use nautilus_network::{
45 ConnectionUrl,
46 http::create_standard_nautilus_headers,
47 mode::ConnectionMode,
48 ratelimiter::quota::Quota,
49 websocket::{
50 SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
51 channel_message_handler,
52 },
53};
54use ustr::Ustr;
55
56use crate::{
57 common::{
58 consts::{
59 RECONNECT_BACKOFF_FACTOR, RECONNECT_BASE_BACKOFF, RECONNECT_JITTER_MS,
60 RECONNECT_MAX_BACKOFF, RECONNECT_TIMEOUT, WS_DISCONNECT_TIMEOUT, WS_HEARTBEAT_SECS,
61 },
62 credential::CoinbaseCredential,
63 enums::CoinbaseWsChannel,
64 },
65 websocket::{
66 handler::{FeedHandler, HandlerCommand, NautilusWsMessage},
67 messages::{CoinbaseWsAction, CoinbaseWsSubscription},
68 },
69};
70
71pub static COINBASE_WS_CONNECTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
73 Quota::per_second(NonZeroU32::new(8).expect("non-zero")).expect("valid constant")
74});
75
76pub static COINBASE_WS_SUBSCRIPTION_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
78 Quota::per_second(NonZeroU32::new(8).expect("non-zero")).expect("valid constant")
79});
80
81pub const COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION: &str = "subscription";
83
84pub static COINBASE_WS_SUBSCRIPTION_KEYS: LazyLock<[Ustr; 1]> =
86 LazyLock::new(|| [Ustr::from(COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION)]);
87
88#[derive(Debug)]
93pub struct CoinbaseWebSocketClient {
94 url: ConnectionUrl,
95 connection_mode: Arc<ArcSwap<AtomicU8>>,
96 signal: Arc<AtomicBool>,
97 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
98 out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
99 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
100 subscription_aliases: Arc<AtomicMap<Ustr, Ustr>>,
105 bar_types: ahash::AHashMap<String, BarType>,
106 subscriptions: SubscriptionState,
107 credential: Option<CoinbaseCredential>,
108 account_id: Option<AccountId>,
109 task_handle: TaskSlot<()>,
110 shutdown_errors: Vec<String>,
111 transport_backend: TransportBackend,
112 proxy_url: Option<SecretString>,
113 socket_control: Option<SocketControl>,
114}
115
116impl Clone for CoinbaseWebSocketClient {
117 fn clone(&self) -> Self {
118 Self {
119 url: self.url.clone(),
120 connection_mode: Arc::clone(&self.connection_mode),
121 signal: Arc::clone(&self.signal),
122 cmd_tx: Arc::clone(&self.cmd_tx),
123 out_rx: None,
124 instruments: Arc::clone(&self.instruments),
125 subscription_aliases: Arc::clone(&self.subscription_aliases),
126 bar_types: self.bar_types.clone(),
127 subscriptions: self.subscriptions.clone(),
128 credential: self.credential.clone(),
129 account_id: self.account_id,
130 task_handle: TaskSlot::new(),
131 shutdown_errors: Vec::new(),
132 transport_backend: self.transport_backend,
133 proxy_url: self.proxy_url.clone(),
134 socket_control: self.socket_control.clone(),
135 }
136 }
137}
138
139impl CoinbaseWebSocketClient {
140 pub fn new(url: &str, transport_backend: TransportBackend, proxy_url: Option<String>) -> Self {
142 let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
143
144 Self {
145 url: url.into(),
146 connection_mode: Arc::new(ArcSwap::from_pointee(AtomicU8::new(
147 ConnectionMode::Closed.as_u8(),
148 ))),
149 signal: Arc::new(AtomicBool::new(false)),
150 cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
151 out_rx: None,
152 instruments: Arc::new(AtomicMap::new()),
153 subscription_aliases: Arc::new(AtomicMap::new()),
154 bar_types: ahash::AHashMap::new(),
155 subscriptions: SubscriptionState::new('|'),
156 credential: None,
157 account_id: None,
158 task_handle: TaskSlot::new(),
159 shutdown_errors: Vec::new(),
160 transport_backend,
161 proxy_url: proxy_url.map(SecretString::from),
162 socket_control: None,
163 }
164 }
165
166 #[must_use]
168 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
169 self.socket_control = Some(control);
170 self
171 }
172
173 pub fn with_credential(
175 url: &str,
176 credential: CoinbaseCredential,
177 transport_backend: TransportBackend,
178 proxy_url: Option<String>,
179 ) -> Self {
180 let mut client = Self::new(url, transport_backend, proxy_url);
181 client.credential = Some(credential);
182 client
183 }
184
185 pub async fn set_account_id(&mut self, account_id: AccountId) {
190 self.account_id = Some(account_id);
191
192 let cmd_tx = self.cmd_tx.read().await;
193 if let Err(e) = cmd_tx.send(HandlerCommand::SetAccountId(account_id)) {
194 log::debug!("Failed to send SetAccountId: {e}");
195 }
196 }
197
198 pub async fn initialize_instruments(&self, instruments: Vec<InstrumentAny>) {
205 let cmd_tx = self.cmd_tx.read().await;
206
207 if !instruments.is_empty() {
208 self.instruments.rcu(|cache| {
209 for instrument in &instruments {
210 cache.insert(instrument.id(), instrument.clone());
211 }
212 });
213 }
214
215 if let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments)) {
216 log::debug!("Failed to send InitializeInstruments: {e}");
217 }
218 }
219
220 fn prime_default_subscriptions(&self) {
224 self.subscriptions
225 .mark_subscribe(CoinbaseWsChannel::Heartbeats.as_ref());
226 }
227
228 pub async fn connect(&mut self) -> anyhow::Result<()> {
230 if self.is_active() || self.is_reconnecting() {
231 log::warn!("WebSocket already connected or reconnecting");
232 return Ok(());
233 }
234
235 if let Some(outcome) = finish_task(
236 &mut self.task_handle,
237 WS_DISCONNECT_TIMEOUT,
238 WS_DISCONNECT_TIMEOUT,
239 )
240 .await
241 {
242 match outcome {
243 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
244 TaskJoinOutcome::Failed(error) => {
245 anyhow::bail!("Coinbase WebSocket handler failed: {error}");
246 }
247 TaskJoinOutcome::Incomplete => {
248 anyhow::bail!("Coinbase WebSocket handler did not stop after abort");
249 }
250 }
251 }
252
253 self.signal.store(false, Ordering::Relaxed);
255
256 let (message_handler, raw_rx) = channel_message_handler();
257 let headers = create_standard_nautilus_headers();
258
259 let cfg = WebSocketConfig {
260 url: self.url.expose().to_owned(),
261 headers,
262 heartbeat_interval_secs: Some(WS_HEARTBEAT_SECS),
265 heartbeat_payload: None,
266 connect_timeout_ms: Some(RECONNECT_TIMEOUT.as_millis() as u64),
267 reconnect_delay_initial_ms: Some(RECONNECT_BASE_BACKOFF.as_millis() as u64),
268 reconnect_delay_max_ms: Some(RECONNECT_MAX_BACKOFF.as_millis() as u64),
269 reconnect_backoff_factor: Some(RECONNECT_BACKOFF_FACTOR),
270 reconnect_jitter_ms: Some(RECONNECT_JITTER_MS),
271 reconnect_max_attempts: None,
272 heartbeat_timeout_secs: None,
273 idle_timeout_ms: None,
274 writer_capacity: None,
275 backend: self.transport_backend,
276 proxy_url: self
277 .proxy_url
278 .as_ref()
279 .map(|value| value.expose_secret().to_owned()),
280 max_message_size_bytes: None,
281 max_frame_size_bytes: None,
282 };
283
284 let keyed_quotas = vec![(
285 COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION.to_string(),
286 *COINBASE_WS_SUBSCRIPTION_QUOTA,
287 )];
288
289 let client = WebSocketClient::builder()
290 .config(cfg)
291 .message_handler(message_handler)
292 .keyed_quotas(keyed_quotas)
293 .default_quota(*COINBASE_WS_CONNECTION_QUOTA)
294 .maybe_state_sink(self.socket_control.as_ref().map(SocketControl::sink))
295 .connect()
296 .await?;
297
298 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
299 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
300
301 self.out_rx = Some(out_rx);
302 let connection_mode = client.connection_mode_atomic();
303 let reconnect_handle = client.reconnect_handle();
304 log::debug!("Coinbase WebSocket connected: {}", self.url.origin());
305
306 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
307 anyhow::bail!("Failed to send SetClient command: {e}");
308 }
309
310 if let Some(control) = &self.socket_control {
311 control.register(move || reconnect_handle.request_reconnect());
312 }
313
314 {
315 let mut command_sender = self.cmd_tx.write().await;
316 let instruments_vec: Vec<InstrumentAny> =
317 self.instruments.load().values().cloned().collect();
318
319 if !instruments_vec.is_empty()
320 && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments_vec))
321 {
322 log::error!("Failed to send InitializeInstruments: {e}");
323 }
324
325 for (key, bar_type) in &self.bar_types {
327 if let Err(e) = cmd_tx.send(HandlerCommand::AddBarType {
328 key: key.clone(),
329 bar_type: *bar_type,
330 }) {
331 log::error!("Failed to restore bar type {key}: {e}");
332 }
333 }
334
335 if let Some(account_id) = self.account_id
336 && let Err(e) = cmd_tx.send(HandlerCommand::SetAccountId(account_id))
337 {
338 log::error!("Failed to restore account_id: {e}");
339 }
340
341 self.prime_default_subscriptions();
342
343 resubscribe_all(
345 &self.subscriptions,
346 &self.credential,
347 &cmd_tx,
348 Some(&out_tx),
349 );
350
351 *command_sender = cmd_tx.clone();
352 self.connection_mode.store(connection_mode);
353 }
354
355 let signal = Arc::clone(&self.signal);
356 let subscriptions = self.subscriptions.clone();
357 let credential = self.credential.clone();
358 let cmd_tx_reconnect = cmd_tx.clone();
359 let aliases_for_handler = Arc::clone(&self.subscription_aliases);
360
361 if let Err(e) = self.task_handle.spawn(async move {
362 let mut handler = FeedHandler::new(signal, cmd_rx, raw_rx, aliases_for_handler);
363
364 loop {
365 match handler.next().await {
366 Some(NautilusWsMessage::Reconnected) => {
367 subscriptions.reset_after_reconnect();
368 resubscribe_all(
369 &subscriptions,
370 &credential,
371 &cmd_tx_reconnect,
372 Some(&out_tx),
373 );
374
375 if let Err(e) = out_tx.send(NautilusWsMessage::Reconnected) {
376 log::debug!("Output channel closed: {e}");
377 break;
378 }
379 }
380 Some(msg) => {
381 if let Err(e) = out_tx.send(msg) {
382 log::debug!("Output channel closed: {e}");
383 break;
384 }
385 }
386 None => {
387 log::debug!("Feed handler stopped");
388 break;
389 }
390 }
391 }
392 }) {
393 self.out_rx = None;
394 anyhow::bail!("Failed to start Coinbase WebSocket handler task: {e}");
395 }
396
397 Ok(())
398 }
399
400 pub async fn subscribe(
402 &self,
403 channel: CoinbaseWsChannel,
404 product_ids: &[Ustr],
405 ) -> anyhow::Result<()> {
406 let jwt = if channel.requires_auth() {
407 let credential = self
408 .credential
409 .as_ref()
410 .ok_or_else(|| anyhow::anyhow!("Credentials required for {channel}"))?;
411 Some(credential.build_ws_jwt()?)
412 } else {
413 self.credential.as_ref().and_then(|c| c.build_ws_jwt().ok())
414 };
415
416 let sub = protect_subscription(CoinbaseWsSubscription {
417 msg_type: CoinbaseWsAction::Subscribe,
418 product_ids: product_ids.to_vec(),
419 channel,
420 jwt,
421 })?;
422
423 let channel_str = channel.as_ref();
424
425 if product_ids.is_empty() {
426 self.subscriptions.mark_subscribe(channel_str);
427 } else {
428 for product_id in product_ids {
429 let topic = format!("{channel_str}|{product_id}");
430 self.subscriptions.mark_subscribe(&topic);
431 }
432 }
433
434 let cmd_tx = self.cmd_tx.read().await;
435 cmd_tx
436 .send(HandlerCommand::Subscribe {
437 channel: sub.channel,
438 product_ids: sub.product_ids,
439 payload: sub.payload,
440 })
441 .map_err(|e| anyhow::anyhow!("Failed to send Subscribe command: {e}"))
442 }
443
444 pub async fn unsubscribe(
446 &self,
447 channel: CoinbaseWsChannel,
448 product_ids: &[Ustr],
449 ) -> anyhow::Result<()> {
450 let jwt = self.credential.as_ref().and_then(|c| c.build_ws_jwt().ok());
451
452 let unsub = protect_subscription(CoinbaseWsSubscription {
453 msg_type: CoinbaseWsAction::Unsubscribe,
454 product_ids: product_ids.to_vec(),
455 channel,
456 jwt,
457 })?;
458
459 let channel_str = channel.as_ref();
460
461 if product_ids.is_empty() {
462 self.subscriptions.mark_unsubscribe(channel_str);
463 } else {
464 for product_id in product_ids {
465 let topic = format!("{channel_str}|{product_id}");
466 self.subscriptions.mark_unsubscribe(&topic);
467 }
468 }
469
470 let cmd_tx = self.cmd_tx.read().await;
471 cmd_tx
472 .send(HandlerCommand::Unsubscribe {
473 channel: unsub.channel,
474 product_ids: unsub.product_ids,
475 payload: unsub.payload,
476 })
477 .map_err(|e| anyhow::anyhow!("Failed to send Unsubscribe command: {e}"))
478 }
479
480 pub async fn next_message(&mut self) -> Option<NautilusWsMessage> {
482 self.out_rx.as_mut()?.recv().await
483 }
484
485 pub(crate) fn begin_shutdown(&self) {
487 self.signal.store(true, Ordering::Release);
488 }
489
490 pub async fn disconnect(&mut self) -> anyhow::Result<()> {
492 let cmd_tx = self.cmd_tx.read().await;
495
496 if let Err(e) = cmd_tx.send(HandlerCommand::Disconnect) {
497 log::debug!("Failed to send Disconnect command: {e}");
498 }
499 drop(cmd_tx);
500
501 self.begin_shutdown();
504
505 if let Some(outcome) = finish_task(
506 &mut self.task_handle,
507 WS_DISCONNECT_TIMEOUT,
508 WS_DISCONNECT_TIMEOUT,
509 )
510 .await
511 {
512 match outcome {
513 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
514 TaskJoinOutcome::Failed(error) => {
515 self.shutdown_errors
516 .push(format!("Coinbase WebSocket handler failed: {error}"));
517 }
518 TaskJoinOutcome::Incomplete => {
519 self.shutdown_errors
520 .push("Coinbase WebSocket handler did not stop after abort".to_string());
521 }
522 }
523 }
524
525 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
530
531 loop {
532 let mode_ptr = self.connection_mode.load();
533
534 if ConnectionMode::from_u8(mode_ptr.load(Ordering::Relaxed)).is_closed() {
535 break;
536 }
537
538 if tokio::time::Instant::now() >= deadline {
539 self.shutdown_errors
540 .push("Timed out waiting for WebSocket to reach Closed state".to_string());
541 break;
542 }
543
544 tokio::time::sleep(Duration::from_millis(20)).await;
545 }
546
547 if let Some(control) = &self.socket_control {
548 control.deregister();
549 }
550
551 if self.shutdown_errors.is_empty() {
552 Ok(())
553 } else {
554 let errors = std::mem::take(&mut self.shutdown_errors);
555 anyhow::bail!(errors.join("; "))
556 }
557 }
558
559 #[must_use]
561 pub fn is_active(&self) -> bool {
562 let mode_ptr = self.connection_mode.load();
563 let mode_val = mode_ptr.load(Ordering::Relaxed);
564 ConnectionMode::from_u8(mode_val).is_active()
565 }
566
567 #[must_use]
569 pub fn is_reconnecting(&self) -> bool {
570 let mode_ptr = self.connection_mode.load();
571 let mode_val = mode_ptr.load(Ordering::Relaxed);
572 ConnectionMode::from_u8(mode_val).is_reconnect()
573 }
574
575 #[must_use]
577 pub fn instruments(&self) -> &Arc<AtomicMap<InstrumentId, InstrumentAny>> {
578 &self.instruments
579 }
580
581 #[must_use]
583 pub fn subscription_aliases(&self) -> &Arc<AtomicMap<Ustr, Ustr>> {
584 &self.subscription_aliases
585 }
586
587 pub fn register_subscription_alias(&self, canonical: Ustr, subscribed: Ustr) {
591 self.subscription_aliases.insert(canonical, subscribed);
592 }
593
594 pub fn unregister_subscription_alias(&self, canonical: &Ustr) {
596 self.subscription_aliases.remove(canonical);
597 }
598
599 #[must_use]
601 pub fn subscriptions(&self) -> &SubscriptionState {
602 &self.subscriptions
603 }
604
605 pub async fn update_instrument(&self, instrument: InstrumentAny) {
607 let cmd_tx = self.cmd_tx.read().await;
608 let id = instrument.id();
609 self.instruments.insert(id, instrument.clone());
610
611 if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(Box::new(instrument))) {
612 log::debug!("Failed to send UpdateInstrument: {e}");
613 }
614 }
615
616 pub(crate) async fn update_instruments(&self, instruments: &[InstrumentAny]) {
617 if instruments.is_empty() {
618 return;
619 }
620
621 let cmd_tx = self.cmd_tx.read().await;
622 self.instruments.rcu(|cache| {
623 for instrument in instruments {
624 cache.insert(instrument.id(), instrument.clone());
625 }
626 });
627
628 for instrument in instruments {
629 if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(Box::new(
630 instrument.clone(),
631 ))) {
632 log::debug!("Failed to send UpdateInstrument: {e}");
633 }
634 }
635 }
636
637 pub fn take_out_rx(
641 &mut self,
642 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>> {
643 self.out_rx.take()
644 }
645
646 pub fn register_bar_type(&mut self, key: String, bar_type: BarType) {
651 self.bar_types.insert(key, bar_type);
652 }
653
654 pub async fn add_bar_type(&mut self, key: String, bar_type: BarType) {
656 self.bar_types.insert(key.clone(), bar_type);
657
658 let cmd_tx = self.cmd_tx.read().await;
659
660 if let Err(e) = cmd_tx.send(HandlerCommand::AddBarType { key, bar_type }) {
661 log::debug!("Failed to send AddBarType: {e}");
662 }
663 }
664}
665
666impl Drop for CoinbaseWebSocketClient {
667 fn drop(&mut self) {
668 if let Some(handle) = self.task_handle.as_ref() {
669 self.signal.store(true, Ordering::Release);
670 handle.abort();
671 }
672 }
673}
674
675struct ProtectedSubscription {
676 channel: CoinbaseWsChannel,
677 product_ids: Vec<Ustr>,
678 payload: SecretString,
679}
680
681fn protect_subscription(
682 mut subscription: CoinbaseWsSubscription,
683) -> Result<ProtectedSubscription, serde_json::Error> {
684 let payload = SecretString::from(serde_json::to_string(&subscription)?);
685
686 Ok(ProtectedSubscription {
687 channel: subscription.channel,
688 product_ids: std::mem::take(&mut subscription.product_ids),
689 payload,
690 })
691}
692
693fn resubscribe_all(
694 subscriptions: &SubscriptionState,
695 credential: &Option<CoinbaseCredential>,
696 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
697 out_tx: Option<&tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>>,
698) {
699 let topics = subscriptions.all_topics();
700
701 if topics.is_empty() {
702 log::debug!("No active subscriptions to restore");
703 return;
704 }
705
706 log::info!(
707 "Resubscribing to {} topics after reconnection",
708 topics.len()
709 );
710
711 for topic in topics {
712 let (channel, product_id) = match topic.split_once('|') {
713 Some((ch, pid)) => (ch, Some(pid)),
714 None => (topic.as_str(), None),
715 };
716
717 let channel_enum = match CoinbaseWsChannel::from_str(channel) {
718 Ok(ch) => ch,
719 Err(_) => {
720 log::warn!("Unknown channel in topic: {topic}");
721 continue;
722 }
723 };
724
725 let jwt = match credential.as_ref() {
726 Some(c) => match c.build_ws_jwt() {
727 Ok(token) => Some(token),
728 Err(e) => {
729 if channel_enum.requires_auth() {
730 let msg = format!(
731 "JWT required for {channel} but build failed: {e}; topic {topic} not restored"
732 );
733 log::error!("{msg}");
734 if let Some(tx) = out_tx {
735 let _ = tx.send(NautilusWsMessage::Error(msg));
736 }
737 continue;
738 }
739 None
740 }
741 },
742 None => {
743 if channel_enum.requires_auth() {
744 let msg = format!(
745 "JWT required for {channel} but no credentials configured; topic {topic} not restored"
746 );
747 log::error!("{msg}");
748 if let Some(tx) = out_tx {
749 let _ = tx.send(NautilusWsMessage::Error(msg));
750 }
751 continue;
752 }
753 None
754 }
755 };
756
757 let product_ids = match product_id {
758 Some(pid) => vec![Ustr::from(pid)],
759 None => vec![],
760 };
761
762 let sub = CoinbaseWsSubscription {
763 msg_type: CoinbaseWsAction::Subscribe,
764 product_ids,
765 channel: channel_enum,
766 jwt,
767 };
768
769 let sub = match protect_subscription(sub) {
770 Ok(sub) => sub,
771 Err(e) => {
772 log::error!("Failed to serialize subscription for {topic}: {e}");
773 continue;
774 }
775 };
776
777 if let Err(e) = cmd_tx.send(HandlerCommand::Subscribe {
778 channel: sub.channel,
779 product_ids: sub.product_ids,
780 payload: sub.payload,
781 }) {
782 log::error!("Failed to resubscribe {topic}: {e}");
783 }
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use futures_util::StreamExt;
790 use nautilus_network::websocket::SubscriptionState;
791 use nautilus_testkit::connection::race_command_with_publication;
792 use rstest::rstest;
793
794 use super::*;
795
796 #[rstest]
797 #[case::single(false)]
798 #[case::bulk(true)]
799 #[tokio::test]
800 async fn instrument_updates_wait_for_sender_publication(#[case] bulk: bool) {
801 let ws_client =
802 CoinbaseWebSocketClient::new("wss://test", TransportBackend::default(), None);
803 let instrument = InstrumentAny::CryptoPerpetual(
804 nautilus_model::instruments::stubs::crypto_perpetual_ethusdt(),
805 );
806 let mut sender = ws_client.cmd_tx.write().await;
807
808 let mut update = Box::pin(async {
809 if bulk {
810 ws_client
811 .initialize_instruments(vec![instrument.clone()])
812 .await;
813 } else {
814 ws_client.update_instrument(instrument.clone()).await;
815 }
816 });
817
818 assert!(futures_util::poll!(update.as_mut()).is_pending());
819 assert!(ws_client.instruments.load().is_empty());
820 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
821 *sender = cmd_tx;
822 drop(sender);
823 update.await;
824
825 assert_eq!(
826 ws_client.instruments.get_cloned(&instrument.id()),
827 Some(instrument.clone())
828 );
829
830 match (bulk, cmd_rx.try_recv().unwrap()) {
831 (true, HandlerCommand::InitializeInstruments(instruments)) => {
832 assert_eq!(instruments, vec![instrument]);
833 }
834 (false, HandlerCommand::UpdateInstrument(updated)) => assert_eq!(*updated, instrument),
835 (_, command) => panic!("unexpected cache command: {command:?}"),
836 }
837
838 assert!(cmd_rx.try_recv().is_err());
839 }
840
841 #[rstest]
842 #[case::initialize(true)]
843 #[case::update(false)]
844 #[tokio::test]
845 async fn batch_updates_merge_and_keep_command_order(#[case] initialize: bool) {
846 let ws_client =
847 CoinbaseWebSocketClient::new("wss://test", TransportBackend::default(), None);
848 let retained = InstrumentAny::CryptoPerpetual(
849 nautilus_model::instruments::stubs::crypto_perpetual_ethusdt(),
850 );
851
852 let mut first = match retained.clone() {
853 InstrumentAny::CryptoPerpetual(instrument) => instrument,
854 _ => unreachable!(),
855 };
856
857 first.id = "BTCUSDT.COINBASE".into();
858 first.ts_event = 17.into();
859 let mut latest = first.clone();
860 latest.ts_event = 29.into();
861 let batch = vec![
862 InstrumentAny::CryptoPerpetual(first),
863 InstrumentAny::CryptoPerpetual(latest),
864 ];
865 ws_client
866 .initialize_instruments(vec![retained.clone()])
867 .await;
868 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
869 *ws_client.cmd_tx.write().await = sender;
870 if initialize {
871 ws_client.initialize_instruments(batch.clone()).await;
872 } else {
873 ws_client.update_instruments(&batch).await;
874 }
875
876 assert_eq!(ws_client.instruments.len(), 2);
877
878 for expected in [&retained, &batch[1]] {
879 assert_eq!(
880 serde_json::to_value(ws_client.instruments.get_cloned(&expected.id()).unwrap())
881 .unwrap(),
882 serde_json::to_value(expected).unwrap()
883 );
884 }
885
886 if initialize {
887 let HandlerCommand::InitializeInstruments(actual) = receiver.try_recv().unwrap() else {
888 panic!("expected InitializeInstruments");
889 };
890
891 assert_eq!(
892 serde_json::to_value(actual).unwrap(),
893 serde_json::to_value(&batch).unwrap()
894 );
895 } else {
896 for expected in &batch {
897 let HandlerCommand::UpdateInstrument(actual) = receiver.try_recv().unwrap() else {
898 panic!("expected UpdateInstrument");
899 };
900
901 assert_eq!(
902 serde_json::to_value(actual).unwrap(),
903 serde_json::to_value(expected).unwrap()
904 );
905 }
906 }
907
908 assert!(receiver.try_recv().is_err());
909 let before = ws_client.instruments.load();
910 ws_client.update_instruments(&[]).await;
911 assert!(Arc::ptr_eq(&before, &ws_client.instruments.load()));
912 assert!(receiver.try_recv().is_err());
913 }
914
915 #[tokio::test]
916 async fn command_during_sender_publication_reaches_initialized_handler() {
917 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
918 let addr = listener.local_addr().unwrap();
919 let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
920
921 let server = tokio::spawn(async move {
922 let (socket, _) = listener.accept().await.unwrap();
923 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
924 while let Some(Ok(message)) = socket.next().await {
925 if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
926 let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
927 let _ = received_tx.send(payload);
928 }
929 }
930 });
931
932 let url = format!("ws://{addr}");
933 let mut ws_client = CoinbaseWebSocketClient::new(&url, TransportBackend::default(), None);
934 let observer = ws_client.clone();
935 let expected = serde_json::json!({
936 "type": "subscribe",
937 "channel": "market_trades",
938 "product_ids": [
939 "BTC-USD"
940 ]
941 });
942
943 let command = HandlerCommand::Subscribe {
944 channel: CoinbaseWsChannel::MarketTrades,
945 product_ids: vec![Ustr::from("BTC-USD")],
946 payload: SecretString::from(expected.to_string()),
947 };
948
949 race_command_with_publication(
950 Arc::clone(&ws_client.cmd_tx),
951 ws_client.connect(),
952 move || command,
953 || observer.is_active(),
954 )
955 .await
956 .expect("connect failed");
957
958 let received = tokio::time::timeout(Duration::from_secs(2), async {
959 let heartbeat = received_rx.recv().await.unwrap();
960 let command = received_rx.recv().await.unwrap();
961 (heartbeat, command)
962 })
963 .await;
964
965 ws_client.disconnect().await.expect("disconnect failed");
966 server.abort();
967
968 let (heartbeat, payload) = received.expect("startup command must reach server");
969 assert_eq!(
970 heartbeat,
971 serde_json::json!({
972 "type": "subscribe",
973 "channel": "heartbeats"
974 })
975 );
976 assert_eq!(payload, expected);
977 }
978
979 #[rstest]
980 #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
981 #[case(
982 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
983 "wss://stream.example.com:9443"
984 )]
985 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
986 let client = CoinbaseWebSocketClient::new(url, TransportBackend::default(), None);
987 let debug = format!("{client:?}");
988 assert!(debug.contains(&format!("url: {origin:?}")));
989 assert!(!debug.contains("SYN-"));
990 assert_eq!(client.url.expose(), url);
991 }
992
993 #[rstest]
994 fn test_debug_redacts_proxy_url() {
995 let proxy_url = "http://user:password@proxy.example:8080";
996 let client = CoinbaseWebSocketClient::new(
997 "wss://test",
998 TransportBackend::default(),
999 Some(proxy_url.to_string()),
1000 );
1001
1002 let debug = format!("{client:?}");
1003
1004 assert!(debug.contains(REDACTED));
1005 assert!(!debug.contains(proxy_url));
1006 }
1007
1008 #[rstest]
1009 fn test_drop_clone_does_not_signal_handler() {
1010 let client = CoinbaseWebSocketClient::new("wss://test", TransportBackend::default(), None);
1011 let clone = client.clone();
1012
1013 drop(clone);
1014
1015 assert!(!client.signal.load(Ordering::Acquire));
1016 }
1017
1018 #[rstest]
1019 fn test_resubscribe_all_product_level_topic() {
1020 let subs = SubscriptionState::new('|');
1021 subs.mark_subscribe("level2|BTC-USD");
1022
1023 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1024 resubscribe_all(&subs, &None, &tx, None);
1025
1026 let cmd = rx.try_recv().unwrap();
1027
1028 match cmd {
1029 HandlerCommand::Subscribe {
1030 channel,
1031 product_ids,
1032 payload,
1033 } => {
1034 let payload: serde_json::Value =
1035 serde_json::from_str(payload.expose_secret()).unwrap();
1036 assert_eq!(channel, CoinbaseWsChannel::Level2);
1037 assert_eq!(product_ids, vec![Ustr::from("BTC-USD")]);
1038 assert!(payload.get("jwt").is_none());
1039 }
1040 other => panic!("Expected Subscribe, was {other:?}"),
1041 }
1042 }
1043
1044 #[rstest]
1045 fn test_resubscribe_all_channel_level_topic() {
1046 let subs = SubscriptionState::new('|');
1047 subs.mark_subscribe("heartbeats");
1048
1049 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1050 resubscribe_all(&subs, &None, &tx, None);
1051
1052 let cmd = rx.try_recv().unwrap();
1053
1054 match cmd {
1055 HandlerCommand::Subscribe {
1056 channel,
1057 product_ids,
1058 ..
1059 } => {
1060 assert_eq!(channel, CoinbaseWsChannel::Heartbeats);
1061 assert!(product_ids.is_empty());
1062 }
1063 other => panic!("Expected Subscribe, was {other:?}"),
1064 }
1065 }
1066
1067 #[rstest]
1068 fn test_resubscribe_all_multiple_topics() {
1069 let subs = SubscriptionState::new('|');
1070 subs.mark_subscribe("market_trades|BTC-USD");
1071 subs.mark_subscribe("ticker|ETH-USD");
1072
1073 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1074 resubscribe_all(&subs, &None, &tx, None);
1075
1076 let cmd1 = rx.try_recv().unwrap();
1077 let cmd2 = rx.try_recv().unwrap();
1078
1079 assert!(matches!(cmd1, HandlerCommand::Subscribe { .. }));
1080 assert!(matches!(cmd2, HandlerCommand::Subscribe { .. }));
1081 assert!(rx.try_recv().is_err());
1082 }
1083
1084 #[rstest]
1085 fn test_resubscribe_all_empty_subscriptions() {
1086 let subs = SubscriptionState::new('|');
1087
1088 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1089 resubscribe_all(&subs, &None, &tx, None);
1090
1091 assert!(rx.try_recv().is_err());
1092 }
1093
1094 #[rstest]
1095 fn test_resubscribe_all_unknown_channel_skipped() {
1096 let subs = SubscriptionState::new('|');
1097 subs.mark_subscribe("nonexistent_channel|BTC-USD");
1098
1099 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1100 resubscribe_all(&subs, &None, &tx, None);
1101
1102 assert!(rx.try_recv().is_err());
1103 }
1104
1105 #[rstest]
1106 #[case("level2|BTC-USD", CoinbaseWsChannel::Level2)]
1107 #[case("market_trades|ETH-USD", CoinbaseWsChannel::MarketTrades)]
1108 #[case("ticker|BTC-USD", CoinbaseWsChannel::Ticker)]
1109 #[case("ticker_batch|BTC-USD", CoinbaseWsChannel::TickerBatch)]
1110 #[case("candles|BTC-USD", CoinbaseWsChannel::Candles)]
1111 #[case("heartbeats", CoinbaseWsChannel::Heartbeats)]
1112 #[case("status", CoinbaseWsChannel::Status)]
1113 fn test_resubscribe_all_channel_mapping(
1114 #[case] topic: &str,
1115 #[case] expected_channel: CoinbaseWsChannel,
1116 ) {
1117 let subs = SubscriptionState::new('|');
1118 subs.mark_subscribe(topic);
1119
1120 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1121 resubscribe_all(&subs, &None, &tx, None);
1122
1123 let cmd = rx.try_recv().unwrap();
1124
1125 match cmd {
1126 HandlerCommand::Subscribe { channel, .. } => {
1127 assert_eq!(channel, expected_channel);
1128 }
1129 other => panic!("Expected Subscribe, was {other:?}"),
1130 }
1131 }
1132
1133 #[rstest]
1134 fn test_protected_subscription_preserves_wire_jwt_and_redacts_command_debug() {
1135 let jwt = "jwt-secret-value";
1136 let subscription = CoinbaseWsSubscription {
1137 msg_type: CoinbaseWsAction::Subscribe,
1138 product_ids: vec![Ustr::from("BTC-USD")],
1139 channel: CoinbaseWsChannel::User,
1140 jwt: Some(SecretString::from(jwt)),
1141 };
1142 let subscription_debug = format!("{subscription:?}");
1143 let protected = protect_subscription(subscription).unwrap();
1144 let payload: serde_json::Value =
1145 serde_json::from_str(protected.payload.expose_secret()).unwrap();
1146 let command = HandlerCommand::Subscribe {
1147 channel: protected.channel,
1148 product_ids: protected.product_ids,
1149 payload: protected.payload,
1150 };
1151 let debug = format!("{command:?}");
1152
1153 assert_eq!(payload["jwt"], jwt);
1154 assert!(subscription_debug.contains(REDACTED));
1155 assert!(!subscription_debug.contains(jwt));
1156 assert!(!debug.contains(jwt));
1157 }
1158
1159 #[rstest]
1160 #[case("user|BTC-USD")]
1161 #[case("futures_balance_summary")]
1162 fn test_resubscribe_all_auth_channel_skipped_without_credentials(#[case] topic: &str) {
1163 let subs = SubscriptionState::new('|');
1164 subs.mark_subscribe(topic);
1165
1166 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1167 resubscribe_all(&subs, &None, &tx, None);
1168
1169 assert!(rx.try_recv().is_err());
1171 }
1172
1173 #[rstest]
1174 #[case("user|BTC-USD", "user")]
1175 #[case("futures_balance_summary", "futures_balance_summary")]
1176 fn test_resubscribe_all_emits_error_for_auth_channel_without_credentials(
1177 #[case] topic: &str,
1178 #[case] channel: &str,
1179 ) {
1180 let subs = SubscriptionState::new('|');
1181 subs.mark_subscribe(topic);
1182
1183 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1184 let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel();
1185 resubscribe_all(&subs, &None, &cmd_tx, Some(&out_tx));
1186
1187 assert!(cmd_rx.try_recv().is_err());
1189
1190 let msg = out_rx
1191 .try_recv()
1192 .expect("Error event must be emitted when auth channel cannot resubscribe");
1193 match msg {
1194 NautilusWsMessage::Error(text) => {
1195 assert!(
1196 text.contains(channel),
1197 "error must mention the channel, was: {text}"
1198 );
1199 assert!(
1200 text.contains(topic),
1201 "error must mention the topic, was: {text}"
1202 );
1203 }
1204 other => panic!("expected Error variant, was {other:?}"),
1205 }
1206 }
1207
1208 #[rstest]
1209 fn test_resubscribe_all_emits_error_when_jwt_build_fails() {
1210 let subs = SubscriptionState::new('|');
1211 let topic = "user|BTC-USD";
1212 subs.mark_subscribe(topic);
1213
1214 let bad_credential = Some(CoinbaseCredential::new(
1217 "organizations/test/apiKeys/test".to_string(),
1218 "not-a-pem-key".to_string(),
1219 ));
1220
1221 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1222 let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel();
1223 resubscribe_all(&subs, &bad_credential, &cmd_tx, Some(&out_tx));
1224
1225 assert!(cmd_rx.try_recv().is_err(), "no subscribe should be sent");
1226 let msg = out_rx
1227 .try_recv()
1228 .expect("Error event must be emitted when JWT build fails for an auth channel");
1229 match msg {
1230 NautilusWsMessage::Error(text) => {
1231 assert!(text.contains("user"), "error must mention channel: {text}");
1232 assert!(text.contains(topic), "error must mention topic: {text}");
1233 }
1234 other => panic!("expected Error variant, was {other:?}"),
1235 }
1236 }
1237
1238 #[rstest]
1239 fn test_prime_default_subscriptions_marks_heartbeats() {
1240 let client = CoinbaseWebSocketClient::new("wss://test", TransportBackend::default(), None);
1241 assert!(client.subscriptions.all_topics().is_empty());
1242
1243 client.prime_default_subscriptions();
1244
1245 let topics = client.subscriptions.all_topics();
1246 assert!(topics.iter().any(|t| t == "heartbeats"), "{topics:?}");
1247 }
1248
1249 #[rstest]
1250 fn test_ws_quotas_match_documented_limits() {
1251 assert_eq!(COINBASE_WS_CONNECTION_QUOTA.burst_size().get(), 8);
1252 assert_eq!(COINBASE_WS_SUBSCRIPTION_QUOTA.burst_size().get(), 8);
1253 }
1254
1255 #[rstest]
1256 fn test_ws_subscription_rate_limit_key_is_stable() {
1257 assert_eq!(COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION, "subscription");
1258 assert_eq!(
1259 COINBASE_WS_SUBSCRIPTION_KEYS[0],
1260 COINBASE_RATE_LIMIT_KEY_SUBSCRIPTION,
1261 );
1262 }
1263}