Skip to main content

rustrade_execution/
error.rs

1//! Error types for [`ExecutionClient`](super::client::ExecutionClient) operations.
2//!
3//! # Retry Semantics
4//!
5//! Use [`ClientError::is_transient`] to determine if an operation should be retried.
6//! Transient errors (connectivity issues, rate limits) may succeed on retry with
7//! appropriate backoff. Non-transient errors (invalid instrument, insufficient
8//! balance) will fail identically on retry — the caller must change the request.
9//!
10//! The `is_transient()` method is the stable contract for retry decisions. Prefer
11//! it over pattern matching on specific variants, as the internal taxonomy may
12//! evolve while `is_transient()` semantics remain stable.
13
14use derive_more::Constructor;
15use rust_decimal::Decimal;
16use rustrade_instrument::{
17    asset::{AssetIndex, name::AssetNameExchange},
18    exchange::ExchangeId,
19    index::error::IndexError,
20    instrument::{InstrumentIndex, name::InstrumentNameExchange},
21};
22use rustrade_integration::error::SocketError;
23use serde::{Deserialize, Serialize};
24use std::fmt;
25use thiserror::Error;
26
27/// Type alias for a [`ClientError`] that is keyed on [`AssetNameExchange`] and
28/// [`InstrumentNameExchange`] (yet to be indexed).
29pub type UnindexedClientError = ClientError<AssetNameExchange, InstrumentNameExchange>;
30
31/// Type alias for a [`ApiError`] that is keyed on [`AssetNameExchange`] and
32/// [`InstrumentNameExchange`] (yet to be indexed).
33pub type UnindexedApiError = ApiError<AssetNameExchange, InstrumentNameExchange>;
34
35/// Type alias for a [`OrderError`] that is keyed on [`AssetNameExchange`] and
36/// [`InstrumentNameExchange`] (yet to be indexed).
37pub type UnindexedOrderError = OrderError<AssetNameExchange, InstrumentNameExchange>;
38
39/// Represents all errors produced by an [`ExecutionClient`](super::client::ExecutionClient).
40#[non_exhaustive]
41#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
42pub enum ClientError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
43    /// Connectivity based error.
44    ///
45    /// eg/ Timeout.
46    #[error("Connectivity: {0}")]
47    Connectivity(#[from] ConnectivityError),
48
49    /// API based error.
50    ///
51    /// eg/ RateLimit.
52    #[error("API: {0}")]
53    Api(#[from] ApiError<AssetKey, InstrumentKey>),
54
55    /// A background task panicked or was cancelled during an operation.
56    ///
57    /// This indicates a bug or unexpected runtime condition (e.g., a tokio
58    /// `spawn_blocking` task panicked). The operation was not retried and
59    /// the caller should treat this as non-recoverable, requiring operator
60    /// attention.
61    #[error("task failed: {0}")]
62    TaskFailed(String),
63
64    /// An opaque error from an upstream library that cannot be further classified.
65    ///
66    /// This is a catch-all for errors that don't fit into [`Self::Connectivity`] or
67    /// [`Self::Api`] categories — typically because the upstream library (e.g., ibapi,
68    /// binance-sdk) returns unstructured errors.
69    ///
70    /// Conservatively treated as non-transient. If you encounter this error
71    /// frequently, consider filing an issue to improve error classification.
72    #[error("internal error: {0}")]
73    Internal(String),
74
75    /// A bounded read of fills could not advance past its start: more fills share the span's
76    /// first moment than one call can read, so a read from the same start would stop at the same
77    /// place.
78    ///
79    /// [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades) returns it
80    /// rather than a [`TradesRead`](crate::trade::TradesRead) whose `resume` could only loop. The
81    /// fills it read are not returned. Callers should alert operators and reconcile the span
82    /// another way.
83    #[error("trade read stopped after {fills_read} fills without advancing past its start")]
84    Truncated {
85        /// How many fills the read returned before it stopped.
86        fills_read: usize,
87    },
88
89    /// Open orders snapshot was truncated at the API's row limit.
90    ///
91    /// Unlike [`Self::Truncated`] (which applies to paginated fill reads), this
92    /// error indicates a single-request endpoint hit its maximum row count.
93    /// Alpaca's `/v2/orders` endpoint caps results at 500; accounts with more
94    /// concurrent open orders will have an incomplete snapshot.
95    ///
96    /// Callers should alert operators — an incomplete order snapshot can cause
97    /// duplicate submissions, missed cancellations, or incorrect position sizing.
98    #[error("open orders snapshot truncated at {limit} results — data may be incomplete")]
99    TruncatedSnapshot {
100        /// Maximum number of rows returned by the single-request endpoint.
101        limit: usize,
102    },
103}
104
105impl<AssetKey, InstrumentKey> ClientError<AssetKey, InstrumentKey> {
106    /// Returns `true` if this error is likely transient and the operation
107    /// may succeed if retried after a suitable backoff.
108    ///
109    /// The caller is responsible for retry limits and backoff strategy.
110    /// This method classifies the error only — it does not implement policy.
111    ///
112    /// # Transient errors
113    /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
114    /// - [`Api::RateLimit`](ApiError::RateLimit)
115    ///
116    /// # Non-transient errors
117    /// - Other [`Api`](Self::Api) errors (invalid instrument, insufficient balance, etc.)
118    /// - [`TaskFailed`](Self::TaskFailed) (indicates a bug)
119    /// - [`Internal`](Self::Internal) (unknown — conservatively non-transient)
120    /// - [`Truncated`](Self::Truncated) / [`TruncatedSnapshot`](Self::TruncatedSnapshot)
121    pub fn is_transient(&self) -> bool {
122        match self {
123            Self::Connectivity(e) => e.is_transient(),
124            Self::Api(ApiError::RateLimit) => true,
125            Self::Api(_) => false,
126            Self::TaskFailed(_) => false,
127            Self::Internal(_) => false,
128            Self::Truncated { .. } => false,
129            Self::TruncatedSnapshot { .. } => false,
130        }
131    }
132}
133
134/// Represents all connectivity-centric errors.
135///
136/// Connectivity errors are generally intermittent / non-deterministic (eg/ Timeout).
137/// All variants are transient — retry with exponential backoff (typically 1-30s).
138#[non_exhaustive]
139#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
140pub enum ConnectivityError {
141    /// Exchange is offline, likely due to scheduled maintenance.
142    ///
143    /// Transient — retry with backoff. Maintenance windows typically last minutes
144    /// to hours; consider longer backoff intervals (30s-5min) to avoid log spam.
145    #[error("Exchange offline: {0}")]
146    ExchangeOffline(ExchangeId),
147
148    /// Request timed out before a response was received.
149    ///
150    /// Transient — retry with backoff. May indicate network congestion, server
151    /// overload, or an overly aggressive timeout. Consider increasing timeout
152    /// on subsequent attempts.
153    #[error("request timed out")]
154    Timeout,
155
156    /// Network-level socket error (connection refused, reset, DNS failure, etc.).
157    ///
158    /// Transient — retry with backoff. If persistent, may indicate firewall
159    /// issues, incorrect endpoint configuration, or prolonged server outage.
160    #[error("{0}")]
161    Socket(String),
162}
163
164impl From<SocketError> for ConnectivityError {
165    fn from(value: SocketError) -> Self {
166        Self::Socket(value.to_string())
167    }
168}
169
170impl ConnectivityError {
171    /// Returns `true` if this connectivity error is transient.
172    ///
173    /// All connectivity errors are considered transient — they represent
174    /// temporary network or server conditions that may resolve with retry.
175    pub fn is_transient(&self) -> bool {
176        match self {
177            Self::ExchangeOffline(_) => true,
178            Self::Timeout => true,
179            Self::Socket(_) => true,
180        }
181    }
182}
183
184/// Represents all API errors generated by an exchange.
185///
186/// These typically indicate a request is invalid for some reason (eg/ BalanceInsufficient).
187/// Most variants are **not transient** — the same request will fail identically on retry.
188/// The exception is [`RateLimit`](Self::RateLimit), which is transient.
189#[non_exhaustive]
190#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
191pub enum ApiError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
192    /// Provided asset identifier is invalid or not supported.
193    ///
194    /// For example:
195    /// - The [`AssetNameExchange`] was an invalid format.
196    ///
197    /// Not transient — do not retry. The asset identifier must be corrected.
198    ///
199    /// Indexed only when the [`ExecutionInstrumentMap`] holds the asset. Otherwise there is no
200    /// index to carry, and [`AccountEventIndexer`] reports it as
201    /// [`OrderRejected`](Self::OrderRejected) (from an order request) or
202    /// [`RequestRejected`](Self::RequestRejected) (from any other request), holding this error's
203    /// own message.
204    ///
205    /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
206    /// [`AccountEventIndexer`]: crate::indexer::AccountEventIndexer
207    #[error("asset {0} invalid: {1}")]
208    AssetInvalid(AssetKey, String),
209
210    /// Provided instrument identifier is invalid or not supported.
211    ///
212    /// For example:
213    /// - The exchange does not have a market for an instrument.
214    /// - The [`InstrumentNameExchange`] was an invalid format.
215    ///
216    /// Not transient — do not retry. The instrument identifier must be corrected.
217    ///
218    /// Indexed only when the [`ExecutionInstrumentMap`] holds the instrument; otherwise reported
219    /// as [`AssetInvalid`](Self::AssetInvalid) describes.
220    ///
221    /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
222    #[error("instrument {0} invalid: {1}")]
223    InstrumentInvalid(InstrumentKey, String),
224
225    /// Request was rejected due to rate limiting.
226    ///
227    /// The exchange enforces request quotas and the caller has exceeded them.
228    /// Some exchanges provide a `Retry-After` header or similar hint; the client
229    /// may incorporate this into internal retry logic before surfacing this error.
230    ///
231    /// Transient — retry with backoff. Typical backoff is 10-60 seconds, but
232    /// respect exchange-specific guidance if available.
233    #[error("rate limit exceeded")]
234    RateLimit,
235
236    /// Authentication failed (invalid credentials, expired key, bad signature).
237    ///
238    /// Unlike other API errors which affect a single request, authentication
239    /// failures indicate that **all** subsequent requests will fail until
240    /// credentials are corrected. Callers should halt trading and alert operators.
241    ///
242    /// Not transient — do not retry. Fix credentials and restart.
243    #[error("authentication failed: {0}")]
244    Unauthenticated(String),
245
246    /// Balance of an asset is insufficient to execute the requested operation.
247    ///
248    /// The asset is `None` when it is not known which asset ran short:
249    /// - the venue's rejection does not name it (e.g. Binance's "Account has insufficient
250    ///   balance for requested action"), and a client does not guess it from the order;
251    /// - the venue named an asset that the [`ExecutionInstrumentMap`] does not hold, so it has no
252    ///   index. [`AccountEventIndexer::api_error`] logs the name it dropped.
253    ///
254    /// The `String` carries the venue's message.
255    ///
256    /// Not transient — do not retry the same request. Reduce order size or
257    /// deposit additional funds.
258    ///
259    /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
260    /// [`AccountEventIndexer::api_error`]: crate::indexer::AccountEventIndexer::api_error
261    #[error(
262        "balance insufficient{asset}: {1}",
263        asset = .0.as_ref().map(|asset| format!(" for asset {asset}")).unwrap_or_default()
264    )]
265    BalanceInsufficient(Option<AssetKey>, String),
266
267    /// Order was rejected by the exchange for a business rule violation.
268    ///
269    /// Common causes include: price outside allowed range, quantity below
270    /// minimum, post-only order would cross, reduce-only with no position.
271    ///
272    /// Not transient — do not retry the same request. Adjust order parameters.
273    #[error("order rejected: {0}")]
274    OrderRejected(String),
275
276    /// Cancel request failed because the order was already cancelled.
277    ///
278    /// This is a state conflict, not an error per se — the desired end state
279    /// (order cancelled) has already been achieved.
280    ///
281    /// Not transient — do not retry. The order is already in the cancelled state.
282    #[error("order already cancelled")]
283    OrderAlreadyCancelled,
284
285    /// Cancel request failed because the order was already fully filled.
286    ///
287    /// This is a state conflict — the order completed before the cancel arrived.
288    /// The caller should reconcile their local state with the fill.
289    ///
290    /// Not transient — do not retry. The order no longer exists to cancel.
291    #[error("order already fully filled")]
292    OrderAlreadyFullyFilled,
293
294    /// Cancel request failed because the order had already expired.
295    ///
296    /// This is a state conflict — the order's own deadline retired it before the cancel arrived,
297    /// so the desired end state (order no longer working) has already been achieved. Distinct from
298    /// [`OrderAlreadyCancelled`](Self::OrderAlreadyCancelled) because nobody asked for it: a
299    /// time-in-force such as [`GoodTillDate`](crate::order::TimeInForce::GoodTillDate) retired it
300    /// unprompted, and a caller reconciling its local state needs to know which of the two happened.
301    ///
302    /// Not transient — do not retry. The order no longer exists to cancel.
303    #[error("order already expired")]
304    OrderAlreadyExpired,
305
306    /// Open request refused because its [`ClientOrderId`] is already in use at the venue.
307    ///
308    /// The refusal concerns this request alone: the order already under the id is **unaffected**,
309    /// and is not reported ended by it. What "in use" means is the venue's rule. On the
310    /// [`SimulatedVenue`] and the IBKR client it is an order still working under the id, and an id
311    /// is free again once its order has ended. Binance documents an id as unique among open
312    /// orders. The `String` carries the venue's message, or the client's own where it refused the
313    /// request before sending it.
314    ///
315    /// Reported by the [`SimulatedVenue`], by the IBKR client, which refuses the request before it
316    /// reaches TWS, since TWS never sees a client order id, and by the Binance spot and margin
317    /// clients, from Binance's `"Duplicate order sent."`. Other clients report a venue's duplicate
318    /// rejection as [`OrderRejected`](Self::OrderRejected) until its message has been confirmed.
319    ///
320    /// Not transient — the same request fails identically while the order under the id works.
321    /// Use another id, or wait for that order to end.
322    ///
323    /// [`ClientOrderId`]: crate::order::id::ClientOrderId
324    /// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
325    #[error("client order id in use: {0}")]
326    DuplicateClientOrderId(String),
327
328    /// The exchange refused the request itself, rather than an order it carried.
329    ///
330    /// The request was malformed or asked for something the venue does not serve — a missing,
331    /// malformed or out-of-range parameter, such as a query time window longer than the venue
332    /// allows. Distinct from [`OrderRejected`](Self::OrderRejected), which is an order failing a
333    /// business rule; this variant arises from queries and other non-order requests. The message
334    /// carries the venue's own error code where it provides one (e.g. `-1127 ...` on Binance).
335    ///
336    /// Not transient — the same request fails identically on retry. Change the request.
337    #[error("request rejected: {0}")]
338    RequestRejected(String),
339}
340
341/// Represents all errors that can be generated when cancelling or opening orders.
342///
343/// This is a subset of [`ClientError`] for order-specific operations. Use
344/// [`is_transient()`](Self::is_transient) to determine retry eligibility.
345#[non_exhaustive]
346#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
347pub enum OrderError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
348    /// Connectivity-based error (timeout, socket failure, exchange offline), or a venue failure
349    /// that leaves the order's status unknown.
350    ///
351    /// Transient — retry with backoff. See [`ConnectivityError`] for details. The order may or
352    /// may not have reached the matching engine, so before resubmitting an open request,
353    /// reconcile its state (open orders, fills); a blind retry can place it twice.
354    #[error("connectivity: {0}")]
355    Connectivity(#[from] ConnectivityError),
356
357    /// API-based error (rate limit, invalid instrument, order rejected, etc.).
358    ///
359    /// Retry semantics depend on the specific [`ApiError`] variant. Only
360    /// [`ApiError::RateLimit`] is transient; other variants are not.
361    #[error("order rejected: {0}")]
362    Rejected(#[from] ApiError<AssetKey, InstrumentKey>),
363
364    /// The order type is not supported by this connector.
365    ///
366    /// Non-transient — the connector does not support this order type (e.g.,
367    /// trailing stop orders on a connector that only supports market/limit).
368    #[error("unsupported order type: {0}")]
369    UnsupportedOrderType(String),
370
371    /// A quantity or price in the request has more precision than the venue accepts, so the
372    /// client refused it without sending it.
373    ///
374    /// Non-transient — change the request. A client that refuses this way never rounds a value
375    /// itself, because rounding would place a different order than the one requested; it
376    /// documents how to read the venue's precision so the caller can round first.
377    #[error("invalid precision: {0}")]
378    InvalidPrecision(PrecisionViolation),
379}
380
381/// A value in an order request with more precision than the venue accepts, or that is not
382/// positive.
383///
384/// Carried by [`OrderError::InvalidPrecision`].
385#[non_exhaustive]
386#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
387pub struct PrecisionViolation {
388    /// Which value of the request broke the rule.
389    pub field: OrderField,
390    /// The value as requested.
391    pub value: Decimal,
392    /// The rule it broke.
393    pub limit: PrecisionLimit,
394}
395
396impl PrecisionViolation {
397    /// The request's `field`, of `value`, breaks `limit`.
398    pub fn new(field: OrderField, value: Decimal, limit: PrecisionLimit) -> Self {
399        Self {
400            field,
401            value,
402            limit,
403        }
404    }
405}
406
407impl fmt::Display for PrecisionViolation {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        let Self {
410            field,
411            value,
412            limit,
413        } = self;
414        write!(f, "{field} {value} ")?;
415        match limit {
416            PrecisionLimit::DecimalPlaces { max } => {
417                write!(f, "has more than {max} decimal places")
418            }
419            PrecisionLimit::SignificantFigures { max } => {
420                write!(
421                    f,
422                    "is not an integer and has more than {max} significant figures"
423                )
424            }
425            PrecisionLimit::NotRepresentable => {
426                write!(f, "cannot be sent exactly in the venue's number format")
427            }
428            PrecisionLimit::NotPositive => write!(f, "is not positive"),
429        }
430    }
431}
432
433/// A value of an order request, as named by a [`PrecisionViolation`].
434#[non_exhaustive]
435#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
436pub enum OrderField {
437    /// The order's quantity.
438    Quantity,
439    /// The order's limit price.
440    Price,
441    /// The price that triggers a stop or take-profit order.
442    TriggerPrice,
443    /// A trailing order's offset.
444    TrailingOffset,
445}
446
447impl fmt::Display for OrderField {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        f.write_str(match self {
450            Self::Quantity => "quantity",
451            Self::Price => "price",
452            Self::TriggerPrice => "trigger price",
453            Self::TrailingOffset => "trailing offset",
454        })
455    }
456}
457
458/// A venue's precision rule, as broken by a [`PrecisionViolation`].
459#[non_exhaustive]
460#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
461pub enum PrecisionLimit {
462    /// The value may have at most `max` decimal places.
463    DecimalPlaces {
464        /// The most decimal places allowed.
465        max: u32,
466    },
467    /// The value may have at most `max` significant figures, unless it is an integer.
468    SignificantFigures {
469        /// The most significant figures allowed.
470        max: u32,
471    },
472    /// The value cannot be sent exactly: the venue's client library converts it to a number
473    /// format that would change it.
474    NotRepresentable,
475    /// The value is zero or negative. A quantity, price or trailing offset must be positive.
476    NotPositive,
477}
478
479impl<AssetKey, InstrumentKey> OrderError<AssetKey, InstrumentKey> {
480    /// Returns `true` if this error is likely transient and the operation
481    /// may succeed if retried after a suitable backoff.
482    ///
483    /// # Transient errors
484    /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
485    /// - [`Rejected(ApiError::RateLimit)`](ApiError::RateLimit)
486    ///
487    /// # Non-transient errors
488    /// - Other [`Rejected`](Self::Rejected) errors (invalid instrument, insufficient balance, etc.)
489    /// - [`UnsupportedOrderType`](Self::UnsupportedOrderType) and
490    ///   [`InvalidPrecision`](Self::InvalidPrecision)
491    pub fn is_transient(&self) -> bool {
492        match self {
493            Self::Connectivity(e) => e.is_transient(),
494            Self::Rejected(ApiError::RateLimit) => true,
495            Self::Rejected(_) => false,
496            Self::UnsupportedOrderType(_) | Self::InvalidPrecision(_) => false,
497        }
498    }
499}
500
501/// Why an account event stream ended.
502///
503/// Delivered in-band as the payload of
504/// [`AccountEventKind::StreamTerminated`](crate::AccountEventKind::StreamTerminated) so that
505/// stream death is a programmatic signal on the account feed rather than a log-only event. A
506/// consumer can react (re-sync via REST, re-establish the stream, halt trading) according to its
507/// own policy — the library reports *why* the stream ended without prescribing the response.
508///
509/// # Only in-band-deliverable terminations are represented
510///
511/// Every variant here is a termination the library can actually deliver to a consumer that is
512/// still listening. A consumer-initiated drop is deliberately **not** a variant: by the time a
513/// stream task observes the drop, the receiving half of the channel is already gone, so a
514/// `StreamTerminated` send would be a guaranteed no-op. `StreamTerminated` therefore always means
515/// "the stream died while you were still listening".
516///
517/// `#[non_exhaustive]` so venues may distinguish further termination causes in future without a
518/// breaking change (e.g. a venue able to signal a graceful shutdown over a still-open channel).
519#[non_exhaustive]
520#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
521pub enum StreamTerminationReason {
522    /// A venue with built-in reconnection exhausted its retry budget and gave up.
523    ///
524    /// Emitted by venues that reconnect internally (e.g. Binance spot/margin, Alpaca) once the
525    /// final attempt fails. `last_error` is the most recent underlying failure; `attempts` is the
526    /// number of reconnects tried before surrendering. The stream will not recover on its own —
527    /// the consumer must re-establish it.
528    #[error("reconnect budget exhausted after {attempts} attempts: {last_error}")]
529    ReconnectBudgetExhausted {
530        /// Number of reconnect attempts made before giving up.
531        attempts: u32,
532        /// The most recent underlying error that triggered surrender.
533        last_error: String,
534    },
535
536    /// The stream ended on an unrecoverable error with no automatic recovery.
537    ///
538    /// Emitted by venues without library-managed reconnection — e.g. IBKR (the underlying
539    /// subscription errored and the client does not retry), Hyperliquid (the SDK's internal
540    /// reconnection gave up and closed the channel), or the mock exchange (its broadcast stream
541    /// lagged past the buffer). The consumer is responsible for any re-establishment.
542    #[error("unrecoverable stream error: {0}")]
543    Error(String),
544}
545
546/// Type alias for an [`AccountStreamInitError`] that is keyed on [`AssetNameExchange`] and
547/// [`InstrumentNameExchange`] (yet to be indexed).
548pub type UnindexedAccountStreamInitError =
549    AccountStreamInitError<AssetNameExchange, InstrumentNameExchange>;
550
551/// Why an attempt to initialise an account stream, its snapshot and its updates, failed.
552#[non_exhaustive]
553#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
554pub enum AccountStreamInitError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
555    /// The [`ExecutionClient`](crate::client::ExecutionClient) failed to fetch the account
556    /// snapshot or to open the account stream. See [`ClientError::is_transient`].
557    #[error("{0}")]
558    Client(#[from] ClientError<AssetKey, InstrumentKey>),
559
560    /// The account snapshot named an exchange, asset or instrument that is not indexed. This does
561    /// not resolve on retry while the venue keeps reporting it.
562    #[error("IndexError: {0}")]
563    Index(#[from] IndexError),
564}
565
566/// A failed attempt to re-initialise an account stream after it ended.
567///
568/// Delivered in-band as the payload of
569/// [`AccountEventKind::ReinitFailed`](crate::AccountEventKind::ReinitFailed), once per failed
570/// attempt. Attempts continue with backoff; giving up is the consumer's decision.
571#[non_exhaustive]
572#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
573pub struct AccountReinitFailure<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
574    /// Consecutive failed attempts since the stream last initialised, starting at 1.
575    pub attempt: u32,
576    /// Why this attempt failed.
577    pub error: AccountStreamInitError<AssetKey, InstrumentKey>,
578}
579
580/// Represents errors related to exchange, asset and instrument identifier key lookups.
581#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
582pub enum KeyError {
583    /// Indicates an [`ExchangeId`] was encountered that was not indexed, so does not have a
584    /// corresponding `ExchangeIndex`.
585    #[error("ExchangeId: {0}")]
586    ExchangeId(String),
587
588    /// Indicates an [`AssetNameExchange`] was encountered that was not indexed, so does not have a
589    /// corresponding [`AssetIndex`].
590    #[error("AssetKey: {0}")]
591    AssetKey(String),
592
593    /// Indicates an [`InstrumentNameExchange`] was encountered that was no indexed, so does
594    /// not have a corresponding [`InstrumentIndex`].
595    #[error("InstrumentKey: {0}")]
596    InstrumentKey(String),
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn test_connectivity_error_is_transient() {
605        assert!(ConnectivityError::Timeout.is_transient());
606        assert!(ConnectivityError::Socket("connection refused".into()).is_transient());
607        assert!(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot).is_transient());
608    }
609
610    #[test]
611    fn test_client_error_is_transient_connectivity() {
612        let err: ClientError = ClientError::Connectivity(ConnectivityError::Timeout);
613        assert!(err.is_transient());
614
615        let err: ClientError = ClientError::Connectivity(ConnectivityError::Socket("err".into()));
616        assert!(err.is_transient());
617    }
618
619    #[test]
620    fn test_client_error_is_transient_rate_limit() {
621        let err: ClientError = ClientError::Api(ApiError::RateLimit);
622        assert!(err.is_transient());
623    }
624
625    #[test]
626    fn test_client_error_not_transient_api_errors() {
627        let err: ClientError =
628            ClientError::Api(ApiError::AssetInvalid(AssetIndex(0), "bad".into()));
629        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
630
631        let err: ClientError = ClientError::Api(ApiError::BalanceInsufficient(
632            Some(AssetIndex(0)),
633            "low".into(),
634        ));
635        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
636
637        let err: ClientError = ClientError::Api(ApiError::InstrumentInvalid(
638            InstrumentIndex(0),
639            "bad".into(),
640        ));
641        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
642
643        let err: ClientError = ClientError::Api(ApiError::OrderRejected("rejected".into()));
644        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
645
646        let err: ClientError = ClientError::Api(ApiError::OrderAlreadyCancelled);
647        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
648
649        let err: ClientError = ClientError::Api(ApiError::OrderAlreadyFullyFilled);
650        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
651
652        let err: ClientError =
653            ClientError::Api(ApiError::Unauthenticated("invalid signature".into()));
654        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
655
656        let err: ClientError =
657            ClientError::Api(ApiError::RequestRejected("-1127 More than 24 hours".into()));
658        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
659    }
660
661    #[test]
662    fn balance_insufficient_names_its_asset_only_when_known() {
663        let named: UnindexedApiError =
664            ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_string());
665        assert_eq!(named.to_string(), "balance insufficient for asset BTC: low");
666
667        let unnamed: UnindexedApiError = ApiError::BalanceInsufficient(None, "low".to_string());
668        assert_eq!(unnamed.to_string(), "balance insufficient: low");
669    }
670
671    #[test]
672    fn test_client_error_not_transient_task_failed() {
673        let err: ClientError = ClientError::TaskFailed("task panicked".into());
674        assert!(!err.is_transient());
675    }
676
677    #[test]
678    fn test_client_error_not_transient_internal() {
679        let err: ClientError = ClientError::Internal("unknown error".into());
680        assert!(!err.is_transient());
681    }
682
683    #[test]
684    fn test_client_error_not_transient_truncated() {
685        let err: ClientError = ClientError::Truncated { fills_read: 5_000 };
686        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
687
688        let err: ClientError = ClientError::TruncatedSnapshot { limit: 500 };
689        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
690    }
691
692    #[test]
693    fn test_client_error_is_transient_exchange_offline() {
694        let err: ClientError =
695            ClientError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
696        assert!(err.is_transient(), "expected transient for {:?}", err);
697    }
698
699    #[test]
700    fn test_order_error_is_transient_connectivity() {
701        let err: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
702        assert!(err.is_transient(), "expected transient for {:?}", err);
703
704        let err: UnindexedOrderError =
705            OrderError::Connectivity(ConnectivityError::Socket("connection reset".into()));
706        assert!(err.is_transient(), "expected transient for {:?}", err);
707
708        let err: UnindexedOrderError =
709            OrderError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
710        assert!(err.is_transient(), "expected transient for {:?}", err);
711    }
712
713    #[test]
714    fn test_order_error_is_transient_rate_limit() {
715        let err: UnindexedOrderError = OrderError::Rejected(ApiError::RateLimit);
716        assert!(err.is_transient(), "expected transient for {:?}", err);
717    }
718
719    #[test]
720    fn test_order_error_not_transient_api_errors() {
721        let err: UnindexedOrderError =
722            OrderError::Rejected(ApiError::OrderRejected("price out of range".into()));
723        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
724
725        let err: UnindexedOrderError = OrderError::Rejected(ApiError::OrderAlreadyCancelled);
726        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
727
728        let err: UnindexedOrderError = OrderError::Rejected(ApiError::BalanceInsufficient(
729            Some(AssetNameExchange::from("BTC")),
730            "insufficient".into(),
731        ));
732        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
733    }
734}