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 rustrade_instrument::{
15    asset::{AssetIndex, name::AssetNameExchange},
16    exchange::ExchangeId,
17    instrument::{InstrumentIndex, name::InstrumentNameExchange},
18};
19use rustrade_integration::error::SocketError;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23/// Type alias for a [`ClientError`] that is keyed on [`AssetNameExchange`] and
24/// [`InstrumentNameExchange`] (yet to be indexed).
25pub type UnindexedClientError = ClientError<AssetNameExchange, InstrumentNameExchange>;
26
27/// Type alias for a [`ApiError`] that is keyed on [`AssetNameExchange`] and
28/// [`InstrumentNameExchange`] (yet to be indexed).
29pub type UnindexedApiError = ApiError<AssetNameExchange, InstrumentNameExchange>;
30
31/// Type alias for a [`OrderError`] that is keyed on [`AssetNameExchange`] and
32/// [`InstrumentNameExchange`] (yet to be indexed).
33pub type UnindexedOrderError = OrderError<AssetNameExchange, InstrumentNameExchange>;
34
35/// Represents all errors produced by an [`ExecutionClient`](super::client::ExecutionClient).
36#[non_exhaustive]
37#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
38pub enum ClientError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
39    /// Connectivity based error.
40    ///
41    /// eg/ Timeout.
42    #[error("Connectivity: {0}")]
43    Connectivity(#[from] ConnectivityError),
44
45    /// API based error.
46    ///
47    /// eg/ RateLimit.
48    #[error("API: {0}")]
49    Api(#[from] ApiError<AssetKey, InstrumentKey>),
50
51    /// A background task panicked or was cancelled during an operation.
52    ///
53    /// This indicates a bug or unexpected runtime condition (e.g., a tokio
54    /// `spawn_blocking` task panicked). The operation was not retried and
55    /// the caller should treat this as non-recoverable, requiring operator
56    /// attention.
57    #[error("task failed: {0}")]
58    TaskFailed(String),
59
60    /// An opaque error from an upstream library that cannot be further classified.
61    ///
62    /// This is a catch-all for errors that don't fit into [`Self::Connectivity`] or
63    /// [`Self::Api`] categories — typically because the upstream library (e.g., ibapi,
64    /// binance-sdk) returns unstructured errors.
65    ///
66    /// Conservatively treated as non-transient. If you encounter this error
67    /// frequently, consider filing an issue to improve error classification.
68    #[error("internal error: {0}")]
69    Internal(String),
70
71    /// Activity pagination was truncated at the page limit.
72    ///
73    /// The returned data from the underlying call is a partial result. This error
74    /// indicates that more activities exist beyond the safety limit, typically due
75    /// to a very long outage (>5000 fills). Callers should alert operators and
76    /// consider manual reconciliation.
77    #[error("activity pagination truncated at {limit} pages — data may be incomplete")]
78    Truncated {
79        /// Maximum number of pages that were fetched before truncation.
80        limit: usize,
81    },
82
83    /// Open orders snapshot was truncated at the API's row limit.
84    ///
85    /// Unlike [`Self::Truncated`] (which applies to paginated activity fetches), this
86    /// error indicates a single-request endpoint hit its maximum row count.
87    /// Alpaca's `/v2/orders` endpoint caps results at 500; accounts with more
88    /// concurrent open orders will have an incomplete snapshot.
89    ///
90    /// Callers should alert operators — an incomplete order snapshot can cause
91    /// duplicate submissions, missed cancellations, or incorrect position sizing.
92    #[error("open orders snapshot truncated at {limit} results — data may be incomplete")]
93    TruncatedSnapshot {
94        /// Maximum number of rows returned by the single-request endpoint.
95        limit: usize,
96    },
97}
98
99impl<AssetKey, InstrumentKey> ClientError<AssetKey, InstrumentKey> {
100    /// Returns `true` if this error is likely transient and the operation
101    /// may succeed if retried after a suitable backoff.
102    ///
103    /// The caller is responsible for retry limits and backoff strategy.
104    /// This method classifies the error only — it does not implement policy.
105    ///
106    /// # Transient errors
107    /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
108    /// - [`Api::RateLimit`](ApiError::RateLimit)
109    ///
110    /// # Non-transient errors
111    /// - Other [`Api`](Self::Api) errors (invalid instrument, insufficient balance, etc.)
112    /// - [`TaskFailed`](Self::TaskFailed) (indicates a bug)
113    /// - [`Internal`](Self::Internal) (unknown — conservatively non-transient)
114    /// - [`Truncated`](Self::Truncated) / [`TruncatedSnapshot`](Self::TruncatedSnapshot)
115    pub fn is_transient(&self) -> bool {
116        match self {
117            Self::Connectivity(e) => e.is_transient(),
118            Self::Api(ApiError::RateLimit) => true,
119            Self::Api(_) => false,
120            Self::TaskFailed(_) => false,
121            Self::Internal(_) => false,
122            Self::Truncated { .. } => false,
123            Self::TruncatedSnapshot { .. } => false,
124        }
125    }
126}
127
128/// Represents all connectivity-centric errors.
129///
130/// Connectivity errors are generally intermittent / non-deterministic (eg/ Timeout).
131/// All variants are transient — retry with exponential backoff (typically 1-30s).
132#[non_exhaustive]
133#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
134pub enum ConnectivityError {
135    /// Exchange is offline, likely due to scheduled maintenance.
136    ///
137    /// Transient — retry with backoff. Maintenance windows typically last minutes
138    /// to hours; consider longer backoff intervals (30s-5min) to avoid log spam.
139    #[error("Exchange offline: {0}")]
140    ExchangeOffline(ExchangeId),
141
142    /// Request timed out before a response was received.
143    ///
144    /// Transient — retry with backoff. May indicate network congestion, server
145    /// overload, or an overly aggressive timeout. Consider increasing timeout
146    /// on subsequent attempts.
147    #[error("ExecutionRequest timed out")]
148    Timeout,
149
150    /// Network-level socket error (connection refused, reset, DNS failure, etc.).
151    ///
152    /// Transient — retry with backoff. If persistent, may indicate firewall
153    /// issues, incorrect endpoint configuration, or prolonged server outage.
154    #[error("{0}")]
155    Socket(String),
156}
157
158impl From<SocketError> for ConnectivityError {
159    fn from(value: SocketError) -> Self {
160        Self::Socket(value.to_string())
161    }
162}
163
164impl ConnectivityError {
165    /// Returns `true` if this connectivity error is transient.
166    ///
167    /// All connectivity errors are considered transient — they represent
168    /// temporary network or server conditions that may resolve with retry.
169    pub fn is_transient(&self) -> bool {
170        match self {
171            Self::ExchangeOffline(_) => true,
172            Self::Timeout => true,
173            Self::Socket(_) => true,
174        }
175    }
176}
177
178/// Represents all API errors generated by an exchange.
179///
180/// These typically indicate a request is invalid for some reason (eg/ BalanceInsufficient).
181/// Most variants are **not transient** — the same request will fail identically on retry.
182/// The exception is [`RateLimit`](Self::RateLimit), which is transient.
183#[non_exhaustive]
184#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
185pub enum ApiError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
186    /// Provided asset identifier is invalid or not supported.
187    ///
188    /// For example:
189    /// - The [`AssetNameExchange`] was an invalid format.
190    ///
191    /// Not transient — do not retry. The asset identifier must be corrected.
192    #[error("asset {0} invalid: {1}")]
193    AssetInvalid(AssetKey, String),
194
195    /// Provided instrument identifier is invalid or not supported.
196    ///
197    /// For example:
198    /// - The exchange does not have a market for an instrument.
199    /// - The [`InstrumentNameExchange`] was an invalid format.
200    ///
201    /// Not transient — do not retry. The instrument identifier must be corrected.
202    #[error("instrument {0} invalid: {1}")]
203    InstrumentInvalid(InstrumentKey, String),
204
205    /// Request was rejected due to rate limiting.
206    ///
207    /// The exchange enforces request quotas and the caller has exceeded them.
208    /// Some exchanges provide a `Retry-After` header or similar hint; the client
209    /// may incorporate this into internal retry logic before surfacing this error.
210    ///
211    /// Transient — retry with backoff. Typical backoff is 10-60 seconds, but
212    /// respect exchange-specific guidance if available.
213    #[error("rate limit exceeded")]
214    RateLimit,
215
216    /// Authentication failed (invalid credentials, expired key, bad signature).
217    ///
218    /// Unlike other API errors which affect a single request, authentication
219    /// failures indicate that **all** subsequent requests will fail until
220    /// credentials are corrected. Callers should halt trading and alert operators.
221    ///
222    /// Not transient — do not retry. Fix credentials and restart.
223    #[error("authentication failed: {0}")]
224    Unauthenticated(String),
225
226    /// Balance of an asset is insufficient to execute the requested operation.
227    ///
228    /// # Warning: `AssetKey` field may hold an instrument name, not an asset name
229    ///
230    /// Some `ExecutionClient` implementations (e.g. `BinanceSpot`) populate the
231    /// `AssetKey` field with the **instrument name** (e.g. `"BTCUSDT"`) rather than
232    /// the specific low-balance asset (e.g. `"BTC"` or `"USDT"`), because splitting
233    /// a symbol into base/quote requires exchange symbol-info metadata not available
234    /// at error-parse time. Do **not** pattern-match on the `AssetKey` value to
235    /// identify the specific low-balance asset — use the `String` field for
236    /// diagnostics only.
237    ///
238    /// Not transient — do not retry the same request. Reduce order size or
239    /// deposit additional funds.
240    #[error("asset {0} balance insufficient: {1}")]
241    BalanceInsufficient(AssetKey, String),
242
243    /// Order was rejected by the exchange for a business rule violation.
244    ///
245    /// Common causes include: price outside allowed range, quantity below
246    /// minimum, post-only order would cross, reduce-only with no position.
247    ///
248    /// Not transient — do not retry the same request. Adjust order parameters.
249    #[error("order rejected: {0}")]
250    OrderRejected(String),
251
252    /// Cancel request failed because the order was already cancelled.
253    ///
254    /// This is a state conflict, not an error per se — the desired end state
255    /// (order cancelled) has already been achieved.
256    ///
257    /// Not transient — do not retry. The order is already in the cancelled state.
258    #[error("order already cancelled")]
259    OrderAlreadyCancelled,
260
261    /// Cancel request failed because the order was already fully filled.
262    ///
263    /// This is a state conflict — the order completed before the cancel arrived.
264    /// The caller should reconcile their local state with the fill.
265    ///
266    /// Not transient — do not retry. The order no longer exists to cancel.
267    #[error("order already fully filled")]
268    OrderAlreadyFullyFilled,
269
270    /// Cancel request failed because the order had already expired.
271    ///
272    /// This is a state conflict — the order's own deadline retired it before the cancel arrived,
273    /// so the desired end state (order no longer working) has already been achieved. Distinct from
274    /// [`OrderAlreadyCancelled`](Self::OrderAlreadyCancelled) because nobody asked for it: a
275    /// time-in-force such as [`GoodTillDate`](crate::order::TimeInForce::GoodTillDate) retired it
276    /// unprompted, and a caller reconciling its local state needs to know which of the two happened.
277    ///
278    /// Not transient — do not retry. The order no longer exists to cancel.
279    #[error("order already expired")]
280    OrderAlreadyExpired,
281
282    /// The exchange refused the request itself, rather than an order it carried.
283    ///
284    /// The request was malformed or asked for something the venue does not serve — a missing,
285    /// malformed or out-of-range parameter, such as a query time window longer than the venue
286    /// allows. Distinct from [`OrderRejected`](Self::OrderRejected), which is an order failing a
287    /// business rule; this variant arises from queries and other non-order requests. The message
288    /// carries the venue's own error code where it provides one (e.g. `-1127 ...` on Binance).
289    ///
290    /// Not transient — the same request fails identically on retry. Change the request.
291    #[error("request rejected: {0}")]
292    RequestRejected(String),
293}
294
295/// Represents all errors that can be generated when cancelling or opening orders.
296///
297/// This is a subset of [`ClientError`] for order-specific operations. Use
298/// [`is_transient()`](Self::is_transient) to determine retry eligibility.
299#[non_exhaustive]
300#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
301pub enum OrderError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
302    /// Connectivity-based error (timeout, socket failure, exchange offline), or a venue failure
303    /// that leaves the order's status unknown.
304    ///
305    /// Transient — retry with backoff. See [`ConnectivityError`] for details. The order may or
306    /// may not have reached the matching engine, so before resubmitting an open request,
307    /// reconcile its state (open orders, fills); a blind retry can place it twice.
308    #[error("connectivity: {0}")]
309    Connectivity(#[from] ConnectivityError),
310
311    /// API-based error (rate limit, invalid instrument, order rejected, etc.).
312    ///
313    /// Retry semantics depend on the specific [`ApiError`] variant. Only
314    /// [`ApiError::RateLimit`] is transient; other variants are not.
315    #[error("order rejected: {0}")]
316    Rejected(#[from] ApiError<AssetKey, InstrumentKey>),
317
318    /// The order type is not supported by this connector.
319    ///
320    /// Non-transient — the connector does not support this order type (e.g.,
321    /// trailing stop orders on a connector that only supports market/limit).
322    #[error("unsupported order type: {0}")]
323    UnsupportedOrderType(String),
324}
325
326impl<AssetKey, InstrumentKey> OrderError<AssetKey, InstrumentKey> {
327    /// Returns `true` if this error is likely transient and the operation
328    /// may succeed if retried after a suitable backoff.
329    ///
330    /// # Transient errors
331    /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
332    /// - [`Rejected(ApiError::RateLimit)`](ApiError::RateLimit)
333    ///
334    /// # Non-transient errors
335    /// - Other [`Rejected`](Self::Rejected) errors (invalid instrument, insufficient balance, etc.)
336    pub fn is_transient(&self) -> bool {
337        match self {
338            Self::Connectivity(e) => e.is_transient(),
339            Self::Rejected(ApiError::RateLimit) => true,
340            Self::Rejected(_) => false,
341            Self::UnsupportedOrderType(_) => false,
342        }
343    }
344}
345
346/// Why an account event stream ended.
347///
348/// Delivered in-band as the payload of
349/// [`AccountEventKind::StreamTerminated`](crate::AccountEventKind::StreamTerminated) so that
350/// stream death is a programmatic signal on the account feed rather than a log-only event. A
351/// consumer can react (re-sync via REST, re-establish the stream, halt trading) according to its
352/// own policy — the library reports *why* the stream ended without prescribing the response.
353///
354/// # Only in-band-deliverable terminations are represented
355///
356/// Every variant here is a termination the library can actually deliver to a consumer that is
357/// still listening. A consumer-initiated drop is deliberately **not** a variant: by the time a
358/// stream task observes the drop, the receiving half of the channel is already gone, so a
359/// `StreamTerminated` send would be a guaranteed no-op. `StreamTerminated` therefore always means
360/// "the stream died while you were still listening".
361///
362/// `#[non_exhaustive]` so venues may distinguish further termination causes in future without a
363/// breaking change (e.g. a venue able to signal a graceful shutdown over a still-open channel).
364#[non_exhaustive]
365#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
366pub enum StreamTerminationReason {
367    /// A venue with built-in reconnection exhausted its retry budget and gave up.
368    ///
369    /// Emitted by venues that reconnect internally (e.g. Binance spot/margin, Alpaca) once the
370    /// final attempt fails. `last_error` is the most recent underlying failure; `attempts` is the
371    /// number of reconnects tried before surrendering. The stream will not recover on its own —
372    /// the consumer must re-establish it.
373    #[error("reconnect budget exhausted after {attempts} attempts: {last_error}")]
374    ReconnectBudgetExhausted {
375        /// Number of reconnect attempts made before giving up.
376        attempts: u32,
377        /// The most recent underlying error that triggered surrender.
378        last_error: String,
379    },
380
381    /// The stream ended on an unrecoverable error with no automatic recovery.
382    ///
383    /// Emitted by venues without library-managed reconnection — e.g. IBKR (the underlying
384    /// subscription errored and the client does not retry), Hyperliquid (the SDK's internal
385    /// reconnection gave up and closed the channel), or the mock exchange (its broadcast stream
386    /// lagged past the buffer). The consumer is responsible for any re-establishment.
387    #[error("unrecoverable stream error: {0}")]
388    Error(String),
389}
390
391/// Represents errors related to exchange, asset and instrument identifier key lookups.
392#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
393pub enum KeyError {
394    /// Indicates an [`ExchangeId`] was encountered that was not indexed, so does not have a
395    /// corresponding `ExchangeIndex`.
396    #[error("ExchangeId: {0}")]
397    ExchangeId(String),
398
399    /// Indicates an [`AssetNameExchange`] was encountered that was not indexed, so does not have a
400    /// corresponding [`AssetIndex`].
401    #[error("AssetKey: {0}")]
402    AssetKey(String),
403
404    /// Indicates an [`InstrumentNameExchange`] was encountered that was no indexed, so does
405    /// not have a corresponding [`InstrumentIndex`].
406    #[error("InstrumentKey: {0}")]
407    InstrumentKey(String),
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_connectivity_error_is_transient() {
416        assert!(ConnectivityError::Timeout.is_transient());
417        assert!(ConnectivityError::Socket("connection refused".into()).is_transient());
418        assert!(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot).is_transient());
419    }
420
421    #[test]
422    fn test_client_error_is_transient_connectivity() {
423        let err: ClientError = ClientError::Connectivity(ConnectivityError::Timeout);
424        assert!(err.is_transient());
425
426        let err: ClientError = ClientError::Connectivity(ConnectivityError::Socket("err".into()));
427        assert!(err.is_transient());
428    }
429
430    #[test]
431    fn test_client_error_is_transient_rate_limit() {
432        let err: ClientError = ClientError::Api(ApiError::RateLimit);
433        assert!(err.is_transient());
434    }
435
436    #[test]
437    fn test_client_error_not_transient_api_errors() {
438        let err: ClientError =
439            ClientError::Api(ApiError::AssetInvalid(AssetIndex(0), "bad".into()));
440        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
441
442        let err: ClientError =
443            ClientError::Api(ApiError::BalanceInsufficient(AssetIndex(0), "low".into()));
444        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
445
446        let err: ClientError = ClientError::Api(ApiError::InstrumentInvalid(
447            InstrumentIndex(0),
448            "bad".into(),
449        ));
450        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
451
452        let err: ClientError = ClientError::Api(ApiError::OrderRejected("rejected".into()));
453        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
454
455        let err: ClientError = ClientError::Api(ApiError::OrderAlreadyCancelled);
456        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
457
458        let err: ClientError = ClientError::Api(ApiError::OrderAlreadyFullyFilled);
459        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
460
461        let err: ClientError =
462            ClientError::Api(ApiError::Unauthenticated("invalid signature".into()));
463        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
464
465        let err: ClientError =
466            ClientError::Api(ApiError::RequestRejected("-1127 More than 24 hours".into()));
467        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
468    }
469
470    #[test]
471    fn test_client_error_not_transient_task_failed() {
472        let err: ClientError = ClientError::TaskFailed("task panicked".into());
473        assert!(!err.is_transient());
474    }
475
476    #[test]
477    fn test_client_error_not_transient_internal() {
478        let err: ClientError = ClientError::Internal("unknown error".into());
479        assert!(!err.is_transient());
480    }
481
482    #[test]
483    fn test_client_error_not_transient_truncated() {
484        let err: ClientError = ClientError::Truncated { limit: 100 };
485        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
486
487        let err: ClientError = ClientError::TruncatedSnapshot { limit: 500 };
488        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
489    }
490
491    #[test]
492    fn test_client_error_is_transient_exchange_offline() {
493        let err: ClientError =
494            ClientError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
495        assert!(err.is_transient(), "expected transient for {:?}", err);
496    }
497
498    #[test]
499    fn test_order_error_is_transient_connectivity() {
500        let err: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
501        assert!(err.is_transient(), "expected transient for {:?}", err);
502
503        let err: UnindexedOrderError =
504            OrderError::Connectivity(ConnectivityError::Socket("connection reset".into()));
505        assert!(err.is_transient(), "expected transient for {:?}", err);
506
507        let err: UnindexedOrderError =
508            OrderError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
509        assert!(err.is_transient(), "expected transient for {:?}", err);
510    }
511
512    #[test]
513    fn test_order_error_is_transient_rate_limit() {
514        let err: UnindexedOrderError = OrderError::Rejected(ApiError::RateLimit);
515        assert!(err.is_transient(), "expected transient for {:?}", err);
516    }
517
518    #[test]
519    fn test_order_error_not_transient_api_errors() {
520        let err: UnindexedOrderError =
521            OrderError::Rejected(ApiError::OrderRejected("price out of range".into()));
522        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
523
524        let err: UnindexedOrderError = OrderError::Rejected(ApiError::OrderAlreadyCancelled);
525        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
526
527        let err: UnindexedOrderError = OrderError::Rejected(ApiError::BalanceInsufficient(
528            AssetNameExchange::from("BTC"),
529            "insufficient".into(),
530        ));
531        assert!(!err.is_transient(), "expected non-transient for {:?}", err);
532    }
533}