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}