rustrade_data/exchange/lse/error.rs
1use crate::exchange::lse::quota::QuotaStatus;
2use crate::subscription::candle::CandleInterval;
3use chrono::{DateTime, NaiveDate, Utc};
4use std::time::Duration;
5use thiserror::Error;
6
7/// Errors produced by the London Strategic Edge integration.
8///
9/// `#[non_exhaustive]`: further variants are added alongside the endpoints that raise them.
10///
11/// Deliberately not `Clone`/`PartialEq` — [`Http`](Self::Http) wraps a [`reqwest::Error`], which is
12/// neither, matching every other REST-backed integration in this crate.
13#[derive(Debug, Error)]
14#[non_exhaustive]
15pub enum LseError {
16 /// More than one distinct dataset series resolves to the requested slug, so no single dataset
17 /// can be identified.
18 ///
19 /// Returned rather than picking one, because the provider's `/dataset/info` endpoint answers
20 /// `200` for an ambiguous slug — silently serving whichever series it prefers. Two measured
21 /// families produce this: eleven Eurex futures that publish both a bare and a `.F` series
22 /// containing *different* data (the bare series is frequently the far larger one and has no
23 /// slug of its own), and futures whose stripped symbol collides with an unrelated equity
24 /// ticker.
25 #[error(
26 "ambiguous dataset slug {slug:?} for symbol {symbol:?}: more than one series resolves to \
27 it, so it cannot identify a dataset - query the catalog and select explicitly"
28 )]
29 AmbiguousSlug { symbol: String, slug: String },
30
31 /// The provided string does not name a known London Strategic Edge price dataset.
32 #[error("unknown dataset {0:?}")]
33 UnknownDataset(String),
34
35 /// A required environment variable is not set, or does not hold valid UTF-8 (see
36 /// [`LseVaultClient::from_env`]).
37 ///
38 /// The message names the variable and never its value, so a mis-encoded key cannot reach a log
39 /// line through this error.
40 ///
41 /// [`LseVaultClient::from_env`]: super::vault::LseVaultClient::from_env
42 #[error("environment variable error: {0}")]
43 EnvVar(String),
44
45 /// The supplied API key cannot be encoded as an HTTP header value (e.g. non-ASCII bytes).
46 #[error("invalid credential: {0}")]
47 InvalidCredential(String),
48
49 /// The request could not be completed at the transport layer.
50 #[error("HTTP error: {0}")]
51 Http(#[from] reqwest::Error),
52
53 /// The API returned a non-success status, or a success response this integration cannot use.
54 ///
55 /// `message` is the provider's own diagnostic, unwrapped from its response envelope and
56 /// truncated to a bounded length — or, where `status` is a success code, this integration's own
57 /// diagnostic for a response that violated the contract that status implies. A `206` whose
58 /// `Content-Range` is missing, unparseable, or does not resume where the `Range` asked, and a
59 /// `200` page that repeats the cursor it was given, all arrive here: the status is the one the
60 /// provider sent, so it stays reportable, but the fault is one only the client can see.
61 #[error("API error ({status}): {message}")]
62 Api { status: u16, message: String },
63
64 /// The request rate was exceeded.
65 ///
66 /// Terminal — this integration never sleeps and retries on the caller's behalf. A paged fetch
67 /// yields this and **ends**; resume by re-requesting from the last `close_time` received.
68 ///
69 /// The provider permits [`calls_per_minute`](QuotaStatus::calls_per_minute) requests per
70 /// minute, reported by [`usage`](super::vault::LseVaultClient::usage).
71 ///
72 /// # Caveat
73 /// On the *export* endpoints an exhausted allowance is distinguished and reported as
74 /// [`QuotaExceeded`](Self::QuotaExceeded). On the candle path the provider offers no way to
75 /// tell a per-minute rate limit from an exhausted byte allowance, so both arrive here. Either
76 /// way it is observable and terminal, and never silently retried.
77 #[error("rate limited{}", match .retry_after {
78 Some(delay) => format!("; retry after {}s", delay.as_secs()),
79 None => String::new(),
80 })]
81 RateLimited { retry_after: Option<Duration> },
82
83 /// A response body could not be decoded into the expected shape.
84 #[error("invalid response: {message}")]
85 Deserialize { message: String },
86
87 /// The request is malformed in a way the caller must fix, detected before it is sent.
88 #[error("invalid input: {message}")]
89 InvalidInput { message: String },
90
91 /// The requested country is not one the provider publishes bond yields for.
92 ///
93 /// Raised before the request is sent, because `/bond-yields` performs **no validation of its
94 /// own**: a country it does not know answers `200` with `count: 0`, which is byte-for-byte
95 /// indistinguishable from an unknown maturity and from a range that is simply empty. Measured:
96 /// `country=ZZ` and `country=GB` both return `200 count=0`, against `country=UK` returning rows.
97 ///
98 /// `normalised` is what `requested` became before the lookup — the provider keys the United
99 /// Kingdom `UK`, **not** the ISO-3166 `GB` its own `country_iso2` column name implies, and that
100 /// is the only divergence among its 34 codes. Both forms are reported so a caller can see which
101 /// one was actually looked up.
102 #[error(
103 "unknown bond-yield country {requested:?} (looked up as {normalised:?}): the provider does not publish it - consult the stats handle for the countries it does"
104 )]
105 UnknownBondYieldCountry {
106 requested: String,
107 normalised: String,
108 },
109
110 /// The requested maturity is not one the provider publishes for that country.
111 ///
112 /// Raised before the request is sent, for the same reason as
113 /// [`UnknownBondYieldCountry`](Self::UnknownBondYieldCountry): measured, `maturity=99Y` answers
114 /// `200` with `count: 0` rather than an error.
115 ///
116 /// `available` is the country's published tenor list, carried in full because it is short
117 /// (fifteen at the widest measured) and because a tenor is a **provider label**, not a
118 /// duration — `5Y` and `5Y TIPS` are distinct series that report the same `maturity_days`, so
119 /// there is nothing a caller can compute the right spelling from.
120 #[error(
121 "unknown bond-yield maturity {requested:?} for country {country}: the provider publishes {available:?}"
122 )]
123 UnknownBondYieldMaturity {
124 country: String,
125 requested: String,
126 available: Vec<String>,
127 },
128
129 /// The requested range lies entirely outside the published coverage of that series.
130 ///
131 /// The third cause of a `200 count=0`, and the one that membership alone cannot catch: a
132 /// `(country, maturity)` pair can exist and still hold nothing in the window asked for.
133 /// Measured on `US 10Y TIPS` and `TR 2Y`, both of which return zero rows for 2023-2024 and both
134 /// of which report a `first_date` of 2025-07-07 — correct behaviour from the provider, and
135 /// silent.
136 ///
137 /// Raised only when the requested range and the published one are **disjoint**. A partial
138 /// overlap is not an error: it returns the rows that exist, which is what was asked for.
139 #[error(
140 "bond-yield range {start}..={end} lies outside the published coverage of {country}{} \
141 ({first_date}..={last_date})",
142 match .maturity {
143 Some(maturity) => format!(" {maturity}"),
144 None => String::new(),
145 }
146 )]
147 BondYieldRangeOutsideCoverage {
148 country: String,
149 /// `None` when the query named no maturity, so the bound is the country's own coverage.
150 maturity: Option<String>,
151 start: NaiveDate,
152 end: NaiveDate,
153 first_date: NaiveDate,
154 last_date: NaiveDate,
155 },
156
157 /// The requested country is not one the economic calendar publishes.
158 ///
159 /// Raised before the request is sent, for the same reason as
160 /// [`UnknownBondYieldCountry`](Self::UnknownBondYieldCountry): the endpoint answers an unknown
161 /// country with `200` and `count: 0`, indistinguishable from a quiet window.
162 ///
163 /// `normalised` is what `requested` became before the lookup. The calendar keys the United
164 /// Kingdom `UK` rather than `GB`, exactly as `/bond-yields` does — but the two vocabularies are
165 /// **not** the same set: the calendar's 108 codes also include `EA` and `EU`, which are not
166 /// countries. Both forms are reported so a caller can see which one was actually looked up.
167 #[error(
168 "unknown economic-calendar country {requested:?} (looked up as {normalised:?}): the provider does not publish it - consult the stats handle for the countries it does"
169 )]
170 UnknownCalendarCountry {
171 requested: String,
172 normalised: String,
173 },
174
175 /// The requested impact rating is not one the economic calendar publishes.
176 ///
177 /// Measured: `impact=Critical` answers `200` with `count: 0` rather than an error, so this is
178 /// raised before the request is sent.
179 ///
180 /// `available` is carried in full because the vocabulary is closed and short — exactly
181 /// `["High", "Low", "Medium", "None"]`. ⚠️ `"None"` in that list is a literal rating, not an
182 /// absent value.
183 #[error("unknown economic-calendar impact {requested:?}: the provider publishes {available:?}")]
184 UnknownCalendarImpact {
185 requested: String,
186 available: Vec<String>,
187 },
188
189 /// The requested range lies entirely outside the calendar's published coverage.
190 ///
191 /// ⚠️ **A global bound, not a per-country one**, which makes this weaker than
192 /// [`BondYieldRangeOutsideCoverage`](Self::BondYieldRangeOutsideCoverage).
193 /// `/economic-calendar/stats` publishes no per-country coverage at all, so a window *inside*
194 /// this range may still legitimately return nothing — 88 of the 108 countries hold fewer than
195 /// 100 events. That case is deliberately **not** an error: the library has no way to
196 /// distinguish it from a quiet period, and inventing an error for it would mean inventing
197 /// knowledge the provider does not publish.
198 ///
199 /// 🔴 `latest` has been 2026-03-24 across measurements two months apart — the feed is frozen,
200 /// so **every** forward-looking window lands here.
201 ///
202 /// Raised only when the requested range and the published one are **disjoint**. A partial
203 /// overlap is not an error: it returns the events that exist, which is what was asked for.
204 #[error(
205 "economic-calendar range {start}..={end} lies outside the published coverage ({earliest}..={latest})"
206 )]
207 CalendarRangeOutsideCoverage {
208 start: NaiveDate,
209 end: NaiveDate,
210 earliest: NaiveDate,
211 latest: NaiveDate,
212 },
213
214 /// The requested resolution is not one the provider serves.
215 ///
216 /// [`CandleInterval`] is the venue-agnostic union of every resolution any connector in this
217 /// crate serves; this provider serves 14 of them. Rejected before the request is sent rather
218 /// than relayed as a `400`, so the caller gets a typed answer.
219 #[error("unsupported candle interval {interval}: the provider does not serve this resolution")]
220 UnsupportedInterval { interval: CandleInterval },
221
222 /// A candle's period-end boundary is not representable.
223 ///
224 /// `close_time == open_time + interval` can overflow the representable [`DateTime<Utc>`]
225 /// range. Surfaced rather than clamped: a silently substituted boundary would be a
226 /// plausible-looking wrong timestamp on a real candle.
227 #[error("candle boundary overflow: open time {open} + {interval} is not representable")]
228 TimestampOverflow {
229 open: DateTime<Utc>,
230 interval: CandleInterval,
231 },
232
233 /// The pagination cursor could not be advanced past the last bar received.
234 ///
235 /// The cursor steps one second past the newest open time on a page, which is not representable
236 /// within a second of [`DateTime::MAX_UTC`]. Distinct from
237 /// [`TimestampOverflow`](Self::TimestampOverflow), which is a *candle's* period end: the addend
238 /// here is the cursor step, not the interval, and naming the interval would send a reader
239 /// looking at arithmetic the code never performed.
240 #[error("pagination cursor overflow: open time {last_open} + 1s is not representable")]
241 CursorOverflow { last_open: DateTime<Utc> },
242
243 /// The vault served a candle closing past the requested `end`.
244 ///
245 /// The upper bound sent to the vault is exact by construction: it is `end - interval + 1s`
246 /// against a parameter that is *exclusive* on open time, so the newest bar a compliant page can
247 /// carry is the one whose close falls exactly on `end`. A later one means the range parameters
248 /// were not honoured — the same silently-ignored-parameter failure as a page that repeats the
249 /// cursor it was given, which is why both are terminal rather than quietly repaired.
250 ///
251 /// # Not symmetric with the lower bound, deliberately
252 /// The lower bound is widened by one interval *on purpose*, to readmit the bar whose close
253 /// equals `start`, so a page legitimately carries bars closing before `start` and those are
254 /// trimmed without comment. Nothing widens the upper bound, so there is no benign reading of a
255 /// bar past it.
256 ///
257 /// Every in-range bar on the offending page is yielded **before** this arrives: the page is
258 /// scanned to the end first, so failing here costs none of the data the response did contain.
259 #[error(
260 "page {page} for {symbol:?} returned a candle closing after {end} (cursor {cursor}): the \
261 range parameters appear to have been ignored"
262 )]
263 UnexpectedCandleRange {
264 symbol: String,
265 cursor: DateTime<Utc>,
266 page: usize,
267 end: DateTime<Utc>,
268 },
269
270 /// A candle page stepped **backwards** in open time.
271 ///
272 /// Ascending order is how the vault answers today, not something it guarantees, and the
273 /// pagination cursor depends on it being at least non-descending *across* pages: the cursor
274 /// advances past the newest open a page carried, so a page served newest-first advances it to
275 /// the end of the range immediately. The next page is then empty, which is the documented
276 /// end-of-data signal, and `collect_candles` returns `Ok` with the last page of a multi-million
277 /// bar range and no indication that the rest was skipped.
278 ///
279 /// Terminal rather than repaired by sorting: a page arriving in an order the cursor arithmetic
280 /// was not built for means the vault changed behaviour, and every later assumption in this
281 /// module — the trim bounds, the cursor step, the end-of-data signal — was derived from the old
282 /// one. The same property is a typed rejection on the Parquet path
283 /// ([`NonMonotonicTimestamps`](Self::NonMonotonicTimestamps)).
284 #[error(
285 "page {page} for {symbol:?} stepped backwards in open time: {open} follows {previous_open}"
286 )]
287 NonMonotonicCandlePage {
288 symbol: String,
289 page: usize,
290 previous_open: DateTime<Utc>,
291 open: DateTime<Utc>,
292 },
293
294 /// Two consecutive candles are spaced closer together than the interval they were asked for.
295 ///
296 /// Impossible for a compliant fixed-interval series: consecutive bars are exactly one interval
297 /// apart, and a gap (a weekend, a holiday, a quiet symbol) only ever makes the spacing *wider*.
298 /// Spacing narrower than the interval means the candles are at a **finer resolution than the one
299 /// requested**, so every `close_time` derived for them overlaps the bars that follow.
300 ///
301 /// # The failure this exists to catch, on the vault path
302 /// The vault answers `200` to a misspelled resolution parameter and silently defaults to
303 /// 1-minute bars, in a byte-identical response shape (see the `vault` module docs). A `Day1`
304 /// request would then receive 1,440 one-minute rows per day, each stamped with a `close_time` of
305 /// `open + 24h` by the `historical` module's boundary arithmetic — overlapping, wrong, and
306 /// invisible to every other check there, because the range bounds are still satisfied and the
307 /// series still ascends. Spacing is the one property that distinguishes it.
308 ///
309 /// # And on the export path
310 /// `parquet::read_export` derives `close_time` from a **caller-supplied** interval, since
311 /// nothing in a Parquet artifact records the resolution it was written at. The wrong resolution
312 /// therefore reaches the same arithmetic from a different origin — the caller's declaration
313 /// rather than the provider's answer — and produces the same overlapping bars, so the same
314 /// check guards it: a file of 1-minute bars decoded as `Day1` is rejected rather than yielding
315 /// 390 "daily" bars per trading day. Reported in terms of the artifact's `ts` column, which
316 /// carries the bar's open — the close is a value that decoder derived, and only the open is
317 /// something a caller can find in the file itself.
318 ///
319 /// # Limits
320 /// Only the **finer-than-asked-for** direction is detectable. Coarser is not: daily bars taken
321 /// for `Min1` are spaced 24 hours apart, which is wider than 60 seconds and indistinguishable
322 /// from a genuine gap. A single candle is not checked at all, having no pair to measure.
323 ///
324 /// Only [`IntervalStep::Fixed`](crate::subscription::candle::IntervalStep::Fixed) resolutions
325 /// are checked. A calendar step (`month`, `quarter`, `year`) has no single width to compare
326 /// against, and February would false-positive against a 31-day one.
327 #[error(
328 "candles for {symbol:?} are spaced {actual} apart but {interval} was asked for ({open} \
329 follows {previous_open}): they appear to be at a finer resolution than that"
330 )]
331 UnexpectedCandleResolution {
332 symbol: String,
333 interval: CandleInterval,
334 previous_open: DateTime<Utc>,
335 open: DateTime<Utc>,
336 // `chrono::TimeDelta`, not the `std::time::Duration` imported above: the spacing is a
337 // difference between two instants and the type that produces it is chrono's.
338 actual: chrono::TimeDelta,
339 },
340
341 /// A single second of the option print tape holds more prints than one page can carry.
342 ///
343 /// The flow endpoint truncates silently at its row cap and accepts only whole-second bounds, so
344 /// a one-second window that still comes back full cannot be narrowed further, and the prints
345 /// beyond the cap cannot be read at all. Terminal rather than returned short: the alternative is
346 /// a tape missing prints with nothing to say so.
347 #[error(
348 "the option print tape holds at least {rows} prints in the one second from {window_start}, \
349 more than one page can carry"
350 )]
351 OptionFlowWindowSaturated {
352 window_start: DateTime<Utc>,
353 rows: usize,
354 },
355
356 /// An option print came back outside the window or underlying it was requested for.
357 ///
358 /// Means the provider ignored a request parameter, which it is known to do silently — the flow
359 /// endpoint answers a `ticker` filter by returning every contract. Raised before any print of
360 /// the offending window is yielded.
361 #[error("option print {id} does not match its request: {message}")]
362 UnexpectedOptionFlowRow { id: u64, message: String },
363
364 /// The shared allowance is exhausted.
365 ///
366 /// Carries the allowance state at the point of rejection so the caller can decide how to pace.
367 /// Terminal — never retried internally.
368 ///
369 /// # Raised by the export path only
370 /// The bulk-export endpoints report allowance exhaustion as a `429` carrying no `Retry-After`
371 /// and no rate-limit headers, which this integration distinguishes from a per-minute rate
372 /// limit and reports here, populating the position from
373 /// [`usage`](super::vault::LseVaultClient::usage). A *candle* fetch that runs into a limit
374 /// still surfaces as [`RateLimited`](Self::RateLimited) — the provider gives no way to tell
375 /// the two apart on that path.
376 /// `status` is `None` when the follow-up [`usage`](super::vault::LseVaultClient::usage) call
377 /// itself failed. The allowance is still known to be exhausted — that is what the `429` said —
378 /// but this integration will not fabricate a [`QuotaStatus`] to fill the field. One variant
379 /// meaning "allowance gone, position unknown" is what lets a caller pace itself by matching a
380 /// single variant; reporting the second case as a generic
381 /// [`Api`](Self::Api)`{ status: 429 }` instead would make it silently miss half the cases.
382 #[error("quota exceeded{}", match .status {
383 Some(status) => format!(": {status:?}"),
384 None => " (allowance position could not be retrieved)".to_owned(),
385 })]
386 QuotaExceeded { status: Option<QuotaStatus> },
387
388 /// An export job reached a terminal state without producing an artifact.
389 ///
390 /// `expired` means the artifact was built and has since been reaped — roughly 48 hours after
391 /// it was created. Both are terminal; re-exporting costs another export.
392 #[error("export job {job_id} {status}{}", match .message.as_str() {
393 "" => String::new(),
394 message => format!(": {message}"),
395 })]
396 ExportFailed {
397 job_id: String,
398 status: String,
399 message: String,
400 },
401
402 /// An export job did not become ready within the caller's timeout.
403 ///
404 /// **The job is not cancelled and the identifier stays valid** — it keeps building, so polling
405 /// [`export_status`](super::vault::LseVaultClient::export_status) later picks it up without
406 /// spending another export. This is a timeout on waiting, not on the job.
407 #[error(
408 "export job {job_id} still {status} when the caller's timeout elapsed; it keeps building, so poll it again rather than re-exporting"
409 )]
410 ExportTimeout { job_id: String, status: String },
411
412 /// A downloaded artifact does not match the integrity metadata the job reported.
413 ///
414 /// The destination is left untouched rather than holding a corrupt artifact.
415 ///
416 /// `discarded` reports what happened to the partial file at `path`:
417 /// - `false` — it is **retained**, because it is *incomplete* rather than wrong: shorter than the
418 /// artifact, so the bytes are a real prefix the next call resumes from with a `Range` request.
419 /// For a multi-gigabyte artifact that is the difference between finishing and starting over.
420 /// - `true` — it was **removed**, because it is *corrupt*: longer than the artifact, or the right
421 /// length at the wrong digest with nothing left to fetch. Retaining it would fail identically
422 /// forever, so a re-call restarts instead.
423 #[error("integrity check failed for {}: expected {expected}, got {actual} ({})", .path.display(), match .discarded {
424 true => "unusable partial file discarded; re-call to restart",
425 false => "partial file kept for resume",
426 })]
427 IntegrityMismatch {
428 path: std::path::PathBuf,
429 expected: String,
430 actual: String,
431 discarded: bool,
432 },
433
434 /// A `ready` export job does not describe the request it is being downloaded for.
435 ///
436 /// The provider **silently substitutes defaults for parameters it does not recognise**, so a
437 /// request it partly ignored still yields a `ready` job — covering something other than what was
438 /// asked for. The job record echoes `dataset`, `symbol`, `timeframe`, `start` and `end`, and that
439 /// echo is the only client-side evidence of what the artifact actually contains. Downloading it
440 /// anyway would attribute one instrument's or one range's data to another, silently.
441 ///
442 /// Not recoverable by re-downloading: the artifact is what it is. The request has to be corrected
443 /// and re-exported, which costs one of the five hourly exports.
444 #[error(
445 "export job {job_id} does not describe this request: {field} is {reported:?}, requested {requested:?}"
446 )]
447 ExportJobMismatch {
448 job_id: String,
449 field: String,
450 requested: String,
451 reported: String,
452 },
453
454 /// A filesystem operation failed.
455 ///
456 /// `message` names the operation and the path; the underlying [`std::io::Error`] is retained as
457 /// the error [source](std::error::Error::source), so a caller can match on
458 /// [`std::io::ErrorKind`] rather than parse a string.
459 #[error("io error: {message}: {source}")]
460 Io {
461 message: String,
462 #[source]
463 source: std::io::Error,
464 },
465
466 /// An export artifact's columns match none of the layouts this integration decodes.
467 ///
468 /// The provider's tick schema **varies by dataset** — `fx` publishes `bid`/`ask`, `stocks`
469 /// publishes `price`/`volume`, and the synthetic classes publish `price`/`volume`/`ask` — so
470 /// the layout is resolved from the columns present. Reported rather than guessed at: a wrong
471 /// column guess yields plausible numbers in the wrong field.
472 ///
473 /// Also raised for a schema that is not **flat**. Columns are read by leaf index, which equals a
474 /// top-level field's position only when every field is a primitive: one nested group contributes
475 /// several leaves and shifts every index after it. Every measured artifact is flat, so rather
476 /// than support a nesting the provider has never emitted, it is rejected — the alternative is a
477 /// decoder whose column mapping is silently wrong on a file it accepted, which for a `bid`/`ask`
478 /// pair means reading one as the other.
479 #[error("unsupported export schema; columns were: {columns}")]
480 UnsupportedSchema { columns: String },
481
482 /// A recognised export column does not have the type this integration decodes it as.
483 ///
484 /// Distinct from [`UnsupportedSchema`](Self::UnsupportedSchema), which reports columns whose
485 /// *names* match no known layout. Here the layout is recognised and one of its columns is the
486 /// wrong type, so naming the column and both types is the whole diagnostic — reported up front
487 /// rather than as an opaque decode failure on the first row.
488 ///
489 /// # Why a `ts` column that is not UTC-adjusted lands here
490 /// `Timestamp { unit: MICROS, is_adjusted_to_utc: false }` is *physically identical* to the
491 /// UTC-adjusted form — same `INT64`, same legacy `TIMESTAMP_MICROS` converted type — and differs
492 /// only in the origin its values are measured against. Read as epoch microseconds, a local-time
493 /// column shifts every event by the venue's UTC offset with nothing downstream able to notice:
494 /// the timeline is still monotonic and the prices are still right, so a backtest simply trades
495 /// on data it could not have had. This check is the only place that difference is visible.
496 #[error("export column {column:?} is {found}, but this integration requires {required}")]
497 UnsupportedColumnType {
498 column: String,
499 required: &'static str,
500 found: String,
501 },
502
503 /// A column the resolved layout has no substitute for is null on some row.
504 ///
505 /// `ts`, `symbol` and the layout's price columns are `REQUIRED` on every measured artifact, but a
506 /// writer change would make them nullable without altering anything else the decoder keys on, so
507 /// the schema check accepts `OPTIONAL` and this reports a value that is actually missing. None of
508 /// them is substitutable: a null timestamp has no place on a timeline, a null symbol cannot be
509 /// checked against the descriptor — the check that catches a mis-described file — and a null
510 /// price would have to be invented.
511 #[error("export column {column:?} is null on a row that has no substitute for it")]
512 NullValue { column: &'static str },
513
514 /// A row's `symbol` column does not match the symbol the export descriptor names.
515 ///
516 /// Every export is single-symbol, so this means the file and its descriptor disagree — the
517 /// artifact is not the one the caller thinks it is. Caught because attributing it to the
518 /// descriptor's instrument would be silent misattribution, and because `BP` and `BP.L` are
519 /// different instruments quoted in different currencies.
520 #[error("export symbol mismatch: descriptor says {expected:?} but a row carries {found:?}")]
521 SymbolMismatch { expected: String, found: String },
522
523 /// An artifact's timestamps do not advance as its layout requires.
524 ///
525 /// A backtest fed an unsorted stream produces a non-monotonic clock and wrong results in
526 /// release, with no failure point, so this is rejected at decode.
527 ///
528 /// # What counts as a violation depends on the layout
529 /// - **Tick and quote** tapes are *non-decreasing*: several prints routinely share a
530 /// microsecond, so ties are permitted and only a step backwards is rejected.
531 /// - **Candle** artifacts at a fixed resolution are *strictly ascending*: two bars of one
532 /// series cannot share an open, so they cannot share the close derived from it. A tie there
533 /// means the file holds more than one series, or repeats a row. It does **not** mean the
534 /// declared resolution is wrong — a fixed interval is a constant shift from open to close, so
535 /// it cancels out of this comparison entirely and a mis-declared one produces distinct,
536 /// strictly ascending closes. That is a separate check on bar *spacing*, which reports
537 /// [`UnexpectedCandleResolution`](Self::UnexpectedCandleResolution). Calendar intervals are
538 /// exempt from strictness: month arithmetic clamps day-of-month, so two distinct opens can
539 /// legitimately share a close.
540 #[error("export timestamps are not ascending: {found} follows {previous}")]
541 NonMonotonicTimestamps {
542 previous: DateTime<Utc>,
543 found: DateTime<Utc>,
544 },
545
546 /// A row's timestamp is outside the representable [`DateTime<Utc>`] range.
547 #[error("export timestamp {micros}µs is not representable")]
548 TimestampNotRepresentable { micros: i64 },
549
550 /// A provider `f64` has no [`rust_decimal::Decimal`] representation.
551 ///
552 /// Surfaced rather than substituted: a zero or a clamp here would put a real-looking price
553 /// into fees, PnL and risk notional.
554 #[error("price {value} is not representable as a decimal: {message}")]
555 PriceNotRepresentable { value: f64, message: String },
556
557 /// No registered instrument is priced on this exchange under the requested display symbol:
558 /// none has it as its data-side name there (its `DataVenue`'s, or its own when it declares
559 /// none).
560 ///
561 /// Raised when deriving an [`InstrumentIndex`] from the caller's registry rather than
562 /// accepting one. That derivation is what makes a fabricated index unrepresentable — the index
563 /// is a public, unbounded `usize` and engine state indexes positionally — and it is the only
564 /// check that catches a symbol typo, which would otherwise leave one instrument silently
565 /// receiving no data at all.
566 ///
567 /// [`InstrumentIndex`]: rustrade_instrument::instrument::InstrumentIndex
568 #[error(
569 "no registered instrument is priced on {exchange} under {symbol:?}; symbols priced there: [{registered}]"
570 )]
571 UnknownInstrument {
572 symbol: String,
573 exchange: rustrade_instrument::exchange::ExchangeId,
574 registered: String,
575 },
576
577 /// More than one registered instrument is priced on this exchange under the requested display
578 /// symbol, such as one listing executed on two venues with both priced here.
579 ///
580 /// A candle source delivers its symbol's prices to a single instrument, so picking one would
581 /// leave the other silently unpriced. `instruments` lists the `name_internal` of each.
582 #[error(
583 "{symbol:?} on {exchange} prices more than one registered instrument: [{instruments}]; a candle source feeds one"
584 )]
585 AmbiguousInstrument {
586 symbol: String,
587 exchange: rustrade_instrument::exchange::ExchangeId,
588 instruments: String,
589 },
590
591 /// The registered instrument prices this symbol in a different asset than the provider quotes
592 /// it in.
593 ///
594 /// The provider publishes no unit alongside a price, so the asset the caller registered *is*
595 /// the unit. Registering a `.L` listing against `gbp` rather than `gbx` is therefore not a
596 /// naming preference: `BP.L` prints ~548 where BP trades around £5.48, so every notional, fee,
597 /// unrealised PnL and balance derived from it is 100× wrong, and nothing downstream can tell.
598 ///
599 /// Raised at the same boundary as [`UnknownInstrument`](Self::UnknownInstrument), and for the
600 /// same reason: it is the one place the provider's view of a symbol and the caller's registry
601 /// meet, so it is the only place the disagreement is visible.
602 ///
603 /// Compared on [`AssetNameInternal`], because that is the identity the engine keys assets by —
604 /// `GBX` and `gbx` are one asset, and `GBP` is a different one.
605 ///
606 /// # `expected` is derived, not published
607 /// It comes from [`quote_asset`](super::market::quote_asset), which maps a venue suffix to an
608 /// asset and **defaults to USD** for any suffix outside its table. So a correctly-registered
609 /// listing on a venue that table does not cover is rejected here, with an `expected` the
610 /// provider never stated. The message says "this integration derives" rather than "the provider
611 /// quotes" for that reason. A caller in that position wants
612 /// [`LseCandleSource::new`](super::backtest::LseCandleSource::new), which carries no check.
613 ///
614 /// [`AssetNameInternal`]: rustrade_instrument::asset::name::AssetNameInternal
615 #[error(
616 "instrument {symbol:?} on {exchange} is registered with quote asset {registered:?}, but \
617 this integration derives {expected:?} for that symbol from its venue suffix (defaulting \
618 to USD); prices carry no unit, so a mismatch misprices every notional, fee and balance"
619 )]
620 QuoteAssetMismatch {
621 symbol: String,
622 exchange: rustrade_instrument::exchange::ExchangeId,
623 expected: String,
624 registered: String,
625 },
626
627 /// The Parquet artifact could not be read.
628 #[cfg(feature = "lse-parquet")]
629 #[error("parquet error: {0}")]
630 Parquet(#[from] parquet::errors::ParquetError),
631}
632
633/// A coarse classification of [`LseError`], for deciding what to do next.
634///
635/// [`LseError`] is `#[non_exhaustive]` and has a variant per measured provider behaviour, which is
636/// the right shape for diagnosing one failure and the wrong shape for *reacting* to one: a consumer
637/// writing retry logic wants five or six categories, not thirty, and should not have to revisit its
638/// match every time an endpoint gains a variant. This is that projection, and it is what
639/// [`DataError::Lse`](crate::error::DataError::Lse) carries so the classification survives being
640/// flattened into a `String`.
641///
642/// Mirrors `DatabentoErrorKind` — not linked, since that lives behind the `databento` feature and
643/// this type does not — plus the categories this integration's file and export surface adds.
644#[derive(
645 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
646)]
647#[non_exhaustive]
648pub enum LseErrorKind {
649 /// API key missing, malformed, or rejected. Retrying changes nothing until it is fixed.
650 Authentication,
651 /// Throttled, or a metered allowance is spent. Resumable once it replenishes — see
652 /// [`LseError::RateLimited`]'s `retry_after` and [`LseError::QuotaExceeded`]'s status.
653 RateLimit,
654 /// Transport-level failure. Resumable; a retry may well succeed.
655 Network,
656 /// An export job did not reach a terminal state within the caller's timeout. Resumable: the
657 /// job keeps building and its identifier stays valid, so polling again spends no allowance.
658 Timeout,
659 /// The provider rejected the request or the job failed. Not resumable by retrying alone.
660 Api,
661 /// The bytes could not be read as what they claim to be — a malformed body, an unsupported or
662 /// mistyped Parquet schema, a value with no representation, an ordering violation, an
663 /// integrity mismatch. Terminal: the data is what it is.
664 Decode,
665 /// The caller's request or instrument registry is wrong. Terminal until the caller changes it.
666 InvalidInput,
667 /// A local filesystem failure.
668 Io,
669}
670
671impl LseErrorKind {
672 /// Whether retrying the same operation could succeed without the caller changing anything.
673 ///
674 /// [`RateLimit`](Self::RateLimit) and [`Timeout`](Self::Timeout) require *waiting* first; this
675 /// says the attempt is worth making again, not that it is worth making immediately.
676 #[must_use]
677 pub fn is_resumable(&self) -> bool {
678 matches!(self, Self::RateLimit | Self::Network | Self::Timeout)
679 }
680}
681
682impl std::fmt::Display for LseErrorKind {
683 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684 let name = match self {
685 Self::Authentication => "authentication",
686 Self::RateLimit => "rate limit",
687 Self::Network => "network",
688 Self::Timeout => "timeout",
689 Self::Api => "API",
690 Self::Decode => "decode",
691 Self::InvalidInput => "invalid input",
692 Self::Io => "IO",
693 };
694 f.write_str(name)
695 }
696}
697
698impl LseError {
699 /// Classify this error for a consumer deciding whether to retry, wait, or stop.
700 ///
701 /// Exhaustive on purpose: a new variant must produce a compile error here rather than fall
702 /// through to a default. Getting this wrong is not a visible failure — it silently retries
703 /// something terminal, or abandons something that would have succeeded.
704 #[must_use]
705 pub fn kind(&self) -> LseErrorKind {
706 match self {
707 Self::EnvVar(_) | Self::InvalidCredential(_) => LseErrorKind::Authentication,
708 Self::RateLimited { .. } | Self::QuotaExceeded { .. } => LseErrorKind::RateLimit,
709 Self::Http(_) => LseErrorKind::Network,
710 Self::ExportTimeout { .. } => LseErrorKind::Timeout,
711 // The request was valid and the provider cannot serve it whole; retrying changes nothing.
712 Self::Api { .. }
713 | Self::ExportFailed { .. }
714 | Self::OptionFlowWindowSaturated { .. } => LseErrorKind::Api,
715 Self::Io { .. } => LseErrorKind::Io,
716
717 // The caller's request or registry, not the provider's answer.
718 Self::InvalidInput { .. }
719 | Self::UnsupportedInterval { .. }
720 | Self::UnknownDataset(_)
721 | Self::AmbiguousSlug { .. }
722 | Self::UnknownInstrument { .. }
723 | Self::AmbiguousInstrument { .. }
724 | Self::QuoteAssetMismatch { .. }
725 | Self::UnknownBondYieldCountry { .. }
726 | Self::UnknownBondYieldMaturity { .. }
727 | Self::BondYieldRangeOutsideCoverage { .. }
728 | Self::UnknownCalendarCountry { .. }
729 | Self::UnknownCalendarImpact { .. }
730 | Self::CalendarRangeOutsideCoverage { .. } => LseErrorKind::InvalidInput,
731
732 // Everything the provider sent that could not be read as what it claims to be. An
733 // integrity or job mismatch belongs here rather than under `Api`: the request was
734 // accepted and answered, and it is the payload that does not hold up.
735 Self::Deserialize { .. }
736 | Self::TimestampOverflow { .. }
737 | Self::CursorOverflow { .. }
738 | Self::UnexpectedCandleRange { .. }
739 | Self::NonMonotonicCandlePage { .. }
740 | Self::UnexpectedCandleResolution { .. }
741 | Self::UnexpectedOptionFlowRow { .. }
742 | Self::IntegrityMismatch { .. }
743 | Self::ExportJobMismatch { .. }
744 | Self::UnsupportedSchema { .. }
745 | Self::UnsupportedColumnType { .. }
746 | Self::NullValue { .. }
747 | Self::SymbolMismatch { .. }
748 | Self::NonMonotonicTimestamps { .. }
749 | Self::TimestampNotRepresentable { .. }
750 | Self::PriceNotRepresentable { .. } => LseErrorKind::Decode,
751
752 #[cfg(feature = "lse-parquet")]
753 Self::Parquet(_) => LseErrorKind::Decode,
754 }
755 }
756}
757
758/// Maximum retained length of a provider diagnostic, in bytes.
759///
760/// Well above any real message, and far below a pathological proxy error page.
761const MAX_DETAIL_BYTES: usize = 2 * 1024;
762
763/// Extract the provider's human-readable diagnostic from an error response body.
764///
765/// # Why this is not `body["detail"].as_str()`
766///
767/// The provider encodes errors inconsistently, and both forms are measured:
768///
769/// ```text
770/// 400/404: {"detail":"{\"detail\":\"invalid timeframe '7q'; valid: 1s, 5s, ...\"}"}
771/// 401: {"detail":"invalid api key"}
772/// ```
773///
774/// On `400`/`404` the value of `detail` is *itself* a JSON document encoded as a string, so
775/// reading one level surfaces raw JSON in a user-facing error message. This unwraps repeatedly
776/// until `detail` stops being re-encoded, and falls back to the whole body when the response is
777/// not in either shape (a proxy or CDN page, say). The result is always truncated.
778pub(crate) fn extract_detail(body: &str) -> String {
779 let mut current = body.trim().to_owned();
780
781 // Bounded rather than `loop`: two levels are measured, and a handful of iterations is ample for
782 // any further nesting the provider might add without risking a pathological input spinning here.
783 for _ in 0..4 {
784 let Ok(serde_json::Value::Object(object)) =
785 serde_json::from_str::<serde_json::Value>(¤t)
786 else {
787 break;
788 };
789 let Some(serde_json::Value::String(detail)) = object.get("detail") else {
790 break;
791 };
792 current = detail.trim().to_owned();
793 }
794
795 crate::exchange::http::truncate_str(¤t, MAX_DETAIL_BYTES)
796}
797
798#[cfg(test)]
799#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
800mod tests {
801 use super::*;
802
803 #[test]
804 fn single_encoded_detail_is_read_directly() {
805 // The measured 401 shape.
806 assert_eq!(
807 extract_detail(r#"{"detail":"invalid api key"}"#),
808 "invalid api key"
809 );
810 }
811
812 #[test]
813 fn double_encoded_detail_is_unwrapped_to_the_message() {
814 // The measured 400 shape: `detail` is a JSON document encoded as a string. Reading one
815 // level would put raw JSON in front of the user.
816 let body = r#"{"detail":"{\"detail\":\"invalid timeframe '7q'; valid: 1s, 5s, 15s\"}"}"#;
817
818 assert_eq!(
819 extract_detail(body),
820 "invalid timeframe '7q'; valid: 1s, 5s, 15s"
821 );
822 }
823
824 #[test]
825 fn double_encoded_not_found_is_unwrapped() {
826 // The measured 404 shape.
827 let body =
828 r#"{"detail":"{\"detail\":\"'NOPE_XYZ' has no candle data; browse /catalog\"}"}"#;
829
830 assert_eq!(
831 extract_detail(body),
832 "'NOPE_XYZ' has no candle data; browse /catalog"
833 );
834 }
835
836 #[test]
837 fn a_non_json_body_is_returned_as_is() {
838 // A proxy or CDN error page is not in either shape, and is still the best diagnostic there is.
839 assert_eq!(
840 extract_detail("<html>502 Bad Gateway</html>"),
841 "<html>502 Bad Gateway</html>"
842 );
843 }
844
845 #[test]
846 fn json_without_a_detail_field_is_returned_whole() {
847 assert_eq!(extract_detail(r#"{"error":"nope"}"#), r#"{"error":"nope"}"#);
848 }
849
850 #[test]
851 fn a_non_string_detail_is_not_unwrapped() {
852 // Only a re-encoded *string* is a nesting level; an object is already the message.
853 assert_eq!(
854 extract_detail(r#"{"detail":{"code":7}}"#),
855 r#"{"detail":{"code":7}}"#
856 );
857 }
858
859 #[test]
860 fn an_oversized_diagnostic_is_truncated() {
861 let body = format!(r#"{{"detail":"{}"}}"#, "a".repeat(MAX_DETAIL_BYTES * 3));
862
863 assert_eq!(extract_detail(&body).len(), MAX_DETAIL_BYTES);
864 }
865
866 #[test]
867 fn nesting_deeper_than_the_budget_stops_exactly_at_the_budget() {
868 // Six wrappers against a budget of four: it must stop mid-way rather than spin, and the
869 // assertion pins *where* it stopped. Asserting only that the result is non-empty would
870 // hold for any budget at all, including an off-by-one one.
871 let wrap =
872 |inner: &str| serde_json::to_string(&serde_json::json!({ "detail": inner })).unwrap();
873
874 let mut body = r#"{"detail":"bottom"}"#.to_owned();
875 for _ in 0..6 {
876 body = wrap(&body);
877 }
878
879 // Four unwraps consume four of the six wrappers, leaving the two-deep remainder — still
880 // encoded JSON, because the budget ran out before the message did.
881 let mut expected = r#"{"detail":"bottom"}"#.to_owned();
882 for _ in 0..2 {
883 expected = wrap(&expected);
884 }
885
886 assert_eq!(extract_detail(&body), expected);
887 }
888
889 #[test]
890 fn an_empty_body_yields_an_empty_diagnostic() {
891 assert_eq!(extract_detail(""), "");
892 }
893
894 /// The classification a consumer actually branches on.
895 ///
896 /// `kind()` is exhaustive, so a *new* variant is a compile error — but nothing in the compiler
897 /// checks that an existing variant is classified *correctly*, and getting that wrong is
898 /// invisible: it silently retries something terminal, or abandons something that would have
899 /// succeeded on the next attempt. These pin the pairs where that costs something.
900 #[test]
901 fn the_resumable_kinds_are_the_ones_a_retry_could_actually_fix() {
902 // The whole point of carrying `kind` through `DataError::Lse`: a consumer must be able to
903 // tell "wait and try again" from "stop" without substring-matching a `Display`.
904 assert!(LseErrorKind::RateLimit.is_resumable());
905 assert!(LseErrorKind::Network.is_resumable());
906 assert!(LseErrorKind::Timeout.is_resumable());
907
908 assert!(!LseErrorKind::Authentication.is_resumable());
909 assert!(!LseErrorKind::Api.is_resumable());
910 assert!(!LseErrorKind::Decode.is_resumable());
911 assert!(!LseErrorKind::InvalidInput.is_resumable());
912 assert!(!LseErrorKind::Io.is_resumable());
913 }
914
915 #[test]
916 fn a_rate_limit_classifies_as_resumable_and_a_decode_failure_does_not() {
917 // The pair the whole projection exists for, checked end to end from the variant rather
918 // than from the kind: `RateLimited` is documented resumable, `Deserialize` is terminal,
919 // and a consumer that confused them would either spin forever on a malformed body or
920 // abandon a fetch that a 60-second wait would have completed.
921 let rate_limited = LseError::RateLimited {
922 retry_after: Some(Duration::from_secs(60)),
923 };
924 assert_eq!(rate_limited.kind(), LseErrorKind::RateLimit);
925 assert!(rate_limited.kind().is_resumable());
926
927 let decode = LseError::Deserialize {
928 message: "unexpected end of input".to_owned(),
929 };
930 assert_eq!(decode.kind(), LseErrorKind::Decode);
931 assert!(!decode.kind().is_resumable());
932 }
933
934 #[test]
935 fn an_exhausted_allowance_is_a_rate_limit_and_an_export_timeout_is_a_timeout() {
936 // Both are export-path variants that a naive reading would file under `Api` -- they arrive
937 // as a 429 and as a job that never finished. Classifying either as terminal would stop a
938 // consumer that only had to wait.
939 assert_eq!(
940 LseError::QuotaExceeded { status: None }.kind(),
941 LseErrorKind::RateLimit
942 );
943 assert_eq!(
944 LseError::ExportTimeout {
945 job_id: "job-1".to_owned(),
946 status: "running".to_owned(),
947 }
948 .kind(),
949 LseErrorKind::Timeout
950 );
951 }
952
953 #[test]
954 fn a_payload_that_was_accepted_but_does_not_hold_up_is_a_decode_failure_not_an_api_error() {
955 // The boundary that is easiest to get wrong: the request succeeded and the provider
956 // answered, so `status` is a success code -- but the bytes are not what they claim to be.
957 // Filing these under `Api` would tell a consumer to fix its request, which cannot help.
958 let decode_variants = [
959 LseError::IntegrityMismatch {
960 path: std::path::PathBuf::from("/tmp/export.parquet.part"),
961 expected: "abc".to_owned(),
962 actual: "def".to_owned(),
963 discarded: true,
964 },
965 LseError::ExportJobMismatch {
966 job_id: "job-1".to_owned(),
967 field: "symbol".to_owned(),
968 requested: "BP.L".to_owned(),
969 reported: "BP".to_owned(),
970 },
971 LseError::NonMonotonicTimestamps {
972 previous: DateTime::UNIX_EPOCH,
973 found: DateTime::UNIX_EPOCH,
974 },
975 LseError::SymbolMismatch {
976 expected: "BP.L".to_owned(),
977 found: "BP".to_owned(),
978 },
979 ];
980
981 for error in decode_variants {
982 assert_eq!(error.kind(), LseErrorKind::Decode, "{error}");
983 }
984
985 // The contrast: an error the provider itself reported about the request.
986 assert_eq!(
987 LseError::Api {
988 status: 404,
989 message: "no candle data".to_owned(),
990 }
991 .kind(),
992 LseErrorKind::Api
993 );
994 }
995
996 #[test]
997 fn a_caller_side_mistake_is_invalid_input_rather_than_an_api_error() {
998 // These are all detected before or independently of the request, so reporting them as the
999 // provider's fault would send the caller looking in the wrong place.
1000 let caller_variants = [
1001 LseError::UnknownDataset("nope".to_owned()),
1002 LseError::UnsupportedInterval {
1003 interval: CandleInterval::Sec1,
1004 },
1005 LseError::QuoteAssetMismatch {
1006 symbol: "BP.L".to_owned(),
1007 exchange: rustrade_instrument::exchange::ExchangeId::Other,
1008 expected: "gbx".to_owned(),
1009 registered: "gbp".to_owned(),
1010 },
1011 ];
1012
1013 for error in caller_variants {
1014 assert_eq!(error.kind(), LseErrorKind::InvalidInput, "{error}");
1015 }
1016
1017 // A credential problem is its own category: also terminal, but the fix is a key rather
1018 // than a request.
1019 assert_eq!(
1020 LseError::EnvVar("LSE_API_KEY is not set".to_owned()).kind(),
1021 LseErrorKind::Authentication
1022 );
1023 }
1024
1025 #[test]
1026 fn the_kind_survives_flattening_into_the_crate_error() {
1027 // The reason `DataError::Lse` is a struct variant at all. Once an LSE failure is flattened
1028 // for the generic stream helpers, `message` is a `String` and the classification would be
1029 // unrecoverable from it -- so `kind` has to be carried alongside, not derived later.
1030 let error = crate::error::DataError::from(LseError::RateLimited { retry_after: None });
1031
1032 let crate::error::DataError::Lse { kind, message } = &error else {
1033 panic!("unexpected error variant: {error:?}")
1034 };
1035
1036 assert_eq!(*kind, LseErrorKind::RateLimit);
1037 assert!(kind.is_resumable());
1038 // The message is still the full diagnostic; `kind` is an addition to it, not a
1039 // replacement.
1040 assert!(message.contains("rate limited"), "{message}");
1041 }
1042}