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::{
6    error::SocketError,
7    subscription::{SubscriptionId, display_subscription_ids},
8};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12/// All errors generated in `rustrade-data`.
13///
14/// # Non-exhaustive
15/// Variants are added as integrations land, and two of the existing ones (`Databento`, `Lse` —
16/// not linked: each is behind its own feature, and this block is not) appear only under that
17/// feature, so the variant set a downstream `match` sees already depends on the feature selection
18/// it built
19/// with. A wildcard arm is required either way; `#[non_exhaustive]` makes that a compile-time
20/// contract rather than something a consumer discovers when a feature flag or a release moves.
21/// Matches *within* this crate stay exhaustively checked.
22#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
23#[non_exhaustive]
24pub enum DataError {
25    #[error("failed to index market data Subscriptions: {0}")]
26    Index(#[from] IndexError),
27
28    #[error("failed to initialise reconnecting MarketStream due to empty subscriptions")]
29    SubscriptionsEmpty,
30
31    #[error("unsupported DynamicStreams Subscription SubKind: {0}")]
32    UnsupportedSubKind(SubKind),
33
34    #[error("initial snapshot missing for: {0}")]
35    InitialSnapshotMissing(SubscriptionId),
36
37    #[error("initial snapshot invalid: {0}")]
38    InitialSnapshotInvalid(String),
39
40    #[error("SocketError: {0}")]
41    Socket(String),
42
43    /// Databento-specific error with categorized kind for programmatic handling.
44    #[cfg(feature = "databento")]
45    #[error("Databento {kind} error ({context}): {message}")]
46    Databento {
47        kind: DatabentoErrorKind,
48        context: String,
49        message: String,
50    },
51
52    /// A London Strategic Edge integration error, flattened into this crate's common error type.
53    ///
54    /// [`LseError`](crate::exchange::lse::error::LseError) carries a `reqwest::Error`, which is
55    /// neither `Clone` nor serialisable, so it cannot be nested here structurally. Callers wanting
56    /// the full cause should handle `LseError` at the call site; this variant exists so an
57    /// LSE-sourced stream can compose with the crate's generic stream helpers. Same flattening as
58    /// [`Socket`](Self::Socket) applies to `SocketError`.
59    ///
60    /// `kind` survives that flattening, so a consumer can still tell a resumable
61    /// [`RateLimit`](crate::exchange::lse::error::LseErrorKind::RateLimit) from a terminal
62    /// [`Decode`](crate::exchange::lse::error::LseErrorKind::Decode) without substring-matching
63    /// `message` — which is the only thing a bare `String` left it. Same shape as the `Databento`
64    /// variant (not linked: it is behind the `databento` feature, which this variant is not).
65    #[cfg(feature = "lse")]
66    #[error("London Strategic Edge {kind} error: {message}")]
67    Lse {
68        kind: crate::exchange::lse::error::LseErrorKind,
69        message: String,
70    },
71
72    #[error("unsupported dynamic Subscription for exchange: {exchange}, kind: {sub_kind}")]
73    Unsupported {
74        exchange: ExchangeId,
75        sub_kind: SubKind,
76    },
77
78    /// A dynamic subscription names a venue whose subscriber must be built by the caller — usually
79    /// because it carries credentials — and none was supplied. See
80    /// [`DynamicSubscribers`](crate::streams::builder::dynamic::DynamicSubscribers).
81    #[error(
82        "dynamic Subscription for exchange {exchange} needs a subscriber, and none was supplied \
83         through DynamicSubscribers"
84    )]
85    SubscriberRequired { exchange: ExchangeId },
86
87    /// The venue serves the subscription, but this build was compiled without the cargo feature
88    /// that carries its integration.
89    #[error(
90        "exchange {exchange} needs the `{feature}` cargo feature, which this build does not enable"
91    )]
92    FeatureDisabled {
93        exchange: ExchangeId,
94        feature: String,
95    },
96
97    #[error("exchange {exchange} does not support candle interval: {interval}")]
98    UnsupportedInterval {
99        exchange: ExchangeId,
100        interval: CandleInterval,
101    },
102
103    #[error(
104        "\
105        InvalidSequence: first_update_id {first_update_id} does not follow on from the \
106        prev_last_update_id {prev_last_update_id} \
107    "
108    )]
109    InvalidSequence {
110        prev_last_update_id: u64,
111        first_update_id: u64,
112    },
113
114    /// Subscription validation ended before every subscription in a batch was acknowledged.
115    ///
116    /// `unacknowledged` holds the [`SubscriptionId`]s that received no acknowledgement, sorted.
117    /// They are candidates, not proof of rejection: a venue that closes the connection on one bad
118    /// subscription also leaves those sent after it unanswered. Venues whose acknowledgements
119    /// cannot be matched to subscriptions (one acknowledgement per batch, or one that does not
120    /// echo the subscription) report the whole batch.
121    ///
122    /// A subscription's identifier is `ExchangeSub::new(&subscription).id()` — see
123    /// [`ExchangeSub`](crate::exchange::subscription::ExchangeSub).
124    #[error(
125        "subscriptions not acknowledged ({reason}): {}",
126        display_subscription_ids(unacknowledged)
127    )]
128    SubscriptionsUnacknowledged {
129        reason: String,
130        unacknowledged: Vec<SubscriptionId>,
131    },
132
133    /// A started stream failed to re-initialise after it ended, and will retry after its backoff.
134    ///
135    /// Yielded once per failed attempt. `attempt` counts consecutive failures since the stream
136    /// last initialised, starting at 1, so a caller can tell a slow reconnect from one that keeps
137    /// failing — for example a subscription the venue no longer accepts, reported as
138    /// [`SubscriptionsUnacknowledged`](Self::SubscriptionsUnacknowledged) in `error`. The stream
139    /// keeps retrying; giving up is the caller's decision.
140    #[error("stream re-initialisation attempt {attempt} failed: {error}")]
141    ReinitFailed { attempt: u32, error: Box<DataError> },
142}
143
144impl DataError {
145    /// Determine if an error requires a [`MarketStream`](super::MarketStream) to re-initialise.
146    // Explicit `match` (not `matches!`) is kept so additional terminal variants can be classified
147    // arm-by-arm as they are added; the lint would otherwise push this to a single `matches!`.
148    #[allow(clippy::match_like_matches_macro)]
149    pub fn is_terminal(&self) -> bool {
150        match self {
151            DataError::InvalidSequence { .. } => true,
152            _ => false,
153        }
154    }
155}
156
157impl From<SocketError> for DataError {
158    fn from(value: SocketError) -> Self {
159        match value {
160            SocketError::Unacknowledged {
161                reason,
162                unacknowledged,
163            } => Self::SubscriptionsUnacknowledged {
164                reason,
165                unacknowledged,
166            },
167            other => Self::Socket(other.to_string()),
168        }
169    }
170}
171
172#[cfg(feature = "lse")]
173impl From<crate::exchange::lse::error::LseError> for DataError {
174    fn from(value: crate::exchange::lse::error::LseError) -> Self {
175        Self::Lse {
176            kind: value.kind(),
177            message: value.to_string(),
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn an_unacknowledged_batch_keeps_its_subscriptions_through_the_conversion() {
188        let error = DataError::from(SocketError::Unacknowledged {
189            reason: "WebSocket stream terminated unexpectedly".to_owned(),
190            unacknowledged: vec!["l2Book|XYZ:TSLA".into(), "trades|@107".into()],
191        });
192
193        assert_eq!(
194            error,
195            DataError::SubscriptionsUnacknowledged {
196                reason: "WebSocket stream terminated unexpectedly".to_owned(),
197                unacknowledged: vec!["l2Book|XYZ:TSLA".into(), "trades|@107".into()],
198            }
199        );
200        assert_eq!(
201            error.to_string(),
202            "subscriptions not acknowledged (WebSocket stream terminated unexpectedly): \
203             l2Book|XYZ:TSLA, trades|@107"
204        );
205    }
206
207    #[test]
208    fn test_data_error_is_terminal() {
209        struct TestCase {
210            input: DataError,
211            expected: bool,
212        }
213
214        let tests = vec![
215            TestCase {
216                // TC0: is terminal w/ DataError::InvalidSequence
217                input: DataError::InvalidSequence {
218                    prev_last_update_id: 0,
219                    first_update_id: 0,
220                },
221                expected: true,
222            },
223            TestCase {
224                // TC1: is not terminal w/ DataError::Socket
225                input: DataError::from(SocketError::Sink),
226                expected: false,
227            },
228            TestCase {
229                // TC2: not terminal w/ DataError::UnsupportedInterval — a caller
230                // configuration error, not a stream condition warranting re-init.
231                input: DataError::UnsupportedInterval {
232                    exchange: ExchangeId::HyperliquidPerp,
233                    interval: CandleInterval::Sec1,
234                },
235                expected: false,
236            },
237        ];
238
239        for (index, test) in tests.into_iter().enumerate() {
240            let actual = test.input.is_terminal();
241            assert_eq!(actual, test.expected, "TC{} failed", index);
242        }
243    }
244}