1use std::time::Duration;
10use thiserror::Error;
11
12#[non_exhaustive]
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum WebSocketErrorKind {
32 Protocol,
36 Capacity,
39 Utf8,
42 Tls,
45 Io,
48 Http(u16),
72 Other,
75}
76
77#[non_exhaustive]
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum ErrorKind {
88 Network,
92 Protocol,
101 Auth,
104 RateLimit,
110 Client,
115}
116
117#[derive(Error, Debug)]
119pub enum MarketDataError {
120 #[error("Invalid symbol: {symbol}")]
122 InvalidSymbol {
123 symbol: String,
125 },
126
127 #[error("Invalid parameter '{name}': {reason}")]
129 InvalidParameter {
130 name: String,
132 reason: String,
134 },
135
136 #[error("Deserialization failed: {source}")]
138 DeserializationError {
139 #[from]
141 source: serde_json::Error,
142 },
143
144 #[error("Runtime error: {msg}")]
146 RuntimeError {
147 msg: String,
149 },
150
151 #[error("Configuration error: {0}")]
153 ConfigError(
154 String,
156 ),
157
158 #[error("Connection error: {msg}")]
160 ConnectionError {
161 msg: String,
163 },
164
165 #[error("Authentication error: {msg}")]
167 AuthError {
168 msg: String,
170 },
171
172 #[error("API error (status {status}): {message}")]
174 ApiError {
175 status: u16,
177 message: String,
179 },
180
181 #[error("Timeout error: {operation}")]
183 TimeoutError {
184 operation: String,
186 },
187
188 #[error("WebSocket error ({kind:?}): {msg}")]
190 WebSocketError {
191 kind: WebSocketErrorKind,
195 msg: String,
197 },
198
199 #[error("Heartbeat timeout: no inbound frames for {elapsed:?}")]
202 HeartbeatTimeout {
203 elapsed: Duration,
205 },
206
207 #[error("Client already closed")]
209 ClientClosed,
210
211 #[error(transparent)]
213 Other(
214 #[from]
216 anyhow::Error,
217 ),
218}
219
220impl From<tungstenite::Error> for MarketDataError {
221 fn from(err: tungstenite::Error) -> Self {
222 use tungstenite::Error as WsError;
223
224 let (kind, msg) = match err {
231 WsError::ConnectionClosed | WsError::AlreadyClosed | WsError::Io(_) => (
232 WebSocketErrorKind::Io,
233 format!("WebSocket transport error: {}", err),
234 ),
235 WsError::Protocol(_) => (
236 WebSocketErrorKind::Protocol,
237 format!("WebSocket protocol violation: {}", err),
238 ),
239 WsError::Capacity(_) => (
240 WebSocketErrorKind::Capacity,
241 format!("WebSocket capacity exceeded: {}", err),
242 ),
243 WsError::Utf8(_) => (
244 WebSocketErrorKind::Utf8,
245 format!("WebSocket UTF-8 decode failure: {}", err),
246 ),
247 WsError::Tls(_) => (
248 WebSocketErrorKind::Tls,
249 format!("TLS/certificate error: {}", err),
250 ),
251 WsError::Http(response) => {
252 let status = response.status().as_u16();
253 (
254 WebSocketErrorKind::Http(status),
255 format!("HTTP {} during WebSocket handshake", status),
256 )
257 }
258 _ => (
259 WebSocketErrorKind::Other,
260 format!("WebSocket error: {}", err),
261 ),
262 };
263 Self::WebSocketError { kind, msg }
264 }
265}
266
267impl MarketDataError {
268 #[must_use]
291 pub fn source_kind(&self) -> ErrorKind {
292 match self {
293 Self::ConnectionError { .. }
294 | Self::TimeoutError { .. }
295 | Self::HeartbeatTimeout { .. } => ErrorKind::Network,
296 Self::WebSocketError { kind, .. } => match kind {
297 WebSocketErrorKind::Protocol
298 | WebSocketErrorKind::Capacity
299 | WebSocketErrorKind::Utf8
300 | WebSocketErrorKind::Other => ErrorKind::Protocol,
301 WebSocketErrorKind::Tls => ErrorKind::Auth,
302 WebSocketErrorKind::Io => ErrorKind::Network,
303 WebSocketErrorKind::Http(status) => match *status {
304 401 | 403 => ErrorKind::Auth,
305 429 => ErrorKind::RateLimit,
306 500..=599 => ErrorKind::Network,
307 _ => ErrorKind::Client,
308 },
309 },
310 Self::AuthError { .. } => ErrorKind::Auth,
311 Self::ApiError { status, .. } => match *status {
312 401 | 403 => ErrorKind::Auth,
313 429 => ErrorKind::RateLimit,
314 500..=599 => ErrorKind::Network,
315 _ => ErrorKind::Client,
316 },
317 Self::InvalidSymbol { .. }
318 | Self::InvalidParameter { .. }
319 | Self::ConfigError(_)
320 | Self::DeserializationError { .. }
321 | Self::ClientClosed
322 | Self::RuntimeError { .. }
323 | Self::Other(_) => ErrorKind::Client,
324 }
325 }
326
327 pub fn to_error_code(&self) -> i32 {
329 match self {
330 Self::InvalidSymbol { .. } => 1001,
331 Self::InvalidParameter { .. } => 1005,
332 Self::DeserializationError { .. } => 1002,
333 Self::RuntimeError { .. } => 1003,
334 Self::ConfigError(_) => 1004,
335 Self::ConnectionError { .. } => 2001,
336 Self::AuthError { .. } => 2002,
337 Self::ApiError { .. } => 2003,
338 Self::TimeoutError { .. } => 3001,
339 Self::WebSocketError { .. } => 3002,
340 Self::HeartbeatTimeout { .. } => 3003,
341 Self::ClientClosed => 2010,
342 Self::Other(_) => 9999,
343 }
344 }
345
346 pub fn is_retryable(&self) -> bool {
365 match self {
366 Self::ConnectionError { .. }
368 | Self::TimeoutError { .. }
369 | Self::HeartbeatTimeout { .. } => true,
370 Self::WebSocketError { kind, .. } => match kind {
372 WebSocketErrorKind::Io | WebSocketErrorKind::Other => true,
373 WebSocketErrorKind::Http(status) => {
374 *status == 429 || (500..=599).contains(status)
375 }
376 WebSocketErrorKind::Protocol
377 | WebSocketErrorKind::Capacity
378 | WebSocketErrorKind::Utf8
379 | WebSocketErrorKind::Tls => false,
380 },
381 Self::ApiError { status, .. } => *status == 429 || (500..=599).contains(status),
383 Self::InvalidParameter { .. } => false,
385 _ => false,
387 }
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_error_display() {
397 let err = MarketDataError::InvalidSymbol {
398 symbol: "INVALID".to_string(),
399 };
400 assert_eq!(err.to_string(), "Invalid symbol: INVALID");
401
402 let err = MarketDataError::RuntimeError {
403 msg: "test message".to_string(),
404 };
405 assert_eq!(err.to_string(), "Runtime error: test message");
406
407 let err = MarketDataError::ConfigError("missing key".to_string());
408 assert_eq!(err.to_string(), "Configuration error: missing key");
409
410 let err = MarketDataError::ApiError {
411 status: 404,
412 message: "not found".to_string(),
413 };
414 assert_eq!(err.to_string(), "API error (status 404): not found");
415
416 let err = MarketDataError::ClientClosed;
417 assert_eq!(err.to_string(), "Client already closed");
418 }
419
420 #[test]
421 fn test_error_codes() {
422 let err = MarketDataError::InvalidSymbol {
423 symbol: "test".to_string(),
424 };
425 assert_eq!(err.to_error_code(), 1001);
426
427 let err = MarketDataError::RuntimeError {
428 msg: "test".to_string(),
429 };
430 assert_eq!(err.to_error_code(), 1003);
431
432 let err = MarketDataError::ConfigError("test".to_string());
433 assert_eq!(err.to_error_code(), 1004);
434
435 let err = MarketDataError::ConnectionError {
436 msg: "test".to_string(),
437 };
438 assert_eq!(err.to_error_code(), 2001);
439
440 let err = MarketDataError::AuthError {
441 msg: "test".to_string(),
442 };
443 assert_eq!(err.to_error_code(), 2002);
444
445 let err = MarketDataError::ApiError {
446 status: 500,
447 message: "test".to_string(),
448 };
449 assert_eq!(err.to_error_code(), 2003);
450
451 let err = MarketDataError::TimeoutError {
452 operation: "test".to_string(),
453 };
454 assert_eq!(err.to_error_code(), 3001);
455
456 let err = MarketDataError::WebSocketError {
457 kind: WebSocketErrorKind::Protocol,
458 msg: "test".to_string(),
459 };
460 assert_eq!(err.to_error_code(), 3002);
461
462 let err = MarketDataError::HeartbeatTimeout {
463 elapsed: Duration::from_secs(35),
464 };
465 assert_eq!(err.to_error_code(), 3003);
466
467 let err = MarketDataError::ClientClosed;
468 assert_eq!(err.to_error_code(), 2010);
469
470 let err = MarketDataError::Other(anyhow::anyhow!("test"));
471 assert_eq!(err.to_error_code(), 9999);
472 }
473
474 #[test]
475 fn test_retryable_classification() {
476 let err = MarketDataError::ConnectionError {
478 msg: "test".to_string(),
479 };
480 assert!(err.is_retryable());
481
482 let err = MarketDataError::TimeoutError {
483 operation: "test".to_string(),
484 };
485 assert!(err.is_retryable());
486
487 let err = MarketDataError::WebSocketError {
489 kind: WebSocketErrorKind::Io,
490 msg: "reset".to_string(),
491 };
492 assert!(err.is_retryable());
493 let err = MarketDataError::WebSocketError {
494 kind: WebSocketErrorKind::Protocol,
495 msg: "frame".to_string(),
496 };
497 assert!(!err.is_retryable());
498
499 let err = MarketDataError::HeartbeatTimeout {
500 elapsed: Duration::from_secs(35),
501 };
502 assert!(err.is_retryable());
503
504 let err = MarketDataError::InvalidSymbol {
506 symbol: "test".to_string(),
507 };
508 assert!(!err.is_retryable());
509
510 let err = MarketDataError::RuntimeError {
511 msg: "test".to_string(),
512 };
513 assert!(!err.is_retryable());
514
515 let err = MarketDataError::ConfigError("test".to_string());
516 assert!(!err.is_retryable());
517
518 let err = MarketDataError::AuthError {
519 msg: "test".to_string(),
520 };
521 assert!(!err.is_retryable());
522
523 let err = MarketDataError::ApiError {
524 status: 400,
525 message: "test".to_string(),
526 };
527 assert!(!err.is_retryable());
528
529 let err = MarketDataError::ApiError {
531 status: 429,
532 message: "rate limit".to_string(),
533 };
534 assert!(err.is_retryable());
535
536 let err = MarketDataError::ApiError {
538 status: 503,
539 message: "service unavailable".to_string(),
540 };
541 assert!(err.is_retryable());
542
543 let err = MarketDataError::ClientClosed;
544 assert!(!err.is_retryable());
545
546 let err = MarketDataError::Other(anyhow::anyhow!("test"));
547 assert!(!err.is_retryable());
548 }
549
550 #[test]
551 fn test_heartbeat_timeout_display() {
552 let err = MarketDataError::HeartbeatTimeout {
553 elapsed: Duration::from_secs(35),
554 };
555 assert!(err.to_string().contains("35s"));
556 assert!(err.to_string().starts_with("Heartbeat timeout"));
557 }
558
559 #[test]
560 fn test_from_serde_json_error() {
561 let json_err = serde_json::from_str::<serde_json::Value>("{invalid json")
562 .unwrap_err();
563 let err: MarketDataError = json_err.into();
564
565 assert_eq!(err.to_error_code(), 1002);
566 assert!(matches!(err, MarketDataError::DeserializationError { .. }));
567 }
568
569 #[test]
570 fn test_from_anyhow_error() {
571 let anyhow_err = anyhow::anyhow!("test error");
572 let err: MarketDataError = anyhow_err.into();
573
574 assert_eq!(err.to_error_code(), 9999);
575 assert!(matches!(err, MarketDataError::Other(_)));
576 }
577
578 #[test]
579 fn test_from_tungstenite_connection_closed() {
580 use tokio_tungstenite::tungstenite::Error as WsError;
583
584 let ws_err = WsError::ConnectionClosed;
585 let err: MarketDataError = ws_err.into();
586
587 assert_eq!(err.to_error_code(), 3002);
588 assert!(matches!(
589 err,
590 MarketDataError::WebSocketError {
591 kind: WebSocketErrorKind::Io,
592 ..
593 }
594 ));
595 assert!(err.is_retryable());
596 }
597
598 #[test]
599 fn test_from_tungstenite_protocol_error() {
600 use tokio_tungstenite::tungstenite::Error as WsError;
602 use tokio_tungstenite::tungstenite::error::ProtocolError;
603
604 let ws_err = WsError::Protocol(ProtocolError::ResetWithoutClosingHandshake);
605 let err: MarketDataError = ws_err.into();
606
607 assert_eq!(err.to_error_code(), 3002);
608 assert!(matches!(
609 err,
610 MarketDataError::WebSocketError {
611 kind: WebSocketErrorKind::Protocol,
612 ..
613 }
614 ));
615 assert!(
616 !err.is_retryable(),
617 "Protocol violations must not retry (0.6.0+); retry against the same SDK + server combo will keep failing"
618 );
619 }
620
621 #[test]
622 fn test_from_tungstenite_already_closed() {
623 use tokio_tungstenite::tungstenite::Error as WsError;
624
625 let ws_err = WsError::AlreadyClosed;
626 let err: MarketDataError = ws_err.into();
627
628 assert_eq!(err.to_error_code(), 3002);
629 assert!(matches!(err, MarketDataError::WebSocketError { .. }));
630 }
631
632 #[test]
635 fn source_kind_network_for_transport_failures() {
636 let err = MarketDataError::ConnectionError {
637 msg: "reset".to_string(),
638 };
639 assert_eq!(err.source_kind(), ErrorKind::Network);
640
641 let err = MarketDataError::TimeoutError {
642 operation: "read".to_string(),
643 };
644 assert_eq!(err.source_kind(), ErrorKind::Network);
645
646 let err = MarketDataError::HeartbeatTimeout {
647 elapsed: Duration::from_secs(35),
648 };
649 assert_eq!(err.source_kind(), ErrorKind::Network);
650 }
651
652 #[test]
653 fn source_kind_for_websocket_protocol_kind() {
654 let err = MarketDataError::WebSocketError {
655 kind: WebSocketErrorKind::Protocol,
656 msg: "frame".to_string(),
657 };
658 assert_eq!(err.source_kind(), ErrorKind::Protocol);
659 }
660
661 #[test]
662 fn source_kind_for_websocket_io_routes_to_network() {
663 let err = MarketDataError::WebSocketError {
664 kind: WebSocketErrorKind::Io,
665 msg: "reset".to_string(),
666 };
667 assert_eq!(err.source_kind(), ErrorKind::Network);
668 }
669
670 #[test]
671 fn source_kind_for_websocket_tls_routes_to_auth() {
672 let err = MarketDataError::WebSocketError {
673 kind: WebSocketErrorKind::Tls,
674 msg: "cert".to_string(),
675 };
676 assert_eq!(err.source_kind(), ErrorKind::Auth);
677 }
678
679 #[test]
680 fn source_kind_for_websocket_http_401_routes_to_auth() {
681 let err = MarketDataError::WebSocketError {
682 kind: WebSocketErrorKind::Http(401),
683 msg: "unauthorized".to_string(),
684 };
685 assert_eq!(err.source_kind(), ErrorKind::Auth);
686 }
687
688 #[test]
689 fn source_kind_for_websocket_http_429_routes_to_rate_limit() {
690 let err = MarketDataError::WebSocketError {
691 kind: WebSocketErrorKind::Http(429),
692 msg: "throttle".to_string(),
693 };
694 assert_eq!(err.source_kind(), ErrorKind::RateLimit);
695 }
696
697 #[test]
698 fn tungstenite_protocol_routes_to_protocol_kind() {
699 use tokio_tungstenite::tungstenite::error::ProtocolError;
700 use tokio_tungstenite::tungstenite::Error as WsError;
701 let ws_err = WsError::Protocol(ProtocolError::ResetWithoutClosingHandshake);
702 let err: MarketDataError = ws_err.into();
703 match err {
704 MarketDataError::WebSocketError { kind, .. } => {
705 assert_eq!(kind, WebSocketErrorKind::Protocol);
706 }
707 other => panic!("expected WebSocketError, got {other:?}"),
708 }
709 }
710
711 #[test]
712 fn tungstenite_io_routes_to_io_kind() {
713 use std::io;
714 use tokio_tungstenite::tungstenite::Error as WsError;
715 let ws_err = WsError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "reset"));
716 let err: MarketDataError = ws_err.into();
717 match err {
718 MarketDataError::WebSocketError { kind, .. } => {
719 assert_eq!(kind, WebSocketErrorKind::Io);
720 }
721 other => panic!("expected WebSocketError, got {other:?}"),
722 }
723 }
724
725 #[test]
726 fn source_kind_auth_for_401_403_api_errors() {
727 let err = MarketDataError::ApiError {
728 status: 401,
729 message: "unauthorized".to_string(),
730 };
731 assert_eq!(err.source_kind(), ErrorKind::Auth);
732
733 let err = MarketDataError::ApiError {
734 status: 403,
735 message: "forbidden".to_string(),
736 };
737 assert_eq!(err.source_kind(), ErrorKind::Auth);
738
739 let err = MarketDataError::AuthError {
740 msg: "bad token".to_string(),
741 };
742 assert_eq!(err.source_kind(), ErrorKind::Auth);
743 }
744
745 #[test]
746 fn source_kind_network_for_5xx() {
747 let err = MarketDataError::ApiError {
748 status: 503,
749 message: "service unavailable".to_string(),
750 };
751 assert_eq!(err.source_kind(), ErrorKind::Network);
752
753 let err = MarketDataError::ApiError {
754 status: 500,
755 message: "internal".to_string(),
756 };
757 assert_eq!(err.source_kind(), ErrorKind::Network);
758 }
759
760 #[test]
761 fn source_kind_rate_limit_for_429() {
762 let err = MarketDataError::ApiError {
766 status: 429,
767 message: "rate limit".to_string(),
768 };
769 assert_eq!(err.source_kind(), ErrorKind::RateLimit);
770 }
771
772 #[test]
773 fn source_kind_client_for_validation_failures() {
774 let err = MarketDataError::InvalidParameter {
775 name: "symbol".to_string(),
776 reason: "empty".to_string(),
777 };
778 assert_eq!(err.source_kind(), ErrorKind::Client);
779
780 let err = MarketDataError::InvalidSymbol {
781 symbol: "?".to_string(),
782 };
783 assert_eq!(err.source_kind(), ErrorKind::Client);
784
785 let err = MarketDataError::ConfigError("bad".to_string());
786 assert_eq!(err.source_kind(), ErrorKind::Client);
787
788 let err = MarketDataError::ClientClosed;
789 assert_eq!(err.source_kind(), ErrorKind::Client);
790 }
791
792 #[test]
793 fn source_kind_client_for_4xx_excl_auth() {
794 let err = MarketDataError::ApiError {
795 status: 404,
796 message: "not found".to_string(),
797 };
798 assert_eq!(err.source_kind(), ErrorKind::Client);
799
800 let err = MarketDataError::ApiError {
801 status: 400,
802 message: "bad request".to_string(),
803 };
804 assert_eq!(err.source_kind(), ErrorKind::Client);
805 }
806
807 #[test]
812 fn error_kind_variants_exist() {
813 fn classify(k: ErrorKind) -> u8 {
814 match k {
815 ErrorKind::Network => 1,
816 ErrorKind::Protocol => 2,
817 ErrorKind::Auth => 3,
818 ErrorKind::RateLimit => 4,
819 ErrorKind::Client => 5,
820 }
821 }
822 assert_eq!(classify(ErrorKind::Network), 1);
823 assert_eq!(classify(ErrorKind::Protocol), 2);
824 assert_eq!(classify(ErrorKind::Auth), 3);
825 assert_eq!(classify(ErrorKind::RateLimit), 4);
826 assert_eq!(classify(ErrorKind::Client), 5);
827 }
828}
829
830#[cfg(test)]
831mod http_mapping_consistency {
832 use super::{ErrorKind, MarketDataError, WebSocketErrorKind};
840
841 fn ws_err(status: u16) -> MarketDataError {
842 MarketDataError::WebSocketError {
843 kind: WebSocketErrorKind::Http(status),
844 msg: format!("HTTP {} during WebSocket handshake", status),
845 }
846 }
847
848 const HTTP_TABLE: &[(u16, ErrorKind, bool)] = &[
851 (401, ErrorKind::Auth, false),
852 (403, ErrorKind::Auth, false),
853 (404, ErrorKind::Client, false),
854 (429, ErrorKind::RateLimit, true),
855 (500, ErrorKind::Network, true),
856 (503, ErrorKind::Network, true),
857 (999, ErrorKind::Client, false),
858 ];
859
860 #[test]
861 fn http_status_mapping_matches_doc_table() {
862 for &(status, expected_kind, expected_retryable) in HTTP_TABLE {
863 let err = ws_err(status);
864 assert_eq!(
865 err.source_kind(),
866 expected_kind,
867 "HTTP {status}: source_kind() mismatch with documented table on WebSocketErrorKind::Http"
868 );
869 assert_eq!(
870 err.is_retryable(),
871 expected_retryable,
872 "HTTP {status}: is_retryable() mismatch with documented table on WebSocketErrorKind::Http"
873 );
874 }
875 }
876}