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}