1#[derive(Debug, thiserror::Error)]
4pub enum StreamError {
5 #[error("WebSocket connection failed to '{url}': {reason}")]
7 ConnectionFailed { url: String, reason: String },
8
9 #[error("WebSocket disconnected from '{url}'")]
11 Disconnected { url: String },
12
13 #[error("Reconnection exhausted after {attempts} attempts to '{url}'")]
15 ReconnectExhausted { url: String, attempts: u32 },
16
17 #[error("Tick parse error from {exchange}: {reason}")]
19 ParseError { exchange: String, reason: String },
20
21 #[error("Feed '{feed_id}' is stale: last tick was {elapsed_ms}ms ago (threshold: {threshold_ms}ms)")]
23 StaleFeed { feed_id: String, elapsed_ms: u64, threshold_ms: u64 },
24
25 #[error("Order book reconstruction failed for '{symbol}': {reason}")]
27 BookReconstructionFailed { symbol: String, reason: String },
28
29 #[error("Order book crossed for '{symbol}': best bid {bid} >= best ask {ask}")]
31 BookCrossed { symbol: String, bid: String, ask: String },
32
33 #[error("Backpressure on channel '{channel}': {depth}/{capacity} slots used")]
35 Backpressure { channel: String, depth: usize, capacity: usize },
36
37 #[error("Unknown exchange format: '{0}'")]
39 UnknownExchange(String),
40
41 #[error("I/O error: {0}")]
43 Io(String),
44
45 #[error("WebSocket error: {0}")]
47 WebSocket(String),
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53
54 #[test]
55 fn test_connection_failed_display() {
56 let e = StreamError::ConnectionFailed {
57 url: "wss://example.com".into(),
58 reason: "timeout".into(),
59 };
60 assert!(e.to_string().contains("example.com"));
61 assert!(e.to_string().contains("timeout"));
62 }
63
64 #[test]
65 fn test_disconnected_display() {
66 let e = StreamError::Disconnected { url: "wss://feed.io".into() };
67 assert!(e.to_string().contains("feed.io"));
68 }
69
70 #[test]
71 fn test_reconnect_exhausted_display() {
72 let e = StreamError::ReconnectExhausted { url: "wss://x.io".into(), attempts: 5 };
73 assert!(e.to_string().contains("5"));
74 }
75
76 #[test]
77 fn test_parse_error_display() {
78 let e = StreamError::ParseError { exchange: "Binance".into(), reason: "missing field".into() };
79 assert!(e.to_string().contains("Binance"));
80 }
81
82 #[test]
83 fn test_stale_feed_display() {
84 let e = StreamError::StaleFeed {
85 feed_id: "BTC-USD".into(),
86 elapsed_ms: 5000,
87 threshold_ms: 2000,
88 };
89 assert!(e.to_string().contains("BTC-USD"));
90 assert!(e.to_string().contains("5000"));
91 }
92
93 #[test]
94 fn test_book_reconstruction_failed_display() {
95 let e = StreamError::BookReconstructionFailed {
96 symbol: "ETH-USD".into(),
97 reason: "gap in sequence".into(),
98 };
99 assert!(e.to_string().contains("ETH-USD"));
100 }
101
102 #[test]
103 fn test_book_crossed_display() {
104 let e = StreamError::BookCrossed {
105 symbol: "BTC-USD".into(),
106 bid: "50001".into(),
107 ask: "50000".into(),
108 };
109 assert!(e.to_string().contains("crossed"));
110 }
111
112 #[test]
113 fn test_backpressure_display() {
114 let e = StreamError::Backpressure { channel: "ticks".into(), depth: 1000, capacity: 1000 };
115 assert!(e.to_string().contains("1000"));
116 }
117
118 #[test]
119 fn test_unknown_exchange_display() {
120 let e = StreamError::UnknownExchange("Kraken".into());
121 assert!(e.to_string().contains("Kraken"));
122 }
123}