1use std::{
27 collections::VecDeque,
28 fmt::Debug,
29 sync::{
30 Arc,
31 atomic::{AtomicBool, Ordering},
32 },
33};
34
35use nautilus_common::live::dst::time;
36use nautilus_core::{
37 AtomicTime,
38 string::secret::{REDACTED, SecretString},
39};
40pub use nautilus_live::book::snapshot::SnapshotGate;
41use nautilus_model::identifiers::ClientOrderId;
42use nautilus_network::{
43 RECONNECTED,
44 error::SendError,
45 retry::{RetryError, RetryManager, create_websocket_retry_manager},
46 websocket::{AuthTracker, SubscriptionState, TEXT_PING, TEXT_PONG, WebSocketClient},
47};
48use serde_json::{Map, Value};
49use tokio_tungstenite::tungstenite::Message;
50use tokio_util::sync::CancellationToken;
51use ustr::Ustr;
52
53use super::{
54 enums::{OKXSubscriptionEvent, OKXWsChannel, OKXWsOperation},
55 error::OKXWsError,
56 messages::{
57 OKXOrderMsg, OKXSubscription, OKXSubscriptionArg, OKXWebSocketArg, OKXWebSocketError,
58 OKXWsFrame, OKXWsMessage,
59 },
60 subscription::{topic_from_subscription_arg, topic_from_websocket_arg},
61};
62use crate::{
63 common::{
64 consts::{OKX_FIELD_SMSG, OKX_SUCCESS_CODE, should_retry_error_code},
65 enums::{OKXOrderStatus, OKXOrderType},
66 parse::prefer_rpi_response_fields,
67 },
68 websocket::client::OKX_RATE_LIMIT_KEY_SUBSCRIPTION,
69};
70
71pub enum HandlerCommand {
73 SetClient(WebSocketClient),
75 Disconnect,
77 Authenticate { payload: SecretString },
79 Subscribe { args: Vec<OKXSubscriptionArg> },
81 SubscribeBook {
83 subscription: OKXSubscriptionArg,
84 cancel: CancellationToken,
85 gate: SnapshotGate,
86 completion: tokio::sync::oneshot::Sender<Result<(), OKXWsError>>,
87 },
88 Unsubscribe { args: Vec<OKXSubscriptionArg> },
90 Resubscribe {
92 subscription: OKXSubscriptionArg,
93 cancel: CancellationToken,
94 gate: SnapshotGate,
95 completion: tokio::sync::oneshot::Sender<Result<(), OKXWsError>>,
96 },
97 Send {
99 payload: String,
100 rate_limit_keys: Option<Vec<Ustr>>,
101 request_id: Option<String>,
102 client_order_ids: Vec<ClientOrderId>,
103 op: Option<OKXWsOperation>,
104 },
105}
106
107impl Debug for HandlerCommand {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 Self::SetClient(_) => f.write_str("SetClient"),
111 Self::Disconnect => f.write_str("Disconnect"),
112 Self::Authenticate { .. } => f
113 .debug_struct(stringify!(Authenticate))
114 .field("payload", &REDACTED)
115 .finish(),
116 Self::Subscribe { args } => f
117 .debug_struct(stringify!(Subscribe))
118 .field("args", args)
119 .finish(),
120 Self::SubscribeBook { subscription, .. } => {
121 f.debug_tuple("SubscribeBook").field(subscription).finish()
122 }
123 Self::Unsubscribe { args } => f
124 .debug_struct(stringify!(Unsubscribe))
125 .field("args", args)
126 .finish(),
127 Self::Resubscribe { subscription, .. } => {
128 f.debug_tuple("Resubscribe").field(subscription).finish()
129 }
130 Self::Send {
131 rate_limit_keys,
132 request_id,
133 client_order_ids,
134 op,
135 ..
136 } => f
137 .debug_struct(stringify!(Send))
138 .field("payload", &REDACTED)
139 .field("rate_limit_keys", rate_limit_keys)
140 .field("request_id", request_id)
141 .field("client_order_ids", client_order_ids)
142 .field("op", op)
143 .finish(),
144 }
145 }
146}
147
148pub(super) struct OKXWsFeedHandler {
149 clock: &'static AtomicTime,
150 signal: Arc<AtomicBool>,
151 inner: Option<WebSocketClient>,
152 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
153 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
154 out_tx: tokio::sync::mpsc::UnboundedSender<OKXWsMessage>,
155 auth_tracker: AuthTracker,
156 subscriptions_state: SubscriptionState,
157 retry_manager: RetryManager<OKXWsError>,
158 pending_messages: VecDeque<OKXWsMessage>,
159}
160
161impl OKXWsFeedHandler {
162 pub(super) fn new(
164 signal: Arc<AtomicBool>,
165 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
166 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
167 out_tx: tokio::sync::mpsc::UnboundedSender<OKXWsMessage>,
168 auth_tracker: AuthTracker,
169 subscriptions_state: SubscriptionState,
170 clock: &'static AtomicTime,
171 ) -> Self {
172 Self {
173 clock,
174 signal,
175 inner: None,
176 cmd_rx,
177 raw_rx,
178 out_tx,
179 auth_tracker,
180 subscriptions_state,
181 retry_manager: create_websocket_retry_manager(),
182 pending_messages: VecDeque::new(),
183 }
184 }
185
186 pub(super) fn is_stopped(&self) -> bool {
187 self.signal.load(Ordering::Acquire)
188 }
189
190 pub(super) fn send(&self, msg: OKXWsMessage) -> Result<(), ()> {
191 self.out_tx.send(msg).map_err(|_| ())
192 }
193
194 async fn send_with_retry(
195 &self,
196 payload: String,
197 rate_limit_keys: Option<&[Ustr]>,
198 ) -> Result<(), OKXWsError> {
199 self.send_secret_with_retry(payload.into(), rate_limit_keys)
200 .await
201 }
202
203 async fn send_secret_with_retry(
204 &self,
205 payload: SecretString,
206 rate_limit_keys: Option<&[Ustr]>,
207 ) -> Result<(), OKXWsError> {
208 if let Some(client) = &self.inner {
209 let keys_owned: Option<Vec<Ustr>> = rate_limit_keys.map(<[Ustr]>::to_vec);
210 self.retry_manager
211 .invocation(
212 "websocket_send",
213 || {
214 let payload = payload.clone();
215 let keys = keys_owned.clone();
216 async move {
217 client
218 .send_text(payload.expose_secret().to_owned(), keys.as_deref())
219 .await
220 .map_err(OKXWsError::TransportSend)
221 }
222 },
223 should_retry_replay_safe_error,
224 create_okx_retry_error,
225 )
226 .execute()
227 .await
228 } else {
229 Err(OKXWsError::NoActiveClient)
230 }
231 }
232
233 async fn send_on_connection(
234 &self,
235 payload: String,
236 rate_limit_keys: Option<&[Ustr]>,
237 ) -> Result<(), OKXWsError> {
238 let client = self.inner.as_ref().ok_or(OKXWsError::NoActiveClient)?;
239 let connection_epoch = client.connection_epoch();
240 client
241 .send_text_on_connection(payload, rate_limit_keys, connection_epoch)
242 .await
243 .map_err(OKXWsError::TransportSend)
244 }
245
246 pub(super) async fn send_pong(&self) -> anyhow::Result<()> {
247 match self.send_on_connection(TEXT_PONG.to_string(), None).await {
248 Ok(()) => {
249 log::trace!("Sent pong response to OKX text ping");
250 Ok(())
251 }
252 Err(e) => Err(anyhow::anyhow!("Failed to send pong: {e}")),
253 }
254 }
255
256 pub(super) async fn next(&mut self) -> Option<OKXWsMessage> {
257 if let Some(message) = self.pending_messages.pop_front() {
258 return Some(message);
259 }
260
261 let mut poll_raw_next = false;
262
263 loop {
264 if self.signal.load(Ordering::Acquire) {
265 log::debug!("Stop signal received");
266 return None;
267 }
268
269 tokio::select! {
270 biased;
271 Some(cmd) = self.cmd_rx.recv(), if !poll_raw_next => {
272 match cmd {
273 HandlerCommand::SetClient(client) => {
274 log::debug!("Handler received WebSocket client");
275 self.inner = Some(client);
276 }
277 HandlerCommand::Disconnect => {
278 log::debug!("Handler disconnecting WebSocket client");
279 self.inner = None;
280 return None;
281 }
282 HandlerCommand::Authenticate { payload } => {
283 if let Err(e) = self.send_secret_with_retry(
284 payload,
285 Some(OKX_RATE_LIMIT_KEY_SUBSCRIPTION.as_slice()),
286 ).await {
287 log::error!(
288 "Failed to send authentication message after retries: error={e}"
289 );
290 }
291 }
292 HandlerCommand::Subscribe { args } => {
293 if let Err(e) = self.handle_subscribe(args).await {
294 log::error!("Failed to handle subscribe command: error={e}");
295 }
296 }
297 HandlerCommand::SubscribeBook { subscription, cancel, gate, completion } => {
298 let result = tokio::select! {
299 biased;
300 () = cancel.cancelled() => continue,
301 result = async {
302 let client = self.inner.as_ref().ok_or(OKXWsError::NoActiveClient)?;
303 self.send_book_subscription(
304 OKXWsOperation::Subscribe,
305 subscription,
306 client.connection_epoch(),
307 ).await
308 } => result,
309 };
310
311 if result.is_ok() {
312 gate.open();
313 }
314 let _ = completion.send(result);
315 }
316 HandlerCommand::Unsubscribe { args } => {
317 if let Err(e) = self.handle_unsubscribe(args).await {
318 log::error!("Failed to handle unsubscribe command: error={e}");
319 }
320 }
321 HandlerCommand::Resubscribe { subscription, cancel, gate, completion } => {
322 let result = tokio::select! {
323 biased;
324 () = cancel.cancelled() => continue,
325 result = async {
326 self.subscriptions_state.mark_failure(&topic_from_subscription_arg(&subscription));
327 let client = self.inner.as_ref().ok_or(OKXWsError::NoActiveClient)?;
328 let epoch = client.connection_epoch();
329 self.send_book_subscription(
330 OKXWsOperation::Unsubscribe,
331 subscription.clone(),
332 epoch,
333 ).await?;
334 self.send_book_subscription(
335 OKXWsOperation::Subscribe,
336 subscription,
337 epoch,
338 ).await
339 } => result,
340 };
341
342 if result.is_ok() {
343 gate.open();
344 }
345 let _ = completion.send(result);
346 }
347 HandlerCommand::Send {
348 payload,
349 rate_limit_keys,
350 request_id,
351 client_order_ids,
352 op,
353 } => {
354 if let Err(e) = self.send_on_connection(
355 payload,
356 rate_limit_keys.as_deref(),
357 ).await {
358 log::debug!("Failed to send message: error={e}");
359
360 if let Some(request_id) = request_id {
361 self.pending_messages.push_back(OKXWsMessage::SendFailed {
362 request_id,
363 client_order_ids,
364 op,
365 error: e,
366 });
367 }
368 }
369 }
370 }
371
372 poll_raw_next = true;
373 }
374
375 msg = self.raw_rx.recv() => {
376 let event = match msg {
377 Some(msg) => match Self::parse_raw_message(msg) {
378 Some(event) => event,
379 None => continue,
380 },
381 None => {
382 log::debug!("WebSocket stream closed");
383 return None;
384 }
385 };
386
387 match event {
388 OKXWsFrame::Ping => {
389 if let Err(e) = self.send_pong().await {
390 log::warn!("Failed to send pong response: error={e}");
391 }
392 }
393 OKXWsFrame::Login {
394 code, msg, conn_id, ..
395 } => {
396 if code == OKX_SUCCESS_CODE {
397 self.auth_tracker.succeed();
398 return Some(OKXWsMessage::Authenticated);
399 }
400
401 self.auth_tracker.fail(msg.clone());
402
403 let error = OKXWebSocketError {
404 code,
405 message: msg,
406 conn_id: Some(conn_id),
407 timestamp: self.clock.get_time_ns().as_u64(),
408 };
409 self.pending_messages.push_back(OKXWsMessage::Error(error));
410 }
411 OKXWsFrame::BookData { arg, action, data } => {
412 return Some(OKXWsMessage::BookData { arg, action, data });
413 }
414 OKXWsFrame::RpiBookData { arg, action, data } => {
415 return Some(OKXWsMessage::RpiBookData { arg, action, data });
416 }
417 OKXWsFrame::OrderResponse {
418 id, op, code, msg, data,
419 } => {
420 return Some(OKXWsMessage::OrderResponse {
421 id, op, code, msg, data,
422 });
423 }
424 OKXWsFrame::Data { arg, data } => {
425 if let Some(output) = self.route_data_message(arg, data) {
426 return Some(output);
427 }
428 }
429 OKXWsFrame::Error { arg, code, msg } => {
430 let arg = arg.or_else(|| subscription_arg_from_error_message(&msg));
431 if let Some(arg) = arg
432 && self.handle_subscription_error(&arg, &code, &msg)
433 {
434 return Some(OKXWsMessage::SubscriptionFailed {
435 channel: arg.channel,
436 inst_id: arg.inst_id,
437 code,
438 msg,
439 });
440 }
441
442 let error = OKXWebSocketError {
443 code,
444 message: msg,
445 conn_id: None,
446 timestamp: self.clock.get_time_ns().as_u64(),
447 };
448 return Some(OKXWsMessage::Error(error));
449 }
450 OKXWsFrame::Reconnected => {
451 self.auth_tracker.invalidate();
452 return Some(OKXWsMessage::Reconnected);
453 }
454 OKXWsFrame::Subscription {
455 event, arg, code, msg,
456 ..
457 } => {
458 let rejected = self
459 .handle_subscription_ack(&event, &arg, code.as_deref(), msg.as_deref());
460
461 if rejected {
462 return Some(OKXWsMessage::SubscriptionFailed {
463 channel: arg.channel,
464 inst_id: arg.inst_id,
465 code: code.unwrap_or_default(),
466 msg: msg.unwrap_or_default(),
467 });
468 }
469 }
470 OKXWsFrame::ChannelConnCount { .. } => {}
471 }
472 }
473
474 () = std::future::ready(()), if poll_raw_next => {
475 poll_raw_next = false;
476 }
477
478 () = time::sleep(time::Duration::from_millis(100)) => {
480 }
482
483 else => {
484 log::debug!("Handler shutting down: stream ended or command channel closed");
485 return None;
486 }
487 }
488 }
489 }
490
491 fn route_data_message(&self, arg: OKXWebSocketArg, mut data: Value) -> Option<OKXWsMessage> {
492 let OKXWebSocketArg {
493 channel, inst_id, ..
494 } = arg;
495
496 match channel {
497 OKXWsChannel::Account => Some(OKXWsMessage::Account(data)),
498 OKXWsChannel::Positions => Some(OKXWsMessage::Positions(data)),
499 OKXWsChannel::Orders => {
500 parse_array_items(data, "orders", false).map(OKXWsMessage::Orders)
501 }
502 OKXWsChannel::SprdOrders => {
503 parse_array_items(data, "spread orders", false).map(OKXWsMessage::SpreadOrders)
504 }
505 OKXWsChannel::OrdersAlgo | OKXWsChannel::AlgoAdvance => {
506 parse_array_items(data, "algo orders", false).map(OKXWsMessage::AlgoOrders)
507 }
508 OKXWsChannel::LiquidationWarning => {
509 parse_array_items(data, "liquidation warnings", false)
510 .map(OKXWsMessage::LiquidationWarnings)
511 }
512 OKXWsChannel::Instruments => {
513 prefer_rpi_response_fields(&mut data);
514 parse_array_items(data, "instruments", true).map(OKXWsMessage::Instruments)
515 }
516 _ => Some(OKXWsMessage::ChannelData {
517 channel,
518 inst_id,
519 data,
520 }),
521 }
522 }
523
524 fn handle_subscription_ack(
525 &self,
526 event: &OKXSubscriptionEvent,
527 arg: &OKXWebSocketArg,
528 code: Option<&str>,
529 msg: Option<&str>,
530 ) -> bool {
531 let topic = topic_from_websocket_arg(arg);
532 let success = code.is_none_or(|c| c == OKX_SUCCESS_CODE);
533
534 match event {
535 OKXSubscriptionEvent::Subscribe => {
536 if success {
537 self.subscriptions_state.confirm_subscribe(&topic);
538 false
539 } else {
540 log::warn!(
541 "Subscription failed: topic={topic:?}, error={msg:?}, code={code:?}"
542 );
543 self.subscriptions_state.mark_failure(&topic);
544 true
545 }
546 }
547 OKXSubscriptionEvent::Unsubscribe => {
548 if success {
549 self.subscriptions_state.confirm_unsubscribe(&topic);
550 } else {
551 log::warn!(
552 "Unsubscription failed - restoring subscription: \
553 topic={topic:?}, error={msg:?}, code={code:?}"
554 );
555 self.subscriptions_state.confirm_unsubscribe(&topic);
556 self.subscriptions_state.mark_subscribe(&topic);
557 self.subscriptions_state.confirm_subscribe(&topic);
558 }
559 false
560 }
561 }
562 }
563
564 fn handle_subscription_error(&self, arg: &OKXWebSocketArg, code: &str, msg: &str) -> bool {
565 let topic = topic_from_websocket_arg(arg);
566 let event = if self
567 .subscriptions_state
568 .pending_unsubscribe_topics()
569 .iter()
570 .any(|pending| pending == &topic)
571 {
572 OKXSubscriptionEvent::Unsubscribe
573 } else if self
574 .subscriptions_state
575 .pending_subscribe_topics()
576 .iter()
577 .any(|pending| pending == &topic)
578 || (arg.channel.is_book()
579 && self
580 .subscriptions_state
581 .all_topics()
582 .iter()
583 .any(|tracked| tracked == &topic))
584 {
585 OKXSubscriptionEvent::Subscribe
586 } else {
587 return false;
588 };
589
590 self.handle_subscription_ack(&event, arg, Some(code), Some(msg))
591 }
592
593 async fn send_book_subscription(
594 &self,
595 op: OKXWsOperation,
596 subscription: OKXSubscriptionArg,
597 connection_epoch: u64,
598 ) -> Result<(), OKXWsError> {
599 let client = self.inner.as_ref().ok_or(OKXWsError::NoActiveClient)?;
600 let message = OKXSubscription {
601 op,
602 args: vec![subscription],
603 };
604 let payload =
605 serde_json::to_string(&message).map_err(|e| OKXWsError::ClientError(e.to_string()))?;
606
607 client
608 .send_text_on_connection(
609 payload,
610 Some(OKX_RATE_LIMIT_KEY_SUBSCRIPTION.as_slice()),
611 connection_epoch,
612 )
613 .await
614 .map_err(OKXWsError::TransportSend)
615 }
616
617 async fn handle_subscribe(&self, args: Vec<OKXSubscriptionArg>) -> anyhow::Result<()> {
618 for arg in &args {
619 log::debug!(
620 "Subscribing to channel: channel={:?}, inst_id={:?}",
621 arg.channel,
622 arg.inst_id
623 );
624 }
625
626 let message = OKXSubscription {
627 op: OKXWsOperation::Subscribe,
628 args,
629 };
630
631 let json_txt = serde_json::to_string(&message)
632 .map_err(|e| anyhow::anyhow!("Failed to serialize subscription: {e}"))?;
633
634 self.send_with_retry(json_txt, Some(OKX_RATE_LIMIT_KEY_SUBSCRIPTION.as_slice()))
635 .await
636 .map_err(|e| anyhow::anyhow!("Failed to send subscription after retries: {e}"))?;
637 Ok(())
638 }
639
640 async fn handle_unsubscribe(&self, args: Vec<OKXSubscriptionArg>) -> anyhow::Result<()> {
641 for arg in &args {
642 log::debug!(
643 "Unsubscribing from channel: channel={:?}, inst_id={:?}",
644 arg.channel,
645 arg.inst_id
646 );
647 }
648
649 let message = OKXSubscription {
650 op: OKXWsOperation::Unsubscribe,
651 args,
652 };
653
654 let json_txt = serde_json::to_string(&message)
655 .map_err(|e| anyhow::anyhow!("Failed to serialize unsubscription: {e}"))?;
656
657 self.send_with_retry(json_txt, Some(OKX_RATE_LIMIT_KEY_SUBSCRIPTION.as_slice()))
658 .await
659 .map_err(|e| anyhow::anyhow!("Failed to send unsubscription after retries: {e}"))?;
660 Ok(())
661 }
662
663 pub(crate) fn parse_raw_message(
664 msg: tokio_tungstenite::tungstenite::Message,
665 ) -> Option<OKXWsFrame> {
666 match msg {
667 tokio_tungstenite::tungstenite::Message::Text(text) => {
668 if text == TEXT_PONG {
669 log::trace!("Received pong from OKX");
670 return None;
671 }
672
673 if text == TEXT_PING {
674 log::trace!("Received ping from OKX (text)");
675 return Some(OKXWsFrame::Ping);
676 }
677
678 if text == RECONNECTED {
679 log::debug!("Received WebSocket reconnection signal");
680 return Some(OKXWsFrame::Reconnected);
681 }
682 log::trace!("Received WebSocket message: {text}");
683
684 match serde_json::from_str(&text) {
685 Ok(ws_event) => match &ws_event {
686 OKXWsFrame::Error { arg, code, msg } => {
687 let level = error_frame_level(arg.as_ref(), code, msg);
688 log::log!(level, "WebSocket error: {code} - {msg}");
689 Some(ws_event)
690 }
691 OKXWsFrame::Login {
692 event,
693 code,
694 msg,
695 conn_id,
696 } => {
697 if code == OKX_SUCCESS_CODE {
698 log::debug!("WebSocket authenticated: conn_id={conn_id}");
699 } else {
700 log::error!(
701 "WebSocket authentication failed: \
702 event={event}, code={code}, error={msg}"
703 );
704 }
705 Some(ws_event)
706 }
707 OKXWsFrame::Subscription {
708 event,
709 arg,
710 conn_id,
711 ..
712 } => {
713 let channel_str = serde_json::to_string(&arg.channel)
714 .expect("Invalid OKX websocket channel")
715 .trim_matches('"')
716 .to_string();
717 log::debug!("{event}d: channel={channel_str}, conn_id={conn_id}");
718 Some(ws_event)
719 }
720 OKXWsFrame::ChannelConnCount {
721 channel,
722 conn_count,
723 conn_id,
724 ..
725 } => {
726 let channel_str = serde_json::to_string(channel)
727 .expect("Invalid OKX websocket channel")
728 .trim_matches('"')
729 .to_string();
730 log::debug!(
731 "Channel connection status: \
732 channel={channel_str}, connections={conn_count}, conn_id={conn_id}",
733 );
734 None
735 }
736 OKXWsFrame::Ping => {
737 log::trace!("Ignoring ping event parsed from text payload");
738 None
739 }
740 OKXWsFrame::Data { .. }
741 | OKXWsFrame::BookData { .. }
742 | OKXWsFrame::RpiBookData { .. } => Some(ws_event),
743 OKXWsFrame::OrderResponse {
744 id, op, code, data, ..
745 } => {
746 if code == OKX_SUCCESS_CODE {
747 log::debug!(
748 "Order operation successful: id={id:?}, op={op}, code={code}"
749 );
750
751 if let Some(order_data) = data.first() {
752 let success_msg = order_data
753 .get(OKX_FIELD_SMSG)
754 .and_then(|s| s.as_str())
755 .unwrap_or("Order operation successful");
756 log::debug!("Order success details: {success_msg}");
757 }
758 }
759 Some(ws_event)
760 }
761 OKXWsFrame::Reconnected => {
762 log::warn!("Unexpected Reconnected event from deserialization");
763 None
764 }
765 },
766 Err(e) => {
767 log::error!("Failed to parse message: {e}: {text}");
768 None
769 }
770 }
771 }
772 Message::Ping(_payload) => {
773 log::trace!("Received binary ping frame from OKX");
774 Some(OKXWsFrame::Ping)
775 }
776 Message::Pong(payload) => {
777 log::trace!("Received pong frame from OKX ({} bytes)", payload.len());
778 None
779 }
780 Message::Binary(msg) => {
781 log::debug!("Raw binary frame ({} bytes)", msg.len());
782 log::trace!("Raw binary: {msg:?}");
783 None
784 }
785 Message::Close(_) => {
786 log::debug!("Received close message");
787 None
788 }
789 msg => {
790 log::warn!("Unexpected message: {msg}");
791 None
792 }
793 }
794 }
795}
796
797fn error_frame_level(arg: Option<&OKXWebSocketArg>, code: &str, msg: &str) -> log::Level {
799 let book = match arg {
800 Some(arg) => arg.channel.is_book(),
801 None => subscription_arg_from_error_message(msg).is_some_and(|arg| arg.channel.is_book()),
802 };
803
804 if should_retry_error_code(code) || book {
805 log::Level::Warn
806 } else {
807 log::Level::Error
808 }
809}
810
811fn subscription_arg_from_error_message(msg: &str) -> Option<OKXWebSocketArg> {
812 let descriptor = msg
813 .strip_prefix("Wrong URL or channel:")?
814 .split_whitespace()
815 .next()?;
816 let mut fields = descriptor.split(',');
817 let channel = fields.next()?;
818 let mut arg = Map::new();
819 arg.insert("channel".to_string(), Value::String(channel.to_string()));
820
821 for field in fields {
822 let (key, value) = field.split_once(':')?;
823 if !matches!(key, "instId" | "sprdId" | "instType" | "instFamily") {
824 return None;
825 }
826 arg.insert(key.to_string(), Value::String(value.to_string()));
827 }
828
829 serde_json::from_value(Value::Object(arg)).ok()
830}
831
832pub fn is_post_only_auto_cancel(msg: &OKXOrderMsg) -> bool {
834 use crate::common::{consts::OKX_POST_ONLY_CANCEL_SOURCE, enums::OKXOrderStatus};
835
836 if msg.state != OKXOrderStatus::Canceled {
837 return false;
838 }
839
840 let cancel_source_matches = matches!(
841 msg.cancel_source.as_deref(),
842 Some(source) if source == OKX_POST_ONLY_CANCEL_SOURCE
843 );
844
845 let reason_matches = matches!(
846 msg.cancel_source_reason.as_deref(),
847 Some(reason) if reason.contains("POST_ONLY")
848 );
849
850 if !(cancel_source_matches || reason_matches) {
851 return false;
852 }
853
854 msg.acc_fill_sz
855 .as_ref()
856 .is_none_or(|filled| filled == "0" || filled.is_empty())
857}
858
859pub fn is_unfilled_rpi_cancel(msg: &OKXOrderMsg) -> bool {
861 msg.ord_type == OKXOrderType::Rpi
862 && msg.state == OKXOrderStatus::Canceled
863 && msg
864 .acc_fill_sz
865 .as_ref()
866 .is_none_or(|filled| filled == "0" || filled.is_empty())
867}
868
869fn parse_array_items<T: serde::de::DeserializeOwned>(
871 data: Value,
872 label: &str,
873 warn_on_parse_error: bool,
874) -> Option<Vec<T>> {
875 let Value::Array(items) = data else {
876 if warn_on_parse_error {
877 log::warn!("Expected {label} payload to be a JSON array");
878 } else {
879 log::error!("Expected {label} payload to be a JSON array");
880 }
881 return None;
882 };
883
884 let mut parsed = Vec::with_capacity(items.len());
885 for (idx, item) in items.into_iter().enumerate() {
886 match serde_json::from_value::<T>(item) {
887 Ok(value) => parsed.push(value),
888 Err(e) => {
889 if warn_on_parse_error {
890 log::warn!("Failed to parse {label} item at index {idx}: {e}");
891 } else {
892 log::error!("Failed to parse {label} item at index {idx}: {e}");
893 }
894 }
895 }
896 }
897
898 if parsed.is_empty() {
899 None
900 } else {
901 Some(parsed)
902 }
903}
904
905fn should_retry_replay_safe_error(error: &OKXWsError) -> bool {
906 match error {
907 OKXWsError::OkxError { error_code, .. } => should_retry_error_code(error_code),
908 OKXWsError::TransportSend(SendError::Timeout | SendError::ConnectionChanged)
909 | OKXWsError::TungsteniteError(_)
910 | OKXWsError::OperationTimeout { .. } => true,
911 OKXWsError::AuthenticationError(_)
912 | OKXWsError::JsonError(_)
913 | OKXWsError::ParsingError(_)
914 | OKXWsError::ClientError(_)
915 | OKXWsError::NoActiveClient
916 | OKXWsError::HandlerUnavailable(_)
917 | OKXWsError::TransportSend(
918 SendError::InvalidInput(_)
919 | SendError::BufferFull
920 | SendError::Closed
921 | SendError::WriteTimeout
922 | SendError::BrokenPipe(_),
923 )
924 | OKXWsError::SendFailed(_) => false,
925 }
926}
927
928fn create_okx_retry_error(error: RetryError) -> OKXWsError {
929 match error {
930 RetryError::OperationTimeout { timeout_ms } => OKXWsError::OperationTimeout { timeout_ms },
931 RetryError::InvalidConfiguration { message } => OKXWsError::ClientError(message),
932 RetryError::Canceled => {
933 OKXWsError::SendFailed("Adapter disconnecting or shutting down".to_string())
934 }
935 error @ RetryError::ElapsedBudgetExceeded { .. } => {
936 OKXWsError::SendFailed(error.to_string())
937 }
938 }
939}
940
941#[cfg(test)]
942mod tests {
943 use std::{
944 num::NonZeroU32,
945 sync::{Arc, atomic::AtomicBool},
946 time::Duration,
947 };
948
949 use futures_util::{SinkExt, StreamExt};
950 use nautilus_core::{collections::AtomicMap, time::get_atomic_clock_realtime};
951 use nautilus_model::identifiers::InstrumentId;
952 use nautilus_network::{
953 ratelimiter::{RateLimiter, quota::Quota},
954 websocket::{AuthTracker, SubscriptionState, WebSocketConfig, channel_message_handler},
955 };
956 use rstest::rstest;
957 use serde_json::json;
958
959 use super::*;
960 use crate::{
961 book::{BookChannelScope, BookSequenceOutcome, sync::BookSyncTracker},
962 common::{
963 consts::OKX_WS_TOPIC_DELIMITER,
964 enums::{OKXBookAction, OKXBookChannel, OKXRpiPermission},
965 testing::load_test_json,
966 },
967 };
968
969 fn create_handler() -> OKXWsFeedHandler {
970 let signal = Arc::new(AtomicBool::new(false));
971 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
972 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
973 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
974
975 OKXWsFeedHandler::new(
976 signal,
977 cmd_rx,
978 raw_rx,
979 out_tx,
980 AuthTracker::new(),
981 SubscriptionState::new(OKX_WS_TOPIC_DELIMITER),
982 get_atomic_clock_realtime(),
983 )
984 }
985
986 #[rstest]
987 fn test_command_debug_redacts_payloads() {
988 let payload = "authentication-secret";
989 let authenticate = HandlerCommand::Authenticate {
990 payload: SecretString::from(payload.to_string()),
991 };
992 let send = HandlerCommand::Send {
993 payload: payload.to_string(),
994 rate_limit_keys: None,
995 request_id: None,
996 client_order_ids: Vec::new(),
997 op: None,
998 };
999
1000 let debug = format!("{authenticate:?} {send:?}");
1001
1002 assert!(debug.contains(REDACTED));
1003 assert!(!debug.contains(payload));
1004 }
1005
1006 #[tokio::test]
1007 async fn test_next_polls_raw_after_one_ready_command() {
1008 let signal = Arc::new(AtomicBool::new(false));
1009 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1010 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1011 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1012 let mut handler = OKXWsFeedHandler::new(
1013 signal,
1014 cmd_rx,
1015 raw_rx,
1016 out_tx,
1017 AuthTracker::new(),
1018 SubscriptionState::new(OKX_WS_TOPIC_DELIMITER),
1019 get_atomic_clock_realtime(),
1020 );
1021
1022 for _ in 0..3 {
1023 cmd_tx
1024 .send(HandlerCommand::Subscribe { args: Vec::new() })
1025 .unwrap();
1026 }
1027 raw_tx
1028 .send(Message::Text(RECONNECTED.to_string().into()))
1029 .unwrap();
1030
1031 let message = handler.next().await;
1032
1033 assert!(matches!(message, Some(OKXWsMessage::Reconnected)));
1034 assert_eq!(handler.cmd_rx.len(), 2);
1035 }
1036
1037 #[rstest]
1038 #[case::sent(false)]
1039 #[case::canceled(true)]
1040 #[tokio::test]
1041 async fn initial_book_completion_waits_for_send(#[case] canceled: bool) {
1042 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1043 let url = format!("ws://{}/", listener.local_addr().unwrap());
1044 let (wire_tx, mut wire_rx) = tokio::sync::mpsc::unbounded_channel();
1045
1046 let server = tokio::spawn(async move {
1047 let (socket, _) = listener.accept().await.unwrap();
1048 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1049 while let Some(Ok(frame)) = socket.next().await {
1050 wire_tx.send(frame).unwrap();
1051 }
1052 });
1053
1054 let (message_handler, raw_rx) = channel_message_handler();
1055 let client = WebSocketClient::builder()
1056 .config(WebSocketConfig::builder().url(url).build().unwrap())
1057 .message_handler(message_handler)
1058 .default_quota(Quota::with_period(Duration::from_secs(10)).unwrap())
1059 .connect()
1060 .await
1061 .unwrap();
1062 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1063 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1064
1065 let mut handler = OKXWsFeedHandler::new(
1066 Arc::new(AtomicBool::new(false)),
1067 cmd_rx,
1068 raw_rx,
1069 out_tx,
1070 AuthTracker::new(),
1071 SubscriptionState::new(OKX_WS_TOPIC_DELIMITER),
1072 get_atomic_clock_realtime(),
1073 );
1074 handler.inner = Some(client);
1075 cmd_tx
1076 .send(HandlerCommand::Subscribe { args: Vec::new() })
1077 .unwrap();
1078 let cancel = CancellationToken::new();
1079 let gate = SnapshotGate::default();
1080 gate.lock().close();
1081 let (completion, mut result) = tokio::sync::oneshot::channel();
1082 cmd_tx
1083 .send(HandlerCommand::SubscribeBook {
1084 subscription: OKXSubscriptionArg {
1085 channel: OKXWsChannel::Books,
1086 inst_id: Some(Ustr::from("BTC-USDT")),
1087 inst_type: None,
1088 inst_family: None,
1089 },
1090 cancel: cancel.clone(),
1091 gate: gate.clone(),
1092 completion,
1093 })
1094 .unwrap();
1095
1096 let running = tokio::spawn(async move { handler.next().await });
1097 let first = tokio::time::timeout(Duration::from_secs(3), wire_rx.recv())
1098 .await
1099 .unwrap()
1100 .unwrap();
1101 let first: Value = serde_json::from_str(first.to_text().unwrap()).unwrap();
1102 assert_eq!(first, serde_json::json!({"op": "subscribe", "args": []}));
1103 assert!(matches!(
1104 result.try_recv(),
1105 Err(tokio::sync::oneshot::error::TryRecvError::Empty)
1106 ));
1107
1108 if canceled {
1109 cancel.cancel();
1110 }
1111
1112 tokio::time::pause();
1113 tokio::time::advance(Duration::from_secs(10)).await;
1114 tokio::time::resume();
1115 let result = tokio::time::timeout(Duration::from_secs(3), result)
1116 .await
1117 .unwrap();
1118
1119 if canceled {
1120 assert!(gate.lock().is_closed());
1121 assert!(result.is_err());
1122 assert!(
1123 tokio::time::timeout(Duration::from_millis(100), wire_rx.recv())
1124 .await
1125 .is_err()
1126 );
1127 } else {
1128 result.unwrap().unwrap();
1129 assert!(!gate.lock().is_closed());
1130 let frame = tokio::time::timeout(Duration::from_secs(3), wire_rx.recv())
1131 .await
1132 .unwrap()
1133 .unwrap();
1134 let frame: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap();
1135 assert_eq!(
1136 frame,
1137 serde_json::json!({"op": "subscribe", "args": [{"channel": "books", "instId": "BTC-USDT"}]})
1138 );
1139 }
1140
1141 running.abort();
1142 server.abort();
1143 }
1144
1145 #[tokio::test]
1146 async fn book_subscription_rejects_changed_connection() {
1147 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1148 let url = format!("ws://{}/", listener.local_addr().unwrap());
1149 let (wire_tx, mut wire_rx) = tokio::sync::mpsc::unbounded_channel();
1150
1151 let server = tokio::spawn(async move {
1152 let (socket, _) = listener.accept().await.unwrap();
1153 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1154 while let Some(Ok(frame)) = socket.next().await {
1155 wire_tx.send(frame).unwrap();
1156 }
1157 });
1158 let (message_handler, raw_rx) = channel_message_handler();
1159 let client = WebSocketClient::builder()
1160 .config(WebSocketConfig::builder().url(url).build().unwrap())
1161 .message_handler(message_handler)
1162 .connect()
1163 .await
1164 .unwrap();
1165 let wrong_epoch = client.connection_epoch() + 1;
1166 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1167 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1168 let mut handler = OKXWsFeedHandler::new(
1169 Arc::new(AtomicBool::new(false)),
1170 cmd_rx,
1171 raw_rx,
1172 out_tx,
1173 AuthTracker::new(),
1174 SubscriptionState::new(OKX_WS_TOPIC_DELIMITER),
1175 get_atomic_clock_realtime(),
1176 );
1177 handler.inner = Some(client);
1178 let result = handler
1179 .send_book_subscription(
1180 OKXWsOperation::Subscribe,
1181 OKXSubscriptionArg {
1182 channel: OKXWsChannel::Books,
1183 inst_id: Some(Ustr::from("BTC-USDT")),
1184 inst_type: None,
1185 inst_family: None,
1186 },
1187 wrong_epoch,
1188 )
1189 .await;
1190
1191 assert!(matches!(
1192 result,
1193 Err(OKXWsError::TransportSend(SendError::ConnectionChanged))
1194 ));
1195 assert!(matches!(
1196 wire_rx.try_recv(),
1197 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1198 ));
1199 handler.inner.as_ref().unwrap().disconnect().await;
1200 server.abort();
1201 }
1202
1203 #[tokio::test]
1204 async fn recovery_cancel_between_sends_keeps_snapshot_gate_closed() {
1205 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1206 let url = format!("ws://{}/", listener.local_addr().unwrap());
1207 let (received, first_frame) = tokio::sync::oneshot::channel();
1208
1209 let server = tokio::spawn(async move {
1210 let (socket, _) = listener.accept().await.unwrap();
1211 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1212 let frame = socket.next().await.unwrap().unwrap();
1213 received.send(frame).unwrap();
1214
1215 while socket.next().await.is_some() {}
1216 });
1217
1218 let (message_handler, raw_rx) = channel_message_handler();
1219 let client = WebSocketClient::builder()
1220 .config(WebSocketConfig::builder().url(url).build().unwrap())
1221 .message_handler(message_handler)
1222 .default_quota(Quota::per_hour(NonZeroU32::new(1).unwrap()))
1223 .connect()
1224 .await
1225 .unwrap();
1226 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1227 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1228
1229 let mut handler = OKXWsFeedHandler::new(
1230 Arc::new(AtomicBool::new(false)),
1231 cmd_rx,
1232 raw_rx,
1233 out_tx,
1234 AuthTracker::new(),
1235 SubscriptionState::new(OKX_WS_TOPIC_DELIMITER),
1236 get_atomic_clock_realtime(),
1237 );
1238 handler.inner = Some(client);
1239 let tracker = BookSyncTracker::default();
1240 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1241 tracker.record_subscription(instrument_id, time::Instant::now(), SnapshotGate::default());
1242 let recovery = tracker.claim_recovery(instrument_id).unwrap();
1243 assert!(recovery.begin_replacement());
1244 let cancel = recovery.cancellation.child_token();
1245 let (completion, result) = tokio::sync::oneshot::channel();
1246 cmd_tx
1247 .send(HandlerCommand::Resubscribe {
1248 subscription: OKXSubscriptionArg {
1249 channel: OKXWsChannel::Books,
1250 inst_id: Some(Ustr::from("BTC-USDT")),
1251 inst_type: None,
1252 inst_family: None,
1253 },
1254 cancel: cancel.clone(),
1255 gate: recovery.gate.clone(),
1256 completion,
1257 })
1258 .unwrap();
1259
1260 let running = tokio::spawn(async move { handler.next().await });
1261 let frame = tokio::time::timeout(Duration::from_secs(3), first_frame)
1262 .await
1263 .unwrap()
1264 .unwrap();
1265 let request: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap();
1266 assert_eq!(request["op"], "unsubscribe");
1267 assert_eq!(
1268 tracker.validate_sequence(instrument_id, true, &[(Some(-1), 42)], time::Instant::now()),
1269 BookSequenceOutcome::Suppress
1270 );
1271 cancel.cancel();
1272 assert!(
1273 tokio::time::timeout(Duration::from_secs(1), result)
1274 .await
1275 .unwrap()
1276 .is_err()
1277 );
1278
1279 assert!(recovery.gate.lock().is_closed());
1280 assert_eq!(
1281 tracker.validate_sequence(instrument_id, true, &[(Some(-1), 43)], time::Instant::now()),
1282 BookSequenceOutcome::Suppress
1283 );
1284 cmd_tx.send(HandlerCommand::Disconnect).unwrap();
1285 tokio::time::timeout(Duration::from_secs(3), running)
1286 .await
1287 .unwrap()
1288 .unwrap();
1289 server.abort();
1290 }
1291
1292 #[rstest]
1293 #[case::disabled_deadline(Duration::ZERO)]
1294 #[case::snapshot_deadline(Duration::from_secs(3))]
1295 #[tokio::test]
1296 async fn reconnect_replay_survives_inflight_recovery(#[case] snapshot_timeout: Duration) {
1297 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1298 let url = format!("ws://{}/", listener.local_addr().unwrap());
1299 let (wire_tx, mut wire_rx) = tokio::sync::mpsc::unbounded_channel();
1300
1301 let server = tokio::spawn(async move {
1302 let (socket, _) = listener.accept().await.unwrap();
1303 let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1304
1305 while let Some(Ok(Message::Text(text))) = socket.next().await {
1306 let request: Value = serde_json::from_str(&text).unwrap();
1307 wire_tx
1308 .send(request["op"].as_str().unwrap().to_owned())
1309 .unwrap();
1310
1311 if request["op"] == "subscribe" {
1312 for (fixture, sequence, previous) in [
1313 ("ws_books_snapshot.json", 100, -1),
1314 ("ws_books_update.json", 101, 100),
1315 ] {
1316 let mut frame: Value =
1317 serde_json::from_str(&load_test_json(fixture)).unwrap();
1318 frame["data"][0]["seqId"] = json!(sequence);
1319 frame["data"][0]["prevSeqId"] = json!(previous);
1320 socket
1321 .send(Message::Text(frame.to_string().into()))
1322 .await
1323 .unwrap();
1324 }
1325 }
1326 }
1327 });
1328
1329 let limiter = Arc::new(RateLimiter::new_with_quota(
1331 Some(
1332 Quota::with_period(Duration::from_secs(10))
1333 .unwrap()
1334 .allow_burst(NonZeroU32::new(2).unwrap()),
1335 ),
1336 Vec::new(),
1337 ));
1338 let (message_handler, raw_rx) = channel_message_handler();
1339 let client = WebSocketClient::builder()
1340 .config(WebSocketConfig::builder().url(url).build().unwrap())
1341 .message_handler(message_handler)
1342 .rate_limiter(Arc::clone(&limiter))
1343 .connect()
1344 .await
1345 .unwrap();
1346 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1347 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1348
1349 let mut handler = OKXWsFeedHandler::new(
1350 Arc::new(AtomicBool::new(false)),
1351 cmd_rx,
1352 raw_rx,
1353 out_tx,
1354 AuthTracker::new(),
1355 SubscriptionState::new(OKX_WS_TOPIC_DELIMITER),
1356 get_atomic_clock_realtime(),
1357 );
1358 handler.inner = Some(client);
1359 let (messages_tx, mut messages_rx) = tokio::sync::mpsc::unbounded_channel();
1360
1361 let running = tokio::spawn(async move {
1362 while let Some(message) = handler.next().await {
1363 messages_tx.send(message).unwrap();
1364 }
1365 });
1366
1367 let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1368 let channels = AtomicMap::new();
1369 channels.insert(instrument_id, OKXBookChannel::Book);
1370 let tracker = BookSyncTracker::default();
1371 tracker.record_subscription(instrument_id, time::Instant::now(), SnapshotGate::default());
1372 let recovery = tracker.claim_recovery(instrument_id).unwrap();
1373 assert!(recovery.begin_replacement());
1374
1375 let arg = OKXSubscriptionArg {
1376 channel: OKXWsChannel::Books,
1377 inst_id: Some(Ustr::from("BTC-USDT")),
1378 inst_type: None,
1379 inst_family: None,
1380 };
1381
1382 cmd_tx
1383 .send(HandlerCommand::Subscribe {
1384 args: vec![arg.clone()],
1385 })
1386 .unwrap();
1387
1388 assert_eq!(
1389 tokio::time::timeout(Duration::from_secs(3), wire_rx.recv())
1390 .await
1391 .unwrap()
1392 .unwrap(),
1393 "subscribe"
1394 );
1395
1396 for action in [OKXBookAction::Snapshot, OKXBookAction::Update] {
1397 let message = tokio::time::timeout(Duration::from_secs(3), messages_rx.recv())
1398 .await
1399 .unwrap()
1400 .unwrap();
1401 assert!(
1402 matches!(message, OKXWsMessage::BookData { action: received, .. } if received == action)
1403 );
1404 }
1405
1406 let (completion, result) = tokio::sync::oneshot::channel();
1407 cmd_tx
1408 .send(HandlerCommand::Resubscribe {
1409 subscription: arg.clone(),
1410 cancel: recovery.cancellation.child_token(),
1411 gate: recovery.gate.clone(),
1412 completion,
1413 })
1414 .unwrap();
1415
1416 assert_eq!(
1417 tokio::time::timeout(Duration::from_secs(3), wire_rx.recv())
1418 .await
1419 .unwrap()
1420 .unwrap(),
1421 "unsubscribe"
1422 );
1423
1424 tokio::time::pause();
1426 tracker.reset_sequences(&channels, BookChannelScope::Public);
1427
1428 if !snapshot_timeout.is_zero() {
1429 tracker.seed_pending_snapshots(
1430 &channels,
1431 BookChannelScope::Public,
1432 snapshot_timeout,
1433 time::Instant::now(),
1434 );
1435 }
1436
1437 tokio::time::advance(Duration::from_secs(10)).await;
1438 tokio::time::resume();
1439 let _ = tokio::time::timeout(Duration::from_secs(3), result)
1440 .await
1441 .expect("recovery command completes after reconnect");
1442
1443 let resumed = tokio::time::timeout(Duration::from_secs(3), async {
1444 for (action, sequence) in [(OKXBookAction::Snapshot, 100), (OKXBookAction::Update, 101)]
1445 {
1446 let Some(OKXWsMessage::BookData {
1447 action: received,
1448 data,
1449 ..
1450 }) = messages_rx.recv().await
1451 else {
1452 panic!("expected book data after reconnect");
1453 };
1454
1455 assert_eq!(received, action);
1456 assert_eq!(data[0].seq_id, sequence);
1457 assert_eq!(
1458 tracker.validate_sequence(
1459 instrument_id,
1460 received == OKXBookAction::Snapshot,
1461 &[(data[0].prev_seq_id, data[0].seq_id)],
1462 time::Instant::now(),
1463 ),
1464 BookSequenceOutcome::Accept,
1465 );
1466 }
1467 })
1468 .await;
1469
1470 if resumed.is_err() {
1472 cmd_tx
1473 .send(HandlerCommand::Subscribe { args: vec![arg] })
1474 .unwrap();
1475
1476 for action in [OKXBookAction::Snapshot, OKXBookAction::Update] {
1477 let message = tokio::time::timeout(Duration::from_secs(3), messages_rx.recv())
1478 .await
1479 .unwrap()
1480 .unwrap();
1481 assert!(
1482 matches!(message, OKXWsMessage::BookData { action: received, .. } if received == action)
1483 );
1484 }
1485 }
1486
1487 cmd_tx.send(HandlerCommand::Disconnect).unwrap();
1488 tokio::time::timeout(Duration::from_secs(3), running)
1489 .await
1490 .unwrap()
1491 .unwrap();
1492 server.abort();
1493
1494 assert!(
1495 resumed.is_ok(),
1496 "reconnect must preserve an in-flight recovery and resume book output"
1497 );
1498 }
1499
1500 #[rstest]
1501 fn test_should_retry_typed_transport_and_timeout_errors() {
1502 assert!(!should_retry_replay_safe_error(&OKXWsError::TransportSend(
1503 SendError::BufferFull
1504 )));
1505 assert!(should_retry_replay_safe_error(&OKXWsError::TransportSend(
1506 SendError::Timeout
1507 )));
1508 assert!(should_retry_replay_safe_error(&OKXWsError::TransportSend(
1509 SendError::ConnectionChanged
1510 )));
1511 assert!(!should_retry_replay_safe_error(&OKXWsError::TransportSend(
1512 SendError::WriteTimeout
1513 )));
1514 assert!(!should_retry_replay_safe_error(&OKXWsError::TransportSend(
1515 SendError::BrokenPipe("connection reset".to_string())
1516 )));
1517 assert!(should_retry_replay_safe_error(
1518 &OKXWsError::OperationTimeout { timeout_ms: 1_000 }
1519 ));
1520 assert!(!should_retry_replay_safe_error(&OKXWsError::NoActiveClient));
1521 assert!(!should_retry_replay_safe_error(
1522 &OKXWsError::HandlerUnavailable("closed".to_string())
1523 ));
1524 }
1525
1526 #[rstest]
1527 fn test_retryability_uses_websocket_error_type_not_message() {
1528 let message = "connection reset".to_string();
1529 let temporary = OKXWsError::OkxError {
1530 error_code: "50011".to_string(),
1531 message: message.clone(),
1532 };
1533 let permanent = OKXWsError::ClientError(message.clone());
1534 let ambiguous = OKXWsError::SendFailed(message);
1535
1536 assert!(should_retry_replay_safe_error(&temporary));
1537 assert!(!should_retry_replay_safe_error(&permanent));
1538 assert!(!should_retry_replay_safe_error(&ambiguous));
1539 }
1540
1541 #[rstest]
1542 fn test_subscription_error_restores_failed_unsubscribe() {
1543 let handler = create_handler();
1544 let arg = OKXWebSocketArg {
1545 channel: OKXWsChannel::Books,
1546 inst_id: Some(Ustr::from("BTC-USD")),
1547 inst_type: None,
1548 inst_family: None,
1549 bar: None,
1550 };
1551 let topic = topic_from_websocket_arg(&arg);
1552 handler.subscriptions_state.mark_subscribe(&topic);
1553 handler.subscriptions_state.confirm_subscribe(&topic);
1554 handler.subscriptions_state.mark_unsubscribe(&topic);
1555
1556 let rejected_subscription =
1557 handler.handle_subscription_error(&arg, "60019", "Unsubscription failed");
1558
1559 assert!(!rejected_subscription);
1560 assert_eq!(handler.subscriptions_state.all_topics(), vec![topic]);
1561 assert!(
1562 handler
1563 .subscriptions_state
1564 .pending_subscribe_topics()
1565 .is_empty()
1566 );
1567 assert!(
1568 handler
1569 .subscriptions_state
1570 .pending_unsubscribe_topics()
1571 .is_empty()
1572 );
1573 }
1574
1575 #[rstest]
1576 fn test_subscription_arg_from_error_message_matches_mainnet_shape() {
1577 let msg = "Wrong URL or channel:books,instId:BTC-USDT-SWAP doesn't exist. Please use the \
1578 correct URL, channel and parameters referring to API document.";
1579
1580 let arg = subscription_arg_from_error_message(msg).unwrap();
1581
1582 assert_eq!(arg.channel, OKXWsChannel::Books);
1583 assert_eq!(arg.inst_id, Some(Ustr::from("BTC-USDT-SWAP")));
1584 assert_eq!(arg.inst_type, None);
1585 assert_eq!(arg.inst_family, None);
1586 assert_eq!(arg.bar, None);
1587 }
1588
1589 #[rstest]
1590 #[case::book_arg(
1591 Some(OKXWsChannel::Books),
1592 "60018",
1593 "Subscription failed",
1594 log::Level::Warn
1595 )]
1596 #[case::book_from_message(
1597 None,
1598 "60018",
1599 "Wrong URL or channel:books,instId:BTC-USDT-SWAP doesn't exist.",
1600 log::Level::Warn
1601 )]
1602 #[case::retryable(
1603 Some(OKXWsChannel::Tickers),
1604 "50001",
1605 "Service unavailable",
1606 log::Level::Warn
1607 )]
1608 #[case::non_book_arg(
1609 Some(OKXWsChannel::Tickers),
1610 "60018",
1611 "Subscription failed",
1612 log::Level::Error
1613 )]
1614 #[case::non_book_from_message(
1615 None,
1616 "60018",
1617 "Wrong URL or channel:tickers,instId:BTC-USDT-SWAP doesn't exist.",
1618 log::Level::Error
1619 )]
1620 fn test_error_frame_level(
1621 #[case] channel: Option<OKXWsChannel>,
1622 #[case] code: &str,
1623 #[case] msg: &str,
1624 #[case] expected: log::Level,
1625 ) {
1626 let arg = channel.map(|channel| OKXWebSocketArg {
1627 channel,
1628 inst_id: Some(Ustr::from("BTC-USDT-SWAP")),
1629 inst_type: None,
1630 inst_family: None,
1631 bar: None,
1632 });
1633
1634 let level = error_frame_level(arg.as_ref(), code, msg);
1635
1636 assert_eq!(level, expected);
1637 }
1638
1639 #[rstest]
1640 fn test_subscription_error_ignores_non_pending_topic() {
1641 let handler = create_handler();
1642 let arg = OKXWebSocketArg {
1643 channel: OKXWsChannel::Books,
1644 inst_id: Some(Ustr::from("BTC-USDT-SWAP")),
1645 inst_type: None,
1646 inst_family: None,
1647 bar: None,
1648 };
1649
1650 let rejected_subscription =
1651 handler.handle_subscription_error(&arg, "60018", "Subscription failed");
1652
1653 assert!(!rejected_subscription);
1654 assert!(handler.subscriptions_state.all_topics().is_empty());
1655 assert!(
1656 handler
1657 .subscriptions_state
1658 .pending_subscribe_topics()
1659 .is_empty()
1660 );
1661 assert!(
1662 handler
1663 .subscriptions_state
1664 .pending_unsubscribe_topics()
1665 .is_empty()
1666 );
1667 }
1668
1669 #[derive(serde::Deserialize, Debug, PartialEq)]
1670 struct ParseArrayItem {
1671 value: i64,
1672 }
1673
1674 #[rstest]
1675 fn test_parse_array_items_keeps_good_items_when_one_fails() {
1676 let data = json!([
1677 {"value": 1},
1678 {"value": "not a number"},
1679 {"value": 3},
1680 ]);
1681
1682 let parsed: Vec<ParseArrayItem> =
1683 parse_array_items(data, "test", false).expect("non-empty");
1684 assert_eq!(
1685 parsed,
1686 vec![ParseArrayItem { value: 1 }, ParseArrayItem { value: 3 }],
1687 );
1688 }
1689
1690 #[rstest]
1691 fn test_parse_array_items_returns_none_when_payload_not_array() {
1692 let data = json!({"not": "an array"});
1693 let parsed: Option<Vec<ParseArrayItem>> = parse_array_items(data, "test", false);
1694 assert!(parsed.is_none());
1695 }
1696
1697 #[rstest]
1698 fn test_parse_array_items_returns_none_when_all_items_fail() {
1699 let data = json!([{"value": "bad"}]);
1700 let parsed: Option<Vec<ParseArrayItem>> = parse_array_items(data, "test", false);
1701 assert!(parsed.is_none());
1702 }
1703
1704 #[rstest]
1705 fn test_route_instruments_keeps_valid_items_when_one_item_fails() {
1706 let handler = create_handler();
1707 let mut frame: Value =
1708 serde_json::from_str(&load_test_json("ws_instruments.json")).expect("valid fixture");
1709 let data = frame
1710 .get_mut("data")
1711 .and_then(Value::as_array_mut)
1712 .expect("data array");
1713 let mut invalid_item = data[0].clone();
1714 invalid_item["tickSz"] = json!(7);
1715 data.insert(0, invalid_item);
1716
1717 let arg: OKXWebSocketArg = serde_json::from_value(frame["arg"].clone()).expect("valid arg");
1718 let msg = handler
1719 .route_data_message(arg, frame["data"].clone())
1720 .expect("instruments message");
1721
1722 match msg {
1723 OKXWsMessage::Instruments(instruments) => {
1724 assert_eq!(instruments.len(), 1);
1725 assert_eq!(instruments[0].inst_id, "BTC-USDT-SWAP");
1726 }
1727 other => panic!("Expected Instruments, was {other:?}"),
1728 }
1729 }
1730
1731 #[rstest]
1732 fn test_route_instruments_prefers_rpi_over_legacy_alias() {
1733 let handler = create_handler();
1734 let mut frame: Value =
1735 serde_json::from_str(&load_test_json("ws_instruments.json")).expect("valid fixture");
1736 let instrument = &mut frame["data"][0];
1737 instrument["rpi"] = json!("2");
1738 instrument["elp"] = json!("1");
1739
1740 let arg: OKXWebSocketArg = serde_json::from_value(frame["arg"].clone()).expect("valid arg");
1741 let msg = handler
1742 .route_data_message(arg, frame["data"].clone())
1743 .expect("instruments message");
1744
1745 match msg {
1746 OKXWsMessage::Instruments(instruments) => {
1747 assert_eq!(instruments.len(), 1);
1748 assert_eq!(instruments[0].rpi, Some(OKXRpiPermission::Permitted));
1749 }
1750 other => panic!("Expected Instruments, was {other:?}"),
1751 }
1752 }
1753
1754 #[rstest]
1755 fn test_route_liquidation_warnings() {
1756 let handler = create_handler();
1757 let frame: Value = serde_json::from_str(&load_test_json("ws_liquidation_warning.json"))
1758 .expect("valid fixture");
1759
1760 let arg: OKXWebSocketArg = serde_json::from_value(frame["arg"].clone()).expect("valid arg");
1761 let msg = handler
1762 .route_data_message(arg, frame["data"].clone())
1763 .expect("liquidation warning message");
1764
1765 match msg {
1766 OKXWsMessage::LiquidationWarnings(warnings) => {
1767 assert_eq!(warnings.len(), 1);
1768 assert_eq!(warnings[0].inst_id, "BTC-USDT-SWAP");
1769 assert_eq!(warnings[0].mgn_ratio, "0.62");
1770 }
1771 other => panic!("Expected LiquidationWarnings, was {other:?}"),
1772 }
1773 }
1774}