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}
444
445impl fmt::Display for OrderField {
446    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447        f.write_str(match self {
448            Self::Quantity => "quantity",
449            Self::Price => "price",
450            Self::TriggerPrice => "trigger price",
451        })
452    }
453}
454
455/// A venue's precision rule, as broken by a [`PrecisionViolation`].
456#[non_exhaustive]
457#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
458pub enum PrecisionLimit {
459    /// The value may have at most `max` decimal places.
460    DecimalPlaces {
461        /// The most decimal places allowed.
462        max: u32,
463    },
464    /// The value may have at most `max` significant figures, unless it is an integer.
465    SignificantFigures {
466        /// The most significant figures allowed.
467        max: u32,
468    },
469    /// The value cannot be sent exactly: the venue's client library converts it to a number
470    /// format that would change it.
471    NotRepresentable,
472    /// The value is zero or negative. A quantity or price must be positive.
473    NotPositive,
474}
475
476impl<AssetKey, InstrumentKey> OrderError<AssetKey, InstrumentKey> {
477    /// Returns `true` if this error is likely transient and the operation
478    /// may succeed if retried after a suitable backoff.
479    ///
480    /// # Transient errors
481    /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
482    /// - [`Rejected(ApiError::RateLimit)`](ApiError::RateLimit)
483    ///
484    /// # Non-transient errors
485    /// - Other [`Rejected`](Self::Rejected) errors (invalid instrument, insufficient balance, etc.)
486    /// - [`UnsupportedOrderType`](Self::UnsupportedOrderType) and
487    ///   [`InvalidPrecision`](Self::InvalidPrecision)
488    pub fn is_transient(&self) -> bool {
489        match self {
490            Self::Connectivity(e) => e.is_transient(),
491            Self::Rejected(ApiError::RateLimit) => true,
492            Self::Rejected(_) => false,
493            Self::UnsupportedOrderType(_) | Self::InvalidPrecision(_) => false,
494        }
495    }
496}
497
498/// Why an account event stream ended.
499///
500/// Delivered in-band as the payload of
501/// [`AccountEventKind::StreamTerminated`](crate::AccountEventKind::StreamTerminated) so that
502/// stream death is a programmatic signal on the account feed rather than a log-only event. A
503/// consumer can react (re-sync via REST, re-establish the stream, halt trading) according to its
504/// own policy — the library reports *why* the stream ended without prescribing the response.
505///
506/// # Only in-band-deliverable terminations are represented
507///
508/// Every variant here is a termination the library can actually deliver to a consumer that is
509/// still listening. A consumer-initiated drop is deliberately **not** a variant: by the time a
510/// stream task observes the drop, the receiving half of the channel is already gone, so a
511/// `StreamTerminated` send would be a guaranteed no-op. `StreamTerminated` therefore always means
512/// "the stream died while you were still listening".
513///
514/// `#[non_exhaustive]` so venues may distinguish further termination causes in future without a
515/// breaking change (e.g. a venue able to signal a graceful shutdown over a still-open channel).
516#[non_exhaustive]
517#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
518pub enum StreamTerminationReason {
519    /// A venue with built-in reconnection exhausted its retry budget and gave up.
520    ///
521    /// Emitted by venues that reconnect internally (e.g. Binance spot/margin, Alpaca) once the
522    /// final attempt fails. `last_error` is the most recent underlying failure; `attempts` is the
523    /// number of reconnects tried before surrendering. The stream will not recover on its own —
524    /// the consumer must re-establish it.
525    #[error("reconnect budget exhausted after {attempts} attempts: {last_error}")]
526    ReconnectBudgetExhausted {
527        /// Number of reconnect attempts made before giving up.
528        attempts: u32,
529        /// The most recent underlying error that triggered surrender.
530        last_error: String,
531    },
532
533    /// The stream ended on an unrecoverable error with no automatic recovery.
534    ///
535    /// Emitted by venues without library-managed reconnection — e.g. IBKR (the underlying
536    /// subscription errored and the client does not retry), Hyperliquid (the SDK's internal
537    /// reconnection gave up and closed the channel), or the mock exchange (its broadcast stream
538    /// lagged past the buffer). The consumer is responsible for any re-establishment.
539    #[error("unrecoverable stream error: {0}")]
540    Error(String),
541}
542
543/// Type alias for an [`AccountStreamInitError`] that is keyed on [`AssetNameExchange`] and
544/// [`InstrumentNameExchange`] (yet to be indexed).
545pub type UnindexedAccountStreamInitError =
546    AccountStreamInitError<AssetNameExchange, InstrumentNameExchange>;
547
548/// Why an attempt to initialise an account stream, its snapshot and its updates, failed.
549#[non_exhaustive]
550#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
551pub enum AccountStreamInitError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
552    /// The [`ExecutionClient`](crate::client::ExecutionClient) failed to fetch the account
553    /// snapshot or to open the account stream. See [`ClientError::is_transient`].
554    #[error("{0}")]
555    Client(#[from] ClientError<AssetKey, InstrumentKey>),
556
557    /// The account snapshot named an exchange, asset or instrument that is not indexed. This does
558    /// not resolve on retry while the venue keeps reporting it.
559    #[error("IndexError: {0}")]
560    Index(#[from] IndexError),
561}
562
563/// A failed attempt to re-initialise an account stream after it ended.
564///
565/// Delivered in-band as the payload of
566/// [`AccountEventKind::ReinitFailed`](crate::AccountEventKind::ReinitFailed), once per failed
567/// attempt. Attempts continue with backoff; giving up is the consumer's decision.
568#[non_exhaustive]
569#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
570pub struct AccountReinitFailure<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
571    /// Consecutive failed attempts since the stream last initialised, starting at 1.
572    pub attempt: u32,
573    /// Why this attempt failed.
574    pub error: AccountStreamInitError<AssetKey, InstrumentKey>,
575}
576
577/// Represents errors related to exchange, asset and instrument identifier key lookups.
578#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
579pub enum KeyError {
580    /// Indicates an [`ExchangeId`] was encountered that was not indexed, so does not have a
581    /// corresponding `ExchangeIndex`.
582    #[error("ExchangeId: {0}")]
583    ExchangeId(String),
584
585    /// Indicates an [`AssetNameExchange`] was encountered that was not indexed, so does not have a
586    /// corresponding [`AssetIndex`].
587    #[error("AssetKey: {0}")]
588    AssetKey(String),
589
590    /// Indicates an [`InstrumentNameExchange`] was encountered that was no indexed, so does
591    /// not have a corresponding [`InstrumentIndex`].
592    #[error("InstrumentKey: {0}")]
593    InstrumentKey(String),
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_connectivity_error_is_transient() {
602        assert!(ConnectivityError::Timeout.is_transient());
603        assert!(ConnectivityError::Socket("connection refused".into()).is_transient());
604        assert!(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot).is_transient());
605    }
606
607    #[test]
608    fn test_client_error_is_transient_connectivity() {
609        let err: ClientError = ClientError::Connectivity(ConnectivityError::Timeout);
610        assert!(err.is_transient());
611
612        let err: ClientError = ClientError::Connectivity(ConnectivityError::Socket("err".into()));
613        assert!(err.is_transient());
614    }
615
616    #[test]
617    fn test_client_error_is_transient_rate_limit() {
618        let err: ClientError = ClientError::Api(ApiError::RateLimit);
619        assert!(err.is_transient());
620    }
621
622    #[test]
623    fn test_client_error_not_transient_api_errors() {
624        let err: ClientError =
625            ClientError::Api(ApiError::AssetInvalid(AssetIndex(0), "bad".into()));
626        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
627
628        let err: ClientError = ClientError::Api(ApiError::BalanceInsufficient(
629            Some(AssetIndex(0)),
630            "low".into(),
631        ));
632        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
633
634        let err: ClientError = ClientError::Api(ApiError::InstrumentInvalid(
635            InstrumentIndex(0),
636            "bad".into(),
637        ));
638        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
639
640        let err: ClientError = ClientError::Api(ApiError::OrderRejected("rejected".into()));
641        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
642
643        let err: ClientError = ClientError::Api(ApiError::OrderAlreadyCancelled);
644        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
645
646        let err: ClientError = ClientError::Api(ApiError::OrderAlreadyFullyFilled);
647        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
648
649        let err: ClientError =
650            ClientError::Api(ApiError::Unauthenticated("invalid signature".into()));
651        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
652
653        let err: ClientError =
654            ClientError::Api(ApiError::RequestRejected("-1127 More than 24 hours".into()));
655        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
656    }
657
658    #[test]
659    fn balance_insufficient_names_its_asset_only_when_known() {
660        let named: UnindexedApiError =
661            ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_string());
662        assert_eq!(named.to_string(), "balance insufficient for asset BTC: low");
663
664        let unnamed: UnindexedApiError = ApiError::BalanceInsufficient(None, "low".to_string());
665        assert_eq!(unnamed.to_string(), "balance insufficient: low");
666    }
667
668    #[test]
669    fn test_client_error_not_transient_task_failed() {
670        let err: ClientError = ClientError::TaskFailed("task panicked".into());
671        assert!(!err.is_transient());
672    }
673
674    #[test]
675    fn test_client_error_not_transient_internal() {
676        let err: ClientError = ClientError::Internal("unknown error".into());
677        assert!(!err.is_transient());
678    }
679
680    #[test]
681    fn test_client_error_not_transient_truncated() {
682        let err: ClientError = ClientError::Truncated { fills_read: 5_000 };
683        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
684
685        let err: ClientError = ClientError::TruncatedSnapshot { limit: 500 };
686        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
687    }
688
689    #[test]
690    fn test_client_error_is_transient_exchange_offline() {
691        let err: ClientError =
692            ClientError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
693        assert!(err.is_transient(), "expected transient for {:?}", err);
694    }
695
696    #[test]
697    fn test_order_error_is_transient_connectivity() {
698        let err: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
699        assert!(err.is_transient(), "expected transient for {:?}", err);
700
701        let err: UnindexedOrderError =
702            OrderError::Connectivity(ConnectivityError::Socket("connection reset".into()));
703        assert!(err.is_transient(), "expected transient for {:?}", err);
704
705        let err: UnindexedOrderError =
706            OrderError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
707        assert!(err.is_transient(), "expected transient for {:?}", err);
708    }
709
710    #[test]
711    fn test_order_error_is_transient_rate_limit() {
712        let err: UnindexedOrderError = OrderError::Rejected(ApiError::RateLimit);
713        assert!(err.is_transient(), "expected transient for {:?}", err);
714    }
715
716    #[test]
717    fn test_order_error_not_transient_api_errors() {
718        let err: UnindexedOrderError =
719            OrderError::Rejected(ApiError::OrderRejected("price out of range".into()));
720        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
721
722        let err: UnindexedOrderError = OrderError::Rejected(ApiError::OrderAlreadyCancelled);
723        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
724
725        let err: UnindexedOrderError = OrderError::Rejected(ApiError::BalanceInsufficient(
726            Some(AssetNameExchange::from("BTC")),
727            "insufficient".into(),
728        ));
729        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
730    }
731}