Skip to main content

marketdata_core/
errors.rs

1//! Error types for marketdata-core
2//!
3//! Error code ranges:
4//! - 1000-1999: Client errors (bad input, deserialization)
5//! - 2000-2999: Server/API errors (auth, connection, HTTP)
6//! - 3000-3999: Network errors (timeout, WebSocket)
7//! - 9000-9999: Internal errors (unexpected failures)
8
9use std::time::Duration;
10use thiserror::Error;
11
12/// Coarse-grained classification of the source of a [`MarketDataError`].
13///
14/// Returned by [`MarketDataError::source_kind`] so downstream code can branch
15/// on the *category* of failure (network glitch vs SDK / protocol bug vs
16/// auth vs caller-side validation) without pattern-matching every variant or
17/// string-matching the embedded `msg`.
18///
19/// The enum is `#[non_exhaustive]` so future variants can be added in a
20/// minor release without breaking exhaustive matches.
21/// Refined classification of a [`MarketDataError::WebSocketError`].
22///
23/// Mirrors `tungstenite::Error`'s own categorization without leaking the
24/// upstream dependency type. Returned by pattern-matching the structured
25/// `kind` field on the variant.
26///
27/// The enum is `#[non_exhaustive]` so future `tungstenite` releases or
28/// new error sources can add variants without breaking exhaustive matches.
29#[non_exhaustive]
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum WebSocketErrorKind {
32    /// Protocol-level violation: malformed frame, illegal state transition,
33    /// reserved bits set. Never retryable — indicates an SDK / version
34    /// mismatch or a server-side bug.
35    Protocol,
36    /// Frame exceeded the configured `max_message_size` / `max_frame_size`.
37    /// Never retryable; the producer is misbehaving.
38    Capacity,
39    /// UTF-8 decoding failed on a text frame. Never retryable; the producer
40    /// emitted invalid bytes.
41    Utf8,
42    /// TLS / certificate failure during the WebSocket handshake. Treated as
43    /// authentication-adjacent — never retryable without operator action.
44    Tls,
45    /// Transport IO failure: connection reset, EOF, write error, etc.
46    /// Retryable with backoff.
47    Io,
48    /// HTTP error during the WebSocket upgrade. `u16` is the status code.
49    ///
50    /// # Status code mapping
51    ///
52    /// Authoritative grid for monitor / incident-classifier code that
53    /// branches on `WebSocketErrorKind::Http(_)`. [`MarketDataError::source_kind`]
54    /// and [`MarketDataError::is_retryable`] honour this exact mapping; the
55    /// `#[cfg(test)]` `http_mapping_consistency` module in this file pins
56    /// the doc-vs-impl contract so silent drift fails CI.
57    ///
58    /// | Status range | [`ErrorKind`] | [`is_retryable`](MarketDataError::is_retryable) |
59    /// |---|---|---|
60    /// | `401`, `403` | [`Auth`](ErrorKind::Auth) | `false` |
61    /// | `429` | [`RateLimit`](ErrorKind::RateLimit) | `true` |
62    /// | `500..=599` | [`Network`](ErrorKind::Network) | `true` |
63    /// | other 4xx (e.g. `404`) | [`Client`](ErrorKind::Client) | `false` |
64    /// | anything else | [`Client`](ErrorKind::Client) | `false` |
65    ///
66    /// `Auth` and `Client` failures are non-retryable because the upstream
67    /// rejection will not change between attempts; `RateLimit` and `5xx`
68    /// retry with the configured backoff. Other 4xx codes (including `404`)
69    /// surface as `Client` rather than `Network` because the server has
70    /// stated, conclusively, that the request is wrong.
71    Http(u16),
72    /// Anything `tungstenite` adds in the future, or an error we can't
73    /// classify. Retryable (conservative default).
74    Other,
75}
76
77/// Coarse-grained classification of the source of a [`MarketDataError`].
78///
79/// Returned by [`MarketDataError::source_kind`] so downstream code can
80/// branch on the *category* of failure (network glitch vs SDK / protocol
81/// bug vs auth vs rate-limit vs caller-side validation) without
82/// pattern-matching every variant or string-matching the embedded `msg`.
83///
84/// `#[non_exhaustive]` so future variants are non-breaking.
85#[non_exhaustive]
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum ErrorKind {
88    /// Transport-level transient failure: connection reset, timeout,
89    /// heartbeat gap, server outage (5xx). Generally safe to retry with
90    /// backoff.
91    Network,
92    /// Protocol-level violation or unclassified WebSocket failure. Indicates
93    /// an SDK / version mismatch or a server-side bug; retry is unlikely
94    /// to help.
95    ///
96    /// 0.5.1 maps **every** [`MarketDataError::WebSocketError`] to this
97    /// kind because the variant is currently string-only. 0.6.0 refines
98    /// the mapping when `WebSocketErrorKind` lands — IO failures will move
99    /// to [`ErrorKind::Network`] and TLS failures to [`ErrorKind::Auth`].
100    Protocol,
101    /// Authentication / authorization failure: bad credentials, 401/403,
102    /// expired token, TLS cert failure. Human intervention required.
103    Auth,
104    /// Server is rejecting requests because the caller is exceeding its
105    /// rate budget (HTTP 429). Distinct from [`ErrorKind::Network`] —
106    /// the correct response is to *reduce* request volume, not to assume
107    /// the upstream is degraded. Adding parallel retries makes this
108    /// strictly worse.
109    RateLimit,
110    /// Caller-side problem: invalid input, configuration error, client
111    /// already closed, serialization failure, non-auth/non-throttle 4xx.
112    /// The SDK can't recover from the caller's request without changes
113    /// from the caller's side.
114    Client,
115}
116
117/// Main error type for marketdata-core operations
118#[derive(Error, Debug)]
119pub enum MarketDataError {
120    /// Invalid symbol format or unsupported symbol
121    #[error("Invalid symbol: {symbol}")]
122    InvalidSymbol {
123        /// The offending symbol string that failed validation.
124        symbol: String,
125    },
126
127    /// Invalid or missing parameter
128    #[error("Invalid parameter '{name}': {reason}")]
129    InvalidParameter {
130        /// Parameter name that failed validation.
131        name: String,
132        /// Human-readable explanation of why the parameter was rejected.
133        reason: String,
134    },
135
136    /// JSON deserialization failed
137    #[error("Deserialization failed: {source}")]
138    DeserializationError {
139        /// Underlying `serde_json` error.
140        #[from]
141        source: serde_json::Error,
142    },
143
144    /// Runtime operation failed
145    #[error("Runtime error: {msg}")]
146    RuntimeError {
147        /// Diagnostic message describing the runtime failure.
148        msg: String,
149    },
150
151    /// Configuration error
152    #[error("Configuration error: {0}")]
153    ConfigError(
154        /// Diagnostic message identifying the misconfiguration.
155        String,
156    ),
157
158    /// Connection to server failed
159    #[error("Connection error: {msg}")]
160    ConnectionError {
161        /// Diagnostic message describing the connection failure.
162        msg: String,
163    },
164
165    /// Authentication failed
166    #[error("Authentication error: {msg}")]
167    AuthError {
168        /// Diagnostic message describing the authentication failure.
169        msg: String,
170    },
171
172    /// API returned error response
173    #[error("API error (status {status}): {message}")]
174    ApiError {
175        /// HTTP status code returned by the server.
176        status: u16,
177        /// Server-provided error message.
178        message: String,
179    },
180
181    /// Operation timed out
182    #[error("Timeout error: {operation}")]
183    TimeoutError {
184        /// Human-readable name of the operation that timed out.
185        operation: String,
186    },
187
188    /// WebSocket error
189    #[error("WebSocket error ({kind:?}): {msg}")]
190    WebSocketError {
191        /// Structured classification of the underlying WebSocket failure.
192        /// Branch on this rather than substring-matching `msg` for
193        /// programmatic decision-making (retry, alert, fail fast).
194        kind: WebSocketErrorKind,
195        /// Diagnostic message describing the WebSocket failure.
196        msg: String,
197    },
198
199    /// Inbound activity timed out: no frame received within the
200    /// configured `heartbeat_timeout` window.
201    #[error("Heartbeat timeout: no inbound frames for {elapsed:?}")]
202    HeartbeatTimeout {
203        /// Wall-clock interval that elapsed since the last inbound frame.
204        elapsed: Duration,
205    },
206
207    /// Client has been closed and cannot be reused
208    #[error("Client already closed")]
209    ClientClosed,
210
211    /// Other unexpected errors
212    #[error(transparent)]
213    Other(
214        /// Underlying error wrapped via `anyhow`.
215        #[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        // Map each upstream variant to the right `WebSocketErrorKind`.
225        // Previous behaviour collapsed everything into either
226        // `ConnectionError` (IO), `WebSocketError` (protocol/capacity), or
227        // `AuthError` (TLS/401/403) which conflated retry-policy with
228        // fault-source. 0.6.0 retains `WebSocketError` as the single carrier
229        // and exposes the source via the `kind` field.
230        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    /// Coarse-grained classification of the source of this error.
269    ///
270    /// Returns one of [`ErrorKind::Network`], [`ErrorKind::Protocol`],
271    /// [`ErrorKind::Auth`], [`ErrorKind::RateLimit`], or [`ErrorKind::Client`]
272    /// so downstream code can branch on category without pattern-matching
273    /// every variant.
274    ///
275    /// # Mapping
276    ///
277    /// | `MarketDataError` variant | `ErrorKind` |
278    /// |---|---|
279    /// | `ConnectionError`, `TimeoutError`, `HeartbeatTimeout` | `Network` |
280    /// | `WebSocketError { kind: Protocol \| Capacity \| Utf8 \| Other }` | `Protocol` |
281    /// | `WebSocketError { kind: Tls }` | `Auth` |
282    /// | `WebSocketError { kind: Io }` | `Network` |
283    /// | `WebSocketError { kind: Http(_) }` | see [`WebSocketErrorKind::Http`] for the status-code mapping table |
284    /// | `AuthError`, `ApiError { status: 401 \| 403 }` | `Auth` |
285    /// | `ApiError { status: 429 }` | `RateLimit` |
286    /// | `ApiError { status: 500..=599 }` | `Network` |
287    /// | `ApiError { status: other 4xx }` | `Client` |
288    /// | `InvalidSymbol`, `InvalidParameter`, `ConfigError`, `DeserializationError`, `ClientClosed` | `Client` |
289    /// | `RuntimeError`, `Other` | `Client` |
290    #[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    /// Get numeric error code for FFI consumers
328    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    /// Check if error is retryable.
347    ///
348    /// # WebSocket retry verdict (0.6.0+)
349    ///
350    /// Refined to honour [`WebSocketErrorKind`]:
351    ///
352    /// | Kind | Retryable |
353    /// |---|---|
354    /// | `Protocol`, `Capacity`, `Utf8`, `Tls` | no |
355    /// | `Io`, `Other` | yes |
356    /// | `Http(429)`, `Http(500..=599)` | yes |
357    /// | `Http(401 \| 403)` | no |
358    /// | `Http(other)` | no |
359    ///
360    /// Protocol violations are now correctly non-retryable — retrying the
361    /// same SDK against the same server will keep failing. Pre-0.6.0
362    /// behaviour treated every WebSocket error as retryable, which was
363    /// a footgun for monitor incident response.
364    pub fn is_retryable(&self) -> bool {
365        match self {
366            // Network errors are retryable
367            Self::ConnectionError { .. }
368            | Self::TimeoutError { .. }
369            | Self::HeartbeatTimeout { .. } => true,
370            // WebSocket retry verdict driven by structured kind
371            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            // API errors with 429 or 5xx status codes are retryable
382            Self::ApiError { status, .. } => *status == 429 || (500..=599).contains(status),
383            // Parameter errors are never retryable (user must fix input)
384            Self::InvalidParameter { .. } => false,
385            // All other errors are not retryable
386            _ => 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        // Retryable errors
477        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        // Io kind is retryable; Protocol is not — test both.
488        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        // Non-retryable errors
505        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        // ApiError with 429 should be retryable
530        let err = MarketDataError::ApiError {
531            status: 429,
532            message: "rate limit".to_string(),
533        };
534        assert!(err.is_retryable());
535
536        // ApiError with 5xx should be retryable
537        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        // 0.6.0: ConnectionClosed routes to WebSocketError { kind: Io }
581        // (the old behaviour collapsed it into ConnectionError).
582        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        // 0.6.0: Protocol kind is NOT retryable (was retryable in 0.5.x).
601        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    // ----- source_kind() classification (0.5.1) -----
633
634    #[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        // 429 is distinct from Network: the correct response is to
763        // *reduce* request volume, not to assume the upstream is down.
764        // Monitor incident playbooks differ — keep them separable.
765        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    // `#[non_exhaustive]` only forces wildcard arms in OTHER crates. Same-
808    // crate matches see every variant. We document the requirement here
809    // for clarity; downstream cross-crate enforcement is verified by the
810    // FFI binding builds (py / js / uniffi).
811    #[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    //! Pins the doc-vs-impl contract for `WebSocketErrorKind::Http(u16)`.
833    //!
834    //! The variant doc-comment lists the status-code → `ErrorKind` and
835    //! `is_retryable()` mapping. This test exercises representative codes
836    //! across every documented family so any silent drift between the
837    //! doc table and the impl arms in `MarketDataError::source_kind` /
838    //! `is_retryable` fails the suite.
839    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    /// Each row mirrors the rendered table on `WebSocketErrorKind::Http`.
849    /// Update both at the same time — they are the doc-vs-impl contract.
850    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}