Skip to main content

nautilus_coinapi/websocket/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! CoinAPI WebSocket V1 connection with whole-scope replacement and replay.
17//!
18//! CoinAPI has no subscription acknowledgements. The connection authenticates through the
19//! `X-CoinAPI-Key` upgrade header, and the server validates it when the first `hello` arrives.
20//! A heartbeat or accepted data frame confirms the session and its subscriptions; an `error` frame
21//! is terminal and retires the connection instead of reconnecting.
22//!
23//! A server `reconnect` notice is only logged. Reconnecting early would open a second connection
24//! while the old one is open, which a one-connection tier rejects; the server's close triggers the
25//! normal reconnect instead.
26
27use std::{
28    fmt::Debug,
29    sync::{
30        Arc, Weak,
31        atomic::{AtomicBool, AtomicU64, Ordering},
32    },
33};
34
35use nautilus_core::string::secret::SecretString;
36use nautilus_live::book::snapshot::SnapshotGate;
37use nautilus_network::{
38    Message, RECONNECTED, dst,
39    websocket::{
40        AuthTracker, MessageHandler, SubscriptionState, TransportBackend, WebSocketClient,
41        WebSocketConfig, proxy::ProxyTarget, split_topic,
42    },
43};
44use parking_lot::Mutex;
45use url::Url;
46use ustr::Ustr;
47
48use super::{
49    error::Error,
50    messages::{Channel, CoinApiWsMessage, DataType, Frame, Hello, ScopeChange, Subscription},
51};
52use crate::{
53    book::sync::{BookReceipts, BookSubscriptions, BookWrite, BookWrites},
54    common::{consts::COINAPI_NAUTILUS_REFERRER, credential::is_valid_api_key},
55};
56
57/// Primary CoinAPI Market Data WebSocket V1 endpoint.
58pub const WS_URL: &str = "wss://ws.coinapi.io/v1/";
59
60// The server sends a heartbeat after each second of silence
61const IDLE_TIMEOUT_MS: u64 = 5_000;
62const RECONNECT_DELAY_INITIAL_MS: u64 = 1_000;
63const RECONNECT_DELAY_MAX_MS: u64 = 30_000;
64const RECONNECT_BACKOFF_FACTOR: f64 = 2.0;
65const RECONNECT_JITTER_MS: u64 = 500;
66const TOPIC_DELIMITER: char = ':';
67const PERIOD_DELIMITER: char = '.';
68
69// Live probes found that back-to-back hellos can leave a connection streaming nothing, and that
70// CoinAPI closes a connection once hellos exceed about one every two seconds per IP
71const HELLO_INTERVAL_MIN: dst::time::Duration = dst::time::Duration::from_secs(2);
72const RESTART_INTERVAL_MIN: dst::time::Duration = dst::time::Duration::from_secs(5);
73
74/// Transport configuration for the raw WebSocket client.
75#[derive(Debug, Clone)]
76pub struct CoinApiWebSocketConfig {
77    /// Endpoint URL; accepts local simulated peers.
78    pub url: String,
79    /// Transport implementation; simulated peers require Tungstenite.
80    pub backend: TransportBackend,
81    /// Optional explicit proxy URL for the WebSocket transport.
82    pub proxy_url: Option<SecretString>,
83}
84
85impl Default for CoinApiWebSocketConfig {
86    fn default() -> Self {
87        Self {
88            url: WS_URL.into(),
89            backend: TransportBackend::default(),
90            proxy_url: None,
91        }
92    }
93}
94
95/// Authenticated, data-only CoinAPI WebSocket client using one connection.
96///
97/// CoinAPI tiers can limit an organization to one concurrent connection. Subscription changes send
98/// a `hello` that replaces the whole session scope; changes applied together share one `hello`,
99/// and the client sends at most one every two seconds, including across connection recreation.
100/// Because CoinAPI combines every listed data type, symbol, and period, the client drops frames
101/// outside the subscribed pairs; the provider still bills for them. A scope can list only one
102/// book type. Removing the last subscription closes the connection, and the next subscription
103/// opens a new one. Reconnects replay the scope and emit [`CoinApiWsMessage::Reconnected`].
104#[derive(Debug)]
105pub struct CoinApiWebSocketClient {
106    api_key: SecretString,
107    config: CoinApiWebSocketConfig,
108    connection: Arc<tokio::sync::Mutex<Option<Connection>>>,
109    hello_at: Arc<Mutex<Option<dst::time::Instant>>>,
110    next_connection_id: AtomicU64,
111    out_tx: StreamSender,
112    books: Option<Arc<BookSubscriptions>>,
113}
114
115impl CoinApiWebSocketClient {
116    /// Creates a client and the receiver for its messages without connecting.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the credentials, endpoint, or proxy URL are invalid.
121    pub fn new(
122        api_key: SecretString,
123        config: CoinApiWebSocketConfig,
124    ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver<CoinApiWsMessage>), Error> {
125        if !is_valid_api_key(api_key.expose_secret()) {
126            return Err(Error::Validation(
127                "API key must contain visible ASCII characters".into(),
128            ));
129        }
130
131        let url = Url::parse(&config.url).map_err(|_| Error::Validation("invalid URL".into()))?;
132
133        if !matches!(url.scheme(), "ws" | "wss")
134            || url.host_str().is_none()
135            || !url.username().is_empty()
136            || url.password().is_some()
137            || url.query().is_some()
138            || url.fragment().is_some()
139        {
140            return Err(Error::Validation(
141                "URL must be WS(S) without credentials, query, or fragment".into(),
142            ));
143        }
144
145        if let Some(proxy) = &config.proxy_url {
146            ProxyTarget::parse(proxy.expose_secret())
147                .map_err(|_| Error::Validation("proxy URL must be a valid HTTP(S) URL".into()))?;
148        }
149
150        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
151
152        let client = Self {
153            api_key,
154            config,
155            connection: Arc::new(tokio::sync::Mutex::new(None)),
156            hello_at: Arc::new(Mutex::new(None)),
157            next_connection_id: AtomicU64::new(0),
158            out_tx: StreamSender::Public(out_tx),
159            books: None,
160        };
161
162        Ok((client, out_rx))
163    }
164
165    /// Adds a subscription, connecting on first use.
166    ///
167    /// Returns once the new scope is queued for sending; the first data frame confirms it.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if validation, connecting, or sending fails. A book type other than the one
172    /// already subscribed is a validation error. A failed send leaves the pair unsubscribed.
173    pub async fn subscribe(&self, subscription: Subscription) -> Result<(), Error> {
174        self.apply_one(ScopeChange::Subscribe(subscription)).await
175    }
176
177    /// Removes a subscription.
178    ///
179    /// CoinAPI does not acknowledge the replacement scope, so frames still in flight for the
180    /// pair are dropped locally.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if validation or sending fails. A failed send keeps the pair subscribed.
185    pub async fn unsubscribe(&self, subscription: Subscription) -> Result<(), Error> {
186        self.apply_one(ScopeChange::Unsubscribe(subscription)).await
187    }
188
189    /// Applies scope changes in order and sends one `hello` for the resulting scope.
190    ///
191    /// Connects when a subscribe finds no connection, and closes the connection when the scope
192    /// becomes empty, since an empty `hello` is a terminal protocol error. The `hello` waits until
193    /// two seconds after the client's previous one. Returns once the scope is queued for
194    /// sending, with each change rejected by validation, including a book type other than the
195    /// one already subscribed.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if connecting or sending fails; every change in the batch is reverted.
200    pub async fn apply(&self, changes: &[ScopeChange]) -> Result<Vec<(ScopeChange, Error)>, Error> {
201        self.apply_registered(changes, &[], &[]).await
202    }
203
204    pub(crate) fn bind_books(
205        &mut self,
206        books: Arc<BookSubscriptions>,
207    ) -> tokio::sync::mpsc::UnboundedReceiver<StreamMessage> {
208        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
209        self.books = Some(books);
210        self.out_tx = StreamSender::Bound(sender);
211        receiver
212    }
213
214    pub(crate) async fn apply_registered(
215        &self,
216        changes: &[ScopeChange],
217        registrations: &[Option<Arc<AtomicBool>>],
218        gates: &[Option<SnapshotGate>],
219    ) -> Result<Vec<(ScopeChange, Error)>, Error> {
220        let mut rejected = Vec::new();
221        let mut valid = Vec::with_capacity(changes.len());
222
223        for (index, change) in changes.iter().enumerate() {
224            match change.subscription().validate() {
225                Ok(()) => valid.push((
226                    *change,
227                    registrations.get(index).cloned().flatten(),
228                    gates.get(index).cloned().flatten(),
229                )),
230                Err(e) => rejected.push((*change, e)),
231            }
232        }
233
234        let mut guard = self.connection.lock().await;
235        let fresh = guard.is_none();
236
237        if fresh
238            && !valid
239                .iter()
240                .any(|(change, _, _)| matches!(change, ScopeChange::Subscribe(_)))
241        {
242            return Ok(rejected);
243        }
244
245        if fresh {
246            let connection = self.connect().await?;
247            connection.auth.begin();
248            *guard = Some(connection);
249        }
250
251        let Some(connection) = guard.as_mut() else {
252            return Ok(rejected);
253        };
254
255        let result = connection.apply_scope(&valid, &mut rejected).await;
256        if (matches!(result, Ok(false)) || (fresh && result.is_err()))
257            && let Some(connection) = guard.take()
258        {
259            connection.close().await;
260        }
261
262        result.map(|_| rejected)
263    }
264
265    async fn apply_one(&self, change: ScopeChange) -> Result<(), Error> {
266        match self.apply(&[change]).await?.pop() {
267            Some((_, e)) => Err(e),
268            None => Ok(()),
269        }
270    }
271
272    /// Restarts a subscribed book pair's stream so CoinAPI sends it a fresh snapshot.
273    ///
274    /// CoinAPI replays nothing for a repeated scope, but restarts a pair's sequence with a
275    /// snapshot when the pair leaves the scope and returns. The client sends the scope without the
276    /// pair's symbol, then the full scope two seconds later, so other symbols keep streaming. When
277    /// no other symbol is subscribed, the removal scope keeps the symbol's other channels instead,
278    /// or lists only its trades when the book is the whole scope; the client drops those
279    /// unsubscribed trades. The connection stays open, since a one-connection tier rejects a second
280    /// one.
281    ///
282    /// Restarts on a connection start at least five seconds apart, because CoinAPI closes a
283    /// connection that exceeds its per-IP `hello` rate. Subscription changes wait while a restart
284    /// is pending. Calls `paused` as soon as a scope without the pair's symbol is queued, since
285    /// that pauses the symbol's other channels; the call happens even if the restart is then
286    /// cancelled. Returns once both scopes are queued for sending.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the pair is not a subscribed book pair or sending fails.
291    pub async fn resubscribe(
292        &self,
293        subscription: Subscription,
294        paused: impl FnOnce(),
295    ) -> Result<(), Error> {
296        self.resubscribe_inner(subscription, paused, None)
297            .await
298            .map(|_| ())
299    }
300
301    pub(crate) async fn resubscribe_book(
302        &self,
303        subscription: Subscription,
304        paused: impl FnOnce(),
305        gate: SnapshotGate,
306    ) -> Result<Option<BookWrites>, Error> {
307        self.resubscribe_inner(subscription, paused, Some(gate))
308            .await
309    }
310
311    async fn resubscribe_inner(
312        &self,
313        subscription: Subscription,
314        paused: impl FnOnce(),
315        gate: Option<SnapshotGate>,
316    ) -> Result<Option<BookWrites>, Error> {
317        subscription.validate()?;
318
319        if !subscription.channel.data_type.is_book() {
320            return Err(Error::Validation(format!(
321                "{} has no snapshot to restart",
322                topic(&subscription)
323            )));
324        }
325
326        let mut guard = self.connection.lock().await;
327
328        let Some(connection) = guard
329            .as_mut()
330            .filter(|connection| is_subscribed(&connection.subscriptions, &subscription))
331        else {
332            return Err(Error::Validation(format!(
333                "{} is not subscribed",
334                topic(&subscription)
335            )));
336        };
337
338        if let Some(restarted_at) = connection.restarted_at {
339            dst::time::sleep_until(restarted_at + RESTART_INTERVAL_MIN).await;
340        }
341
342        let scope = scope(&connection.subscriptions);
343        let removal = removal_scope(&scope, &subscription);
344        let pauses = removal
345            .iter()
346            .all(|other| other.symbol_id != subscription.symbol_id);
347
348        let removed = send_paced(connection, &removal).await;
349        connection.restarted_at = *connection.hello_at.lock();
350        removed?;
351
352        if pauses {
353            paused();
354        }
355
356        let snapshots = gate
357            .map(|gate| (subscription, None, gate))
358            .into_iter()
359            .collect::<Vec<_>>();
360        send_paced_books(connection, &scope, &snapshots).await
361    }
362
363    /// Closes the connection and forgets its subscriptions; repeated calls are safe.
364    ///
365    /// Dropping the client also closes the connection.
366    pub async fn close(&self) {
367        let mut guard = self.connection.lock().await;
368        if let Some(connection) = guard.take() {
369            connection.close().await;
370        }
371    }
372
373    async fn connect(&self) -> Result<Connection, Error> {
374        let config = WebSocketConfig::builder()
375            .url(self.config.url.clone())
376            .headers(vec![
377                (
378                    "X-CoinAPI-Key".into(),
379                    self.api_key.expose_secret().to_owned(),
380                ),
381                ("X-Referrer".into(), COINAPI_NAUTILUS_REFERRER.into()),
382            ])
383            .idle_timeout_ms(IDLE_TIMEOUT_MS)
384            .reconnect_delay_initial_ms(RECONNECT_DELAY_INITIAL_MS)
385            .reconnect_delay_max_ms(RECONNECT_DELAY_MAX_MS)
386            .reconnect_backoff_factor(RECONNECT_BACKOFF_FACTOR)
387            .reconnect_jitter_ms(RECONNECT_JITTER_MS)
388            .backend(self.config.backend)
389            .maybe_proxy_url(
390                self.config
391                    .proxy_url
392                    .as_ref()
393                    .map(|url| url.expose_secret().to_owned()),
394            )
395            .build()
396            .map_err(|e| Error::Validation(e.to_string()))?;
397
398        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
399        let books = self.books.clone();
400        let handler: MessageHandler = Arc::new(move |message| {
401            let receipts = books.as_ref().map(|books| books.sync.receipts());
402            let _ = raw_tx.send((message, receipts));
403        });
404
405        let client = WebSocketClient::builder()
406            .config(config)
407            .message_handler(handler)
408            .connect()
409            .await?;
410
411        let auth = AuthTracker::new();
412        client.set_auth_tracker(auth.clone(), false);
413        let client = Arc::new(client);
414        let subscriptions = SubscriptionState::new(TOPIC_DELIMITER);
415        let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed);
416
417        let handler = ConnectionHandler {
418            id,
419            api_key: self.api_key.clone(),
420            client: Arc::clone(&client),
421            connection: Arc::downgrade(&self.connection),
422            auth: auth.clone(),
423            subscriptions: subscriptions.clone(),
424            out_tx: self.out_tx.clone(),
425            books: self.books.clone(),
426        };
427
428        let task = dst::task::spawn(handler.run(raw_rx));
429
430        Ok(Connection {
431            id,
432            client,
433            auth,
434            subscriptions,
435            hello_at: Arc::clone(&self.hello_at),
436            restarted_at: None,
437            task,
438            books: self.books.clone(),
439        })
440    }
441}
442
443type RegisteredChange = (ScopeChange, Option<Arc<AtomicBool>>, Option<SnapshotGate>);
444
445struct Connection {
446    id: u64,
447    client: Arc<WebSocketClient>,
448    auth: AuthTracker,
449    subscriptions: SubscriptionState,
450    hello_at: Arc<Mutex<Option<dst::time::Instant>>>,
451    restarted_at: Option<dst::time::Instant>,
452    task: dst::task::JoinHandle<()>,
453    books: Option<Arc<BookSubscriptions>>,
454}
455
456impl Debug for Connection {
457    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458        f.debug_struct(stringify!(Connection))
459            .field("id", &self.id)
460            .field("auth", &self.auth.auth_state())
461            .field("subscriptions", &self.subscriptions)
462            .finish_non_exhaustive()
463    }
464}
465
466impl Connection {
467    async fn apply_scope(
468        &self,
469        changes: &[RegisteredChange],
470        rejected: &mut Vec<(ScopeChange, Error)>,
471    ) -> Result<bool, Error> {
472        let applied = self.change_scope(changes, rejected);
473        let scope = scope(&self.subscriptions);
474
475        if scope.is_empty() {
476            return Ok(false);
477        }
478
479        if applied.is_empty() {
480            return Ok(true);
481        }
482
483        let snapshots = changes
484            .iter()
485            .filter_map(|(change, registration, gate)| {
486                if let ScopeChange::Subscribe(subscription) = change
487                    && applied.contains(change)
488                    && let Some(gate) = gate
489                {
490                    Some((*subscription, registration.clone(), gate.clone()))
491                } else {
492                    None
493                }
494            })
495            .collect::<Vec<_>>();
496
497        let writes = match send_paced_books(self, &scope, &snapshots).await {
498            Ok(writes) => writes,
499            Err(e) => {
500                revert(&self.subscriptions, &applied);
501                return Err(e);
502            }
503        };
504
505        self.commit_scope(changes, &applied);
506
507        if let Some(writes) = writes {
508            writes.commit();
509        }
510
511        Ok(true)
512    }
513
514    fn commit_scope(&self, changes: &[RegisteredChange], applied: &[ScopeChange]) {
515        // Retirement takes this same connection lock, so it observes every committed registration
516        for (change, registration, _) in changes {
517            if matches!(change, ScopeChange::Subscribe(_))
518                && applied.contains(change)
519                && let Some(registration) = registration
520            {
521                registration.store(true, Ordering::Relaxed);
522            }
523        }
524
525        for change in applied {
526            if let ScopeChange::Unsubscribe(subscription) = change {
527                self.subscriptions.confirm_unsubscribe(&topic(subscription));
528            }
529        }
530    }
531
532    fn change_scope(
533        &self,
534        changes: &[RegisteredChange],
535        rejected: &mut Vec<(ScopeChange, Error)>,
536    ) -> Vec<ScopeChange> {
537        let mut applied = Vec::with_capacity(changes.len());
538
539        for (change, _, _) in changes {
540            let change = *change;
541            let topic = topic(change.subscription());
542
543            match change {
544                ScopeChange::Subscribe(subscription) => {
545                    let scope = scope(&self.subscriptions);
546
547                    if let Err(e) = check_book_type(&scope, &subscription) {
548                        rejected.push((change, e));
549                    } else if self.subscriptions.try_mark_subscribe(&topic) {
550                        applied.push(change);
551                    }
552                }
553                ScopeChange::Unsubscribe(subscription) => {
554                    if is_subscribed(&self.subscriptions, &subscription) {
555                        self.subscriptions.mark_unsubscribe(&topic);
556                        applied.push(change);
557                    }
558                }
559            }
560        }
561
562        applied
563    }
564
565    async fn close(self) {
566        self.task.abort();
567        self.client.disconnect().await;
568    }
569}
570
571impl Drop for Connection {
572    fn drop(&mut self) {
573        self.task.abort();
574    }
575}
576
577#[derive(Debug)]
578pub(crate) struct StreamMessage {
579    pub(crate) message: CoinApiWsMessage,
580    pub(crate) write: Option<Arc<BookWrite>>,
581}
582
583#[derive(Debug, Clone)]
584enum StreamSender {
585    Public(tokio::sync::mpsc::UnboundedSender<CoinApiWsMessage>),
586    Bound(tokio::sync::mpsc::UnboundedSender<StreamMessage>),
587}
588
589impl StreamSender {
590    fn send(&self, message: CoinApiWsMessage, receipts: Option<&BookReceipts>) {
591        match self {
592            Self::Public(sender) => {
593                let _ = sender.send(message);
594            }
595            Self::Bound(sender) => {
596                let write = message.subscription().and_then(|subscription| {
597                    receipts.and_then(|receipts| receipts.get(&subscription).cloned())
598                });
599
600                let _ = sender.send(StreamMessage { message, write });
601            }
602        }
603    }
604}
605
606// Holds the slot weakly so dropping the public client drops the connection and aborts this task
607struct ConnectionHandler {
608    id: u64,
609    api_key: SecretString,
610    client: Arc<WebSocketClient>,
611    connection: Weak<tokio::sync::Mutex<Option<Connection>>>,
612    auth: AuthTracker,
613    subscriptions: SubscriptionState,
614    out_tx: StreamSender,
615    books: Option<Arc<BookSubscriptions>>,
616}
617
618impl ConnectionHandler {
619    async fn run(
620        self,
621        mut raw_rx: tokio::sync::mpsc::UnboundedReceiver<(Message, Option<BookReceipts>)>,
622    ) {
623        while let Some((message, receipts)) = raw_rx.recv().await {
624            let Message::Text(bytes) = message else {
625                continue;
626            };
627
628            if bytes.as_ref() == RECONNECTED.as_bytes() {
629                self.replay().await;
630                continue;
631            }
632
633            match Frame::decode(&bytes) {
634                Ok(Frame::Data(message)) => self.route(message, receipts.as_ref()),
635                Ok(Frame::Heartbeat) => self.confirm_session(),
636                Ok(Frame::Reconnect(notice)) => {
637                    log::info!(
638                        "CoinAPI will close the connection before {}",
639                        notice.before_time
640                    );
641                }
642                Ok(Frame::Error(error)) => {
643                    let message = error
644                        .message
645                        .replace(self.api_key.expose_secret(), "<redacted>");
646                    self.retire(message).await;
647                    return;
648                }
649                Ok(Frame::Unknown(_)) => log::debug!("Ignoring unknown CoinAPI frame type"),
650                Err(e) => {
651                    log::warn!(
652                        "Failed to decode CoinAPI frame: category={:?}, line={}, column={}, bytes={}",
653                        e.classify(),
654                        e.line(),
655                        e.column(),
656                        bytes.len()
657                    );
658                }
659            }
660        }
661    }
662
663    fn route(&self, message: CoinApiWsMessage, receipts: Option<&BookReceipts>) {
664        let Some(subscription) = message.subscription() else {
665            return;
666        };
667
668        // The combined scope also delivers pairs nobody subscribed to
669        if !is_subscribed(&self.subscriptions, &subscription) {
670            log::trace!("Dropping unsubscribed CoinAPI frame");
671            return;
672        }
673
674        self.subscriptions.confirm_subscribe(&topic(&subscription));
675        self.confirm_session();
676        self.out_tx.send(message, receipts);
677    }
678
679    fn confirm_session(&self) {
680        if !self.auth.is_authenticated() {
681            self.auth.succeed();
682        }
683    }
684
685    async fn replay(&self) {
686        let Some(connection) = self.connection.upgrade() else {
687            return;
688        };
689
690        let mut guard = connection.lock().await;
691
692        let Some(connection) = guard.as_mut().filter(|connection| connection.id == self.id) else {
693            return;
694        };
695
696        self.subscriptions.reset_after_reconnect();
697        self.auth.begin();
698
699        // A change in flight can reach the replacement socket first, so the replay waits its turn
700        match send_paced(connection, &scope(&self.subscriptions)).await {
701            Ok(()) => {
702                self.out_tx.send(CoinApiWsMessage::Reconnected, None);
703            }
704            Err(e) => log::error!("Failed to replay CoinAPI scope: {e}"),
705        }
706    }
707
708    async fn retire(&self, message: String) {
709        log::error!("CoinAPI rejected the session: {message}");
710        self.auth.fail(message.clone());
711
712        let Some(connection) = self.connection.upgrade() else {
713            return;
714        };
715
716        let mut guard = connection.lock().await;
717        self.client.disconnect().await;
718
719        if guard
720            .as_ref()
721            .is_some_and(|connection| connection.id == self.id)
722            && let Some(books) = &self.books
723        {
724            books.retire();
725        }
726
727        self.out_tx.send(CoinApiWsMessage::Error(message), None);
728
729        // Dropping the owned connection aborts this task, so nothing may await after it
730        if guard
731            .as_ref()
732            .is_some_and(|connection| connection.id == self.id)
733        {
734            guard.take();
735        }
736    }
737}
738
739// Waits out the client's `hello` interval, then sends `scope`
740async fn send_paced(connection: &Connection, scope: &[Subscription]) -> Result<(), Error> {
741    send_paced_books(connection, scope, &[]).await.map(|_| ())
742}
743
744async fn send_paced_books(
745    connection: &Connection,
746    scope: &[Subscription],
747    snapshots: &[(Subscription, Option<Arc<AtomicBool>>, SnapshotGate)],
748) -> Result<Option<BookWrites>, Error> {
749    let hello_at = *connection.hello_at.lock();
750    if let Some(hello_at) = hello_at {
751        dst::time::sleep_until(hello_at + HELLO_INTERVAL_MIN).await;
752    }
753
754    // A caller cancelled during the write leaves the queued hello to the writer, so the interval
755    // starts before the write as well as after it
756    let writes = connection
757        .books
758        .as_ref()
759        .map(|books| books.begin_writes(snapshots));
760    *connection.hello_at.lock() = Some(dst::time::Instant::now());
761    let result = send_scope(&connection.client, scope).await;
762    *connection.hello_at.lock() = Some(dst::time::Instant::now());
763
764    result?;
765    Ok(writes)
766}
767
768// Restores the subscription state from before a batch whose `hello` failed
769fn revert(subscriptions: &SubscriptionState, applied: &[ScopeChange]) {
770    for change in applied.iter().rev() {
771        let topic = topic(change.subscription());
772
773        match change {
774            ScopeChange::Subscribe(_) => {
775                subscriptions.mark_unsubscribe(&topic);
776                subscriptions.confirm_unsubscribe(&topic);
777            }
778            ScopeChange::Unsubscribe(_) => subscriptions.mark_subscribe(&topic),
779        }
780    }
781}
782
783// Writes the hello on the current connection only; the transport would otherwise buffer it
784// through a reconnect and send it on the replacement just ahead of the replayed scope
785async fn send_scope(client: &WebSocketClient, scope: &[Subscription]) -> Result<(), Error> {
786    if scope.is_empty() {
787        return Ok(());
788    }
789
790    let hello = serde_json::to_string(&Hello::new(scope))
791        .expect("CoinAPI hello contains only serializable fields");
792    client
793        .send_text_on_connection(hello, None, client.connection_epoch())
794        .await?;
795    Ok(())
796}
797
798fn scope(subscriptions: &SubscriptionState) -> Vec<Subscription> {
799    subscriptions
800        .all_topics()
801        .iter()
802        .filter_map(|topic| parse_topic(topic))
803        .collect()
804}
805
806// CoinAPI rejects a scope listing two book types with a terminal error frame
807fn check_book_type(scope: &[Subscription], subscription: &Subscription) -> Result<(), Error> {
808    let requested = subscription.channel.data_type;
809
810    if !requested.is_book() {
811        return Ok(());
812    }
813
814    match scope
815        .iter()
816        .map(|subscription| subscription.channel.data_type)
817        .find(|active| active.is_book() && *active != requested)
818    {
819        Some(active) => Err(Error::Validation(format!(
820            "scope already streams {}, so cannot add {}",
821            active.as_ref(),
822            requested.as_ref()
823        ))),
824        None => Ok(()),
825    }
826}
827
828// Leaves the book pair out of the effective scope, which combines every listed type and symbol.
829// An empty `hello` is a terminal error, so a book alone in the scope gives way to its trades.
830fn removal_scope(scope: &[Subscription], pair: &Subscription) -> Vec<Subscription> {
831    let others: Vec<Subscription> = scope
832        .iter()
833        .filter(|subscription| subscription.symbol_id != pair.symbol_id)
834        .copied()
835        .collect();
836
837    if !others.is_empty() {
838        return others;
839    }
840
841    let channels: Vec<Subscription> = scope
842        .iter()
843        .filter(|subscription| *subscription != pair)
844        .copied()
845        .collect();
846
847    if !channels.is_empty() {
848        return channels;
849    }
850
851    vec![Subscription {
852        channel: Channel {
853            data_type: DataType::Trade,
854            period_id: None,
855        },
856        symbol_id: pair.symbol_id,
857    }]
858}
859
860fn is_subscribed(subscriptions: &SubscriptionState, subscription: &Subscription) -> bool {
861    subscriptions.is_subscribed(
862        &Ustr::from(&channel_key(&subscription.channel)),
863        &subscription.symbol_id,
864    )
865}
866
867fn topic(subscription: &Subscription) -> String {
868    format!(
869        "{}{TOPIC_DELIMITER}{}",
870        channel_key(&subscription.channel),
871        subscription.symbol_id
872    )
873}
874
875fn channel_key(channel: &Channel) -> String {
876    match channel.period_id {
877        Some(period) => format!("{}{PERIOD_DELIMITER}{period}", channel.data_type.as_ref()),
878        None => channel.data_type.as_ref().to_owned(),
879    }
880}
881
882fn parse_topic(topic: &str) -> Option<Subscription> {
883    let (channel, symbol_id) = split_topic(topic, TOPIC_DELIMITER);
884
885    let (data_type, period_id) = match channel.split_once(PERIOD_DELIMITER) {
886        Some((data_type, period)) => (data_type, Some(period.into())),
887        None => (channel, None),
888    };
889
890    Some(Subscription {
891        channel: Channel {
892            data_type: data_type.parse().ok()?,
893            period_id,
894        },
895        symbol_id: Ustr::from(symbol_id?),
896    })
897}
898
899#[cfg(test)]
900mod tests {
901    #[cfg(not(all(feature = "simulation", madsim)))]
902    use futures_util::{SinkExt, StreamExt};
903    #[cfg(not(all(feature = "simulation", madsim)))]
904    use nautilus_network::error::SendError;
905    use rstest::rstest;
906
907    use super::*;
908
909    fn sub(data_type: DataType, symbol_id: &str) -> Subscription {
910        Subscription {
911            channel: Channel {
912                data_type,
913                period_id: None,
914            },
915            symbol_id: symbol_id.into(),
916        }
917    }
918
919    #[rstest]
920    #[case::other_symbols_remain(
921        vec![sub(DataType::Book, "A"), sub(DataType::Trade, "A"), sub(DataType::Book, "B")],
922        vec![sub(DataType::Book, "B")]
923    )]
924    #[case::only_symbol_keeps_other_channels(
925        vec![sub(DataType::Book, "A"), sub(DataType::Trade, "A"), sub(DataType::Quote, "A")],
926        vec![sub(DataType::Trade, "A"), sub(DataType::Quote, "A")]
927    )]
928    #[case::pair_is_whole_scope(vec![sub(DataType::Book, "A")], vec![sub(DataType::Trade, "A")])]
929    fn removal_scope_leaves_pair_out_of_effective_scope(
930        #[case] scope: Vec<Subscription>,
931        #[case] expected: Vec<Subscription>,
932    ) {
933        assert_eq!(removal_scope(&scope, &sub(DataType::Book, "A")), expected);
934    }
935
936    #[rstest]
937    #[case(DataType::Book, None)]
938    #[case(DataType::Trade, None)]
939    #[case(
940        DataType::Book5,
941        Some("Invalid CoinAPI WebSocket request: scope already streams book, so cannot add book5")
942    )]
943    #[case(
944        DataType::BookL3,
945        Some(
946            "Invalid CoinAPI WebSocket request: scope already streams book, so cannot add book_l3"
947        )
948    )]
949    fn scope_accepts_one_book_type(#[case] requested: DataType, #[case] expected: Option<&str>) {
950        let scope = [sub(DataType::Trade, "A"), sub(DataType::Book, "A")];
951
952        let result = check_book_type(&scope, &sub(requested, "B"));
953
954        assert_eq!(result.err().map(|e| e.to_string()).as_deref(), expected);
955    }
956
957    #[rstest]
958    fn rollback_undoes_repeated_scope_changes_in_reverse_order() {
959        let subscriptions = SubscriptionState::new(TOPIC_DELIMITER);
960        let a = sub(DataType::Trade, "A");
961        let b = sub(DataType::Trade, "B");
962        let c = sub(DataType::Trade, "C");
963        let d = sub(DataType::Trade, "D");
964
965        for subscription in [a, b] {
966            subscriptions.mark_subscribe(&topic(&subscription));
967            subscriptions.confirm_subscribe(&topic(&subscription));
968        }
969
970        let changes = [
971            ScopeChange::Unsubscribe(a),
972            ScopeChange::Subscribe(c),
973            ScopeChange::Unsubscribe(c),
974            ScopeChange::Subscribe(a),
975            ScopeChange::Unsubscribe(b),
976            ScopeChange::Subscribe(d),
977        ];
978
979        for change in &changes {
980            match change {
981                ScopeChange::Subscribe(subscription) => {
982                    subscriptions.mark_subscribe(&topic(subscription));
983                }
984                ScopeChange::Unsubscribe(subscription) => {
985                    subscriptions.mark_unsubscribe(&topic(subscription));
986                }
987            }
988        }
989
990        revert(&subscriptions, &changes);
991
992        assert_eq!(scope(&subscriptions), [a, b]);
993    }
994
995    #[cfg(not(all(feature = "simulation", madsim)))]
996    #[tokio::test]
997    async fn failed_send_on_existing_connection_rolls_back_scope() {
998        tokio::time::timeout(std::time::Duration::from_secs(10), async {
999            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1000
1001            let (client, _messages) = CoinApiWebSocketClient::new(
1002                "test-key".into(),
1003                CoinApiWebSocketConfig {
1004                    url: format!("ws://{}", listener.local_addr().unwrap()),
1005                    backend: TransportBackend::Tungstenite,
1006                    ..CoinApiWebSocketConfig::default()
1007                },
1008            )
1009            .unwrap();
1010
1011            let peer = tokio::spawn(async move {
1012                let (socket, _) = listener.accept().await.unwrap();
1013                let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1014                let hello = socket.next().await.unwrap().unwrap();
1015                while let Some(message) = socket.next().await {
1016                    if message.unwrap().is_close() {
1017                        socket.flush().await.unwrap();
1018                        break;
1019                    }
1020                }
1021
1022                hello
1023            });
1024
1025            let original = sub(DataType::Trade, "COINBASE_SPOT_BTC_USD");
1026            let replacement = sub(DataType::Quote, "COINBASE_SPOT_BTC_USD");
1027            assert!(
1028                client
1029                    .apply(&[ScopeChange::Subscribe(original)])
1030                    .await
1031                    .unwrap()
1032                    .is_empty()
1033            );
1034            let transport = client
1035                .connection
1036                .lock()
1037                .await
1038                .as_ref()
1039                .unwrap()
1040                .client
1041                .clone();
1042            transport.disconnect().await;
1043            let hello = peer.await.unwrap();
1044
1045            let error = client
1046                .apply(&[
1047                    ScopeChange::Unsubscribe(original),
1048                    ScopeChange::Subscribe(replacement),
1049                ])
1050                .await
1051                .unwrap_err();
1052            let remaining = scope(
1053                &client
1054                    .connection
1055                    .lock()
1056                    .await
1057                    .as_ref()
1058                    .unwrap()
1059                    .subscriptions,
1060            );
1061            client.close().await;
1062
1063            assert_eq!(hello.into_text().unwrap(), r#"{"type":"hello","heartbeat":true,"subscribe_data_type":["trade"],"subscribe_filter_symbol_id":["COINBASE_SPOT_BTC_USD$"]}"#);
1064            assert!(matches!(error, Error::Send(SendError::Closed)));
1065            assert_eq!(remaining, [original]);
1066        })
1067        .await
1068        .unwrap();
1069    }
1070}