Skip to main content

rustrade_data/exchange/massive/
error.rs

1//! Error types for Massive integration.
2
3use crate::error::DataError;
4use std::time::Duration;
5
6/// Byte cap applied to a URL when rendering it into an error message.
7///
8/// URLs in these variants are stored in full — the value is often exactly what a
9/// caller needs to debug a misbehaving server — but a server-supplied URL can be
10/// long, and every `Display` of the error would otherwise carry it whole into a
11/// log line. Matches the 512-byte budget the `Api`/`Auth` messages use; the
12/// tighter 100-byte cap on `Deserialize` is for an inline snippet inside an
13/// already-descriptive message, a different context.
14const DISPLAY_URL_BYTES: usize = 512;
15
16/// Truncate `s` to at most `max_bytes` for rendering, returning the kept prefix and an ellipsis
17/// marker that is non-empty only when bytes were actually dropped.
18///
19/// A *silent* cut is worse than a visible one: a reader who cannot tell the value was shortened may
20/// chase a URL whose informative `cursor=...` tail is simply missing. Every truncating `Display` arm
21/// in this file goes through here so they all mark truncation the same way.
22///
23/// Borrows rather than reusing [`truncate_str`](crate::exchange::http::truncate_str) — that helper
24/// returns an owned `String` for values a caller *stores*, whereas a `Display` arm only needs to
25/// write the prefix straight out.
26fn truncate_for_display(s: &str, max_bytes: usize) -> (&str, &'static str) {
27    let boundary = s.floor_char_boundary(max_bytes);
28    (&s[..boundary], if boundary < s.len() { "..." } else { "" })
29}
30
31/// Massive-specific errors.
32///
33/// The library returns these errors without automatic retry.
34/// Consumers decide how to handle rate limits and auth failures.
35///
36/// `#[non_exhaustive]`: new variants may be added without a major-version bump, so
37/// downstream `match`es must include a wildcard arm.
38#[derive(Debug, Clone, PartialEq)]
39#[non_exhaustive]
40pub enum MassiveError {
41    /// Rate limited by the API. Contains optional retry-after duration.
42    ///
43    /// Returned on HTTP 429 responses. The consumer decides whether and when to retry.
44    RateLimited { retry_after: Option<Duration> },
45
46    /// Authentication failed.
47    ///
48    /// Returned when:
49    /// - API key is invalid or expired
50    /// - API key lacks permission for the requested resource
51    Auth { message: String },
52
53    /// API returned an error response.
54    ///
55    /// Covers non-auth, non-rate-limit API errors (invalid parameters, not found, etc.)
56    Api { status: u16, message: String },
57
58    /// Network or HTTP client error.
59    Http { message: String },
60
61    /// JSON deserialization failed.
62    Deserialize { message: String, payload: String },
63
64    /// Environment variable not set.
65    EnvVar { var: &'static str },
66
67    /// Client-side input validation failed.
68    ///
69    /// Returned when input parameters are invalid before making an API request
70    /// (e.g., timestamp out of representable range).
71    InvalidInput { message: String },
72
73    /// A paginated fetch exceeded the maximum number of pages before the API
74    /// reported the end of the result set.
75    ///
76    /// Yielded as a terminal error on the affected stream. For a market-data
77    /// client a silent truncation is indistinguishable from a genuinely small
78    /// result set, so pagination fails loudly rather than returning a partial
79    /// `Vec`. Hitting this limit indicates either an unexpectedly large query or
80    /// a server that never signals the final page — inspect the query before
81    /// retrying.
82    PaginationLimitExceeded { pages: usize, limit: usize },
83
84    /// A paginated fetch revisited a URL it had already fetched.
85    ///
86    /// Yielded as a terminal error on the affected stream. A `next_url` that
87    /// points back to an already-visited page would otherwise loop forever;
88    /// detecting the cycle turns silent non-termination into an observable
89    /// failure.
90    CyclicPagination { url: String },
91
92    /// A paginated fetch encountered a URL longer than the paginator will follow.
93    ///
94    /// Yielded as a terminal error *before* the URL is recorded for cycle
95    /// detection or requested. A page's `next_url` is server-controlled and
96    /// success bodies are read unbounded, so without this bound a misbehaving
97    /// origin could hand back arbitrarily long URLs — one retained per page, for
98    /// the life of the stream.
99    ///
100    /// A real Massive cursor URL is a few hundred bytes, so hitting this
101    /// indicates response tampering, a server-side bug, or (far less likely, but
102    /// covered by the same check) a caller query that expands into an
103    /// unreasonable first-page URL.
104    PaginationUrlTooLong {
105        /// Length of the rejected URL, in bytes.
106        len: usize,
107        /// The byte limit that was exceeded.
108        limit: usize,
109        /// A bounded prefix of the rejected URL, for diagnosis. Unlike
110        /// [`UntrustedNextUrl::next_url`](MassiveError::UntrustedNextUrl) this is
111        /// truncated by construction — retaining the value in full is the very
112        /// thing the variant exists to prevent.
113        ///
114        /// [`Display`](std::fmt::Display) marks the value as truncated only when
115        /// `prefix` is actually shorter than `len`, so a prefix that happens to
116        /// be complete does not render a misleading trailing `...`.
117        prefix: String,
118    },
119
120    /// A paginated fetch received a `next_url` outside the client's configured
121    /// origin (scheme + host + port), or one that failed to parse as a URL.
122    ///
123    /// Yielded as a terminal error *before* the request is issued. The client
124    /// attaches its API key as an `Authorization: Bearer` header only after this
125    /// origin check passes, so a `next_url` that names an unexpected origin is
126    /// rejected before the token is ever attached. A prefix check is insufficient
127    /// — a look-alike host such as `https://api.massive.com.evil.example` (or the
128    /// separator-less `https://api.massive.comevil.example`) shares the prefix
129    /// yet is a different origin — so origins are parsed and compared, and a
130    /// mismatched or unparseable `next_url` is rejected (fail-closed).
131    ///
132    /// A well-behaved Massive API only ever returns a `next_url` under its own
133    /// origin (the path and `cursor` query differ; the origin is fixed). Hitting
134    /// this indicates a server-side bug, response tampering, or a misconfigured
135    /// base URL. Consumers may match on it to alert distinctly from ordinary
136    /// input-validation failures.
137    UntrustedNextUrl {
138        /// The rejected `next_url`, exactly as received (unparsed if it failed
139        /// to parse as a URL at all).
140        ///
141        /// Stored in full; [`Display`](std::fmt::Display) bounds the *rendered*
142        /// form so an oversized value cannot flood a log line.
143        next_url: String,
144        /// ASCII-serialized origin (`scheme://host[:port]`) the client trusts,
145        /// derived from its configured base URL.
146        expected_origin: String,
147    },
148}
149
150impl std::fmt::Display for MassiveError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            MassiveError::RateLimited { retry_after } => {
154                write!(f, "Massive rate limited")?;
155                if let Some(duration) = retry_after {
156                    write!(f, " (retry after {:?})", duration)?;
157                }
158                Ok(())
159            }
160            MassiveError::Auth { message } => {
161                write!(f, "Massive auth failed: {}", message)
162            }
163            MassiveError::Api { status, message } => {
164                write!(f, "Massive API error ({}): {}", status, message)
165            }
166            MassiveError::Http { message } => {
167                write!(f, "Massive HTTP error: {}", message)
168            }
169            MassiveError::Deserialize { message, payload } => {
170                let (truncated, ellipsis) = truncate_for_display(payload, 100);
171                write!(
172                    f,
173                    "Massive deserialize error: {} (payload: {truncated}{ellipsis})",
174                    message
175                )
176            }
177            MassiveError::EnvVar { var } => {
178                write!(f, "Massive environment variable not set: {}", var)
179            }
180            MassiveError::InvalidInput { message } => {
181                write!(f, "Massive invalid input: {}", message)
182            }
183            MassiveError::PaginationLimitExceeded { pages, limit } => {
184                write!(
185                    f,
186                    "Massive pagination exceeded {limit}-page limit (fetched {pages} pages) — result may be incomplete"
187                )
188            }
189            MassiveError::PaginationUrlTooLong { len, limit, prefix } => {
190                // Derive the ellipsis from the data rather than assuming the cut happened. The
191                // internal constructor always truncates, but `#[non_exhaustive]` sits on the enum,
192                // not this variant, so it does not stop downstream code from building one with a
193                // whole `prefix` — which the unconditional `...` would then misreport as truncated.
194                // Comparing against `len` (the full URL's length) is exact and needs no cap constant.
195                let ellipsis = if prefix.len() < *len { "..." } else { "" };
196                write!(
197                    f,
198                    "Massive pagination URL exceeds the {limit}-byte limit ({len} bytes): {prefix}{ellipsis}"
199                )
200            }
201            MassiveError::CyclicPagination { url } => {
202                let (url, ellipsis) = truncate_for_display(url, DISPLAY_URL_BYTES);
203                write!(
204                    f,
205                    "Massive pagination cycle detected: next_url revisits {url}{ellipsis}"
206                )
207            }
208            MassiveError::UntrustedNextUrl {
209                next_url,
210                expected_origin,
211            } => {
212                let (next_url, ellipsis) = truncate_for_display(next_url, DISPLAY_URL_BYTES);
213                write!(
214                    f,
215                    "Massive rejected next_url outside trusted origin {expected_origin}: {next_url}{ellipsis}"
216                )
217            }
218        }
219    }
220}
221
222impl std::error::Error for MassiveError {}
223
224impl From<MassiveError> for DataError {
225    fn from(err: MassiveError) -> Self {
226        DataError::Socket(err.to_string())
227    }
228}
229
230impl From<reqwest::Error> for MassiveError {
231    fn from(err: reqwest::Error) -> Self {
232        MassiveError::Http {
233            message: err.to_string(),
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    /// A URL long enough to exceed [`DISPLAY_URL_BYTES`], built from a repeated ASCII byte so
243    /// `len()` and char count coincide.
244    fn oversized_url() -> String {
245        format!("https://api.massive.com/v3/x?cursor={}", "a".repeat(4096))
246    }
247
248    #[test]
249    fn cyclic_pagination_display_is_bounded() {
250        let url = oversized_url();
251        let rendered = MassiveError::CyclicPagination { url: url.clone() }.to_string();
252
253        assert!(
254            rendered.len() < url.len(),
255            "an oversized URL must not reach the log line in full"
256        );
257        assert!(rendered.contains("cycle detected"));
258        // The bounded prefix is still the real URL, so the message stays diagnostic.
259        assert!(rendered.contains("https://api.massive.com/v3/x?cursor=aaa"));
260        // A cut must be visible: an unmarked prefix reads as a complete URL, sending a reader
261        // after a `cursor` value that was silently dropped.
262        assert!(rendered.ends_with("..."), "an elided URL is marked");
263    }
264
265    #[test]
266    fn untrusted_next_url_display_is_bounded_but_stores_the_url_in_full() {
267        let next_url = oversized_url();
268        let error = MassiveError::UntrustedNextUrl {
269            next_url: next_url.clone(),
270            expected_origin: "https://api.massive.com".to_owned(),
271        };
272        let rendered = error.to_string();
273
274        assert!(rendered.len() < next_url.len(), "Display must be bounded");
275        assert!(rendered.contains("https://api.massive.com"));
276        assert!(rendered.ends_with("..."), "an elided URL is marked");
277        // The field keeps the value exactly as received — only rendering is capped, so a consumer
278        // matching on the variant can still inspect the whole URL.
279        match error {
280            MassiveError::UntrustedNextUrl {
281                next_url: stored, ..
282            } => {
283                assert_eq!(stored, next_url)
284            }
285            other => panic!("expected UntrustedNextUrl, got {other:?}"),
286        }
287    }
288
289    #[test]
290    fn display_of_a_short_url_is_not_truncated() {
291        // The bound must be invisible in the overwhelmingly common case.
292        let url = "https://api.massive.com/v3/reference/tickers?cursor=abc";
293        let rendered = MassiveError::CyclicPagination {
294            url: url.to_owned(),
295        }
296        .to_string();
297
298        assert!(rendered.ends_with(url), "got: {rendered}");
299    }
300
301    #[test]
302    fn pagination_url_too_long_display_reports_both_sizes() {
303        let rendered = MassiveError::PaginationUrlTooLong {
304            len: 9000,
305            limit: 8192,
306            prefix: "https://api.massive.com/v3/x".to_owned(),
307        }
308        .to_string();
309
310        assert!(rendered.contains("9000"), "got: {rendered}");
311        assert!(rendered.contains("8192"), "got: {rendered}");
312        assert!(rendered.contains("https://api.massive.com/v3/x"));
313        assert!(
314            rendered.ends_with("..."),
315            "a prefix shorter than `len` is truncated and must say so, got: {rendered}"
316        );
317    }
318
319    #[test]
320    fn pagination_url_too_long_display_omits_the_ellipsis_when_nothing_was_cut() {
321        // `#[non_exhaustive]` is on the enum, not this variant, so downstream code can build one
322        // with a complete `prefix`. The ellipsis is derived from `prefix.len() < len` rather than
323        // assumed from the internal constructor's behaviour, so a whole URL is not misreported as
324        // truncated.
325        let url = "https://api.massive.com/v3/x";
326        let rendered = MassiveError::PaginationUrlTooLong {
327            len: url.len(),
328            limit: 8192,
329            prefix: url.to_owned(),
330        }
331        .to_string();
332
333        assert!(rendered.contains(url), "got: {rendered}");
334        assert!(
335            !rendered.ends_with("..."),
336            "an untruncated prefix must not be marked as cut, got: {rendered}"
337        );
338    }
339
340    #[test]
341    fn deserialize_display_truncates_its_payload_snippet() {
342        let rendered = MassiveError::Deserialize {
343            message: "expected `,`".to_owned(),
344            payload: "x".repeat(500),
345        }
346        .to_string();
347
348        assert!(rendered.contains("expected `,`"));
349        assert!(rendered.contains("..."), "an elided payload is marked");
350        assert!(rendered.len() < 500);
351    }
352
353    #[test]
354    fn display_covers_every_variant_without_panicking() {
355        // `Display` slices strings by byte index, so a multi-byte payload in any variant is a
356        // panic risk; this walks all of them at once.
357        let multibyte = "€".repeat(400);
358        let variants = [
359            MassiveError::RateLimited {
360                retry_after: Some(Duration::from_secs(5)),
361            },
362            MassiveError::RateLimited { retry_after: None },
363            MassiveError::Auth {
364                message: multibyte.clone(),
365            },
366            MassiveError::Api {
367                status: 500,
368                message: multibyte.clone(),
369            },
370            MassiveError::Http {
371                message: multibyte.clone(),
372            },
373            MassiveError::Deserialize {
374                message: "bad".to_owned(),
375                payload: multibyte.clone(),
376            },
377            MassiveError::EnvVar { var: "MASSIVE_KEY" },
378            MassiveError::InvalidInput {
379                message: multibyte.clone(),
380            },
381            MassiveError::PaginationLimitExceeded {
382                pages: 10_001,
383                limit: 10_000,
384            },
385            MassiveError::CyclicPagination {
386                url: multibyte.clone(),
387            },
388            MassiveError::PaginationUrlTooLong {
389                len: 9000,
390                limit: 8192,
391                prefix: multibyte.clone(),
392            },
393            MassiveError::UntrustedNextUrl {
394                next_url: multibyte.clone(),
395                expected_origin: "https://api.massive.com".to_owned(),
396            },
397        ];
398
399        for variant in variants {
400            assert!(
401                !variant.to_string().is_empty(),
402                "every variant renders something: {variant:?}"
403            );
404        }
405    }
406}