Skip to main content

rustrade_data/
error.rs

1#[cfg(feature = "databento")]
2use crate::exchange::databento::DatabentoErrorKind;
3use crate::subscription::{SubKind, candle::CandleInterval};
4use rustrade_instrument::{exchange::ExchangeId, index::error::IndexError};
5use rustrade_integration::{error::SocketError, subscription::SubscriptionId};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// All errors generated in `rustrade-data`.
10///
11/// # Non-exhaustive
12/// Variants are added as integrations land, and two of the existing ones (`Databento`, `Lse` —
13/// not linked: each is behind its own feature, and this block is not) appear only under that
14/// feature, so the variant set a downstream `match` sees already depends on the feature selection
15/// it built
16/// with. A wildcard arm is required either way; `#[non_exhaustive]` makes that a compile-time
17/// contract rather than something a consumer discovers when a feature flag or a release moves.
18/// Matches *within* this crate stay exhaustively checked.
19#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
20#[non_exhaustive]
21pub enum DataError {
22    #[error("failed to index market data Subscriptions: {0}")]
23    Index(#[from] IndexError),
24
25    #[error("failed to initialise reconnecting MarketStream due to empty subscriptions")]
26    SubscriptionsEmpty,
27
28    #[error("unsupported DynamicStreams Subscription SubKind: {0}")]
29    UnsupportedSubKind(SubKind),
30
31    #[error("initial snapshot missing for: {0}")]
32    InitialSnapshotMissing(SubscriptionId),
33
34    #[error("initial snapshot invalid: {0}")]
35    InitialSnapshotInvalid(String),
36
37    #[error("SocketError: {0}")]
38    Socket(String),
39
40    /// Databento-specific error with categorized kind for programmatic handling.
41    #[cfg(feature = "databento")]
42    #[error("Databento {kind} error ({context}): {message}")]
43    Databento {
44        kind: DatabentoErrorKind,
45        context: String,
46        message: String,
47    },
48
49    /// A London Strategic Edge integration error, flattened into this crate's common error type.
50    ///
51    /// [`LseError`](crate::exchange::lse::error::LseError) carries a `reqwest::Error`, which is
52    /// neither `Clone` nor serialisable, so it cannot be nested here structurally. Callers wanting
53    /// the full cause should handle `LseError` at the call site; this variant exists so an
54    /// LSE-sourced stream can compose with the crate's generic stream helpers. Same flattening as
55    /// [`Socket`](Self::Socket) applies to `SocketError`.
56    ///
57    /// `kind` survives that flattening, so a consumer can still tell a resumable
58    /// [`RateLimit`](crate::exchange::lse::error::LseErrorKind::RateLimit) from a terminal
59    /// [`Decode`](crate::exchange::lse::error::LseErrorKind::Decode) without substring-matching
60    /// `message` — which is the only thing a bare `String` left it. Same shape as the `Databento`
61    /// variant (not linked: it is behind the `databento` feature, which this variant is not).
62    #[cfg(feature = "lse")]
63    #[error("London Strategic Edge {kind} error: {message}")]
64    Lse {
65        kind: crate::exchange::lse::error::LseErrorKind,
66        message: String,
67    },
68
69    #[error("unsupported dynamic Subscription for exchange: {exchange}, kind: {sub_kind}")]
70    Unsupported {
71        exchange: ExchangeId,
72        sub_kind: SubKind,
73    },
74
75    /// A dynamic subscription names a venue whose subscriber must be built by the caller — usually
76    /// because it carries credentials — and none was supplied. See
77    /// [`DynamicSubscribers`](crate::streams::builder::dynamic::DynamicSubscribers).
78    #[error(
79        "dynamic Subscription for exchange {exchange} needs a subscriber, and none was supplied \
80         through DynamicSubscribers"
81    )]
82    SubscriberRequired { exchange: ExchangeId },
83
84    /// The venue serves the subscription, but this build was compiled without the cargo feature
85    /// that carries its integration.
86    #[error(
87        "exchange {exchange} needs the `{feature}` cargo feature, which this build does not enable"
88    )]
89    FeatureDisabled {
90        exchange: ExchangeId,
91        feature: String,
92    },
93
94    #[error("exchange {exchange} does not support candle interval: {interval}")]
95    UnsupportedInterval {
96        exchange: ExchangeId,
97        interval: CandleInterval,
98    },
99
100    #[error(
101        "\
102        InvalidSequence: first_update_id {first_update_id} does not follow on from the \
103        prev_last_update_id {prev_last_update_id} \
104    "
105    )]
106    InvalidSequence {
107        prev_last_update_id: u64,
108        first_update_id: u64,
109    },
110}
111
112impl DataError {
113    /// Determine if an error requires a [`MarketStream`](super::MarketStream) to re-initialise.
114    // Explicit `match` (not `matches!`) is kept so additional terminal variants can be classified
115    // arm-by-arm as they are added; the lint would otherwise push this to a single `matches!`.
116    #[allow(clippy::match_like_matches_macro)]
117    pub fn is_terminal(&self) -> bool {
118        match self {
119            DataError::InvalidSequence { .. } => true,
120            _ => false,
121        }
122    }
123}
124
125impl From<SocketError> for DataError {
126    fn from(value: SocketError) -> Self {
127        Self::Socket(value.to_string())
128    }
129}
130
131#[cfg(feature = "lse")]
132impl From<crate::exchange::lse::error::LseError> for DataError {
133    fn from(value: crate::exchange::lse::error::LseError) -> Self {
134        Self::Lse {
135            kind: value.kind(),
136            message: value.to_string(),
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_data_error_is_terminal() {
147        struct TestCase {
148            input: DataError,
149            expected: bool,
150        }
151
152        let tests = vec![
153            TestCase {
154                // TC0: is terminal w/ DataError::InvalidSequence
155                input: DataError::InvalidSequence {
156                    prev_last_update_id: 0,
157                    first_update_id: 0,
158                },
159                expected: true,
160            },
161            TestCase {
162                // TC1: is not terminal w/ DataError::Socket
163                input: DataError::from(SocketError::Sink),
164                expected: false,
165            },
166            TestCase {
167                // TC2: not terminal w/ DataError::UnsupportedInterval — a caller
168                // configuration error, not a stream condition warranting re-init.
169                input: DataError::UnsupportedInterval {
170                    exchange: ExchangeId::HyperliquidPerp,
171                    interval: CandleInterval::Sec1,
172                },
173                expected: false,
174            },
175        ];
176
177        for (index, test) in tests.into_iter().enumerate() {
178            let actual = test.input.is_terminal();
179            assert_eq!(actual, test.expected, "TC{} failed", index);
180        }
181    }
182}