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
271/// Represents all errors that can be generated when cancelling or opening orders.
272///
273/// This is a subset of [`ClientError`] for order-specific operations. Use
274/// [`is_transient()`](Self::is_transient) to determine retry eligibility.
275#[non_exhaustive]
276#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
277pub enum OrderError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
278 /// Connectivity-based error (timeout, socket failure, exchange offline).
279 ///
280 /// Transient — retry with backoff. See [`ConnectivityError`] for details.
281 #[error("connectivity: {0}")]
282 Connectivity(#[from] ConnectivityError),
283
284 /// API-based error (rate limit, invalid instrument, order rejected, etc.).
285 ///
286 /// Retry semantics depend on the specific [`ApiError`] variant. Only
287 /// [`ApiError::RateLimit`] is transient; other variants are not.
288 #[error("order rejected: {0}")]
289 Rejected(#[from] ApiError<AssetKey, InstrumentKey>),
290
291 /// The order type is not supported by this connector.
292 ///
293 /// Non-transient — the connector does not support this order type (e.g.,
294 /// trailing stop orders on a connector that only supports market/limit).
295 #[error("unsupported order type: {0}")]
296 UnsupportedOrderType(String),
297}
298
299impl<AssetKey, InstrumentKey> OrderError<AssetKey, InstrumentKey> {
300 /// Returns `true` if this error is likely transient and the operation
301 /// may succeed if retried after a suitable backoff.
302 ///
303 /// # Transient errors
304 /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
305 /// - [`Rejected(ApiError::RateLimit)`](ApiError::RateLimit)
306 ///
307 /// # Non-transient errors
308 /// - Other [`Rejected`](Self::Rejected) errors (invalid instrument, insufficient balance, etc.)
309 pub fn is_transient(&self) -> bool {
310 match self {
311 Self::Connectivity(e) => e.is_transient(),
312 Self::Rejected(ApiError::RateLimit) => true,
313 Self::Rejected(_) => false,
314 Self::UnsupportedOrderType(_) => false,
315 }
316 }
317}
318
319/// Why an account event stream ended.
320///
321/// Delivered in-band as the payload of
322/// [`AccountEventKind::StreamTerminated`](crate::AccountEventKind::StreamTerminated) so that
323/// stream death is a programmatic signal on the account feed rather than a log-only event. A
324/// consumer can react (re-sync via REST, re-establish the stream, halt trading) according to its
325/// own policy — the library reports *why* the stream ended without prescribing the response.
326///
327/// # Only in-band-deliverable terminations are represented
328///
329/// Every variant here is a termination the library can actually deliver to a consumer that is
330/// still listening. A consumer-initiated drop is deliberately **not** a variant: by the time a
331/// stream task observes the drop, the receiving half of the channel is already gone, so a
332/// `StreamTerminated` send would be a guaranteed no-op. `StreamTerminated` therefore always means
333/// "the stream died while you were still listening".
334///
335/// `#[non_exhaustive]` so venues may distinguish further termination causes in future without a
336/// breaking change (e.g. a venue able to signal a graceful shutdown over a still-open channel).
337#[non_exhaustive]
338#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
339pub enum StreamTerminationReason {
340 /// A venue with built-in reconnection exhausted its retry budget and gave up.
341 ///
342 /// Emitted by venues that reconnect internally (e.g. Binance spot/margin, Alpaca) once the
343 /// final attempt fails. `last_error` is the most recent underlying failure; `attempts` is the
344 /// number of reconnects tried before surrendering. The stream will not recover on its own —
345 /// the consumer must re-establish it.
346 #[error("reconnect budget exhausted after {attempts} attempts: {last_error}")]
347 ReconnectBudgetExhausted {
348 /// Number of reconnect attempts made before giving up.
349 attempts: u32,
350 /// The most recent underlying error that triggered surrender.
351 last_error: String,
352 },
353
354 /// The stream ended on an unrecoverable error with no automatic recovery.
355 ///
356 /// Emitted by venues without library-managed reconnection — e.g. IBKR (the underlying
357 /// subscription errored and the client does not retry), Hyperliquid (the SDK's internal
358 /// reconnection gave up and closed the channel), or the mock exchange (its broadcast stream
359 /// lagged past the buffer). The consumer is responsible for any re-establishment.
360 #[error("unrecoverable stream error: {0}")]
361 Error(String),
362}
363
364/// Represents errors related to exchange, asset and instrument identifier key lookups.
365#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
366pub enum KeyError {
367 /// Indicates an [`ExchangeId`] was encountered that was not indexed, so does not have a
368 /// corresponding `ExchangeIndex`.
369 #[error("ExchangeId: {0}")]
370 ExchangeId(String),
371
372 /// Indicates an [`AssetNameExchange`] was encountered that was not indexed, so does not have a
373 /// corresponding [`AssetIndex`].
374 #[error("AssetKey: {0}")]
375 AssetKey(String),
376
377 /// Indicates an [`InstrumentNameExchange`] was encountered that was no indexed, so does
378 /// not have a corresponding [`InstrumentIndex`].
379 #[error("InstrumentKey: {0}")]
380 InstrumentKey(String),
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn test_connectivity_error_is_transient() {
389 assert!(ConnectivityError::Timeout.is_transient());
390 assert!(ConnectivityError::Socket("connection refused".into()).is_transient());
391 assert!(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot).is_transient());
392 }
393
394 #[test]
395 fn test_client_error_is_transient_connectivity() {
396 let err: ClientError = ClientError::Connectivity(ConnectivityError::Timeout);
397 assert!(err.is_transient());
398
399 let err: ClientError = ClientError::Connectivity(ConnectivityError::Socket("err".into()));
400 assert!(err.is_transient());
401 }
402
403 #[test]
404 fn test_client_error_is_transient_rate_limit() {
405 let err: ClientError = ClientError::Api(ApiError::RateLimit);
406 assert!(err.is_transient());
407 }
408
409 #[test]
410 fn test_client_error_not_transient_api_errors() {
411 let err: ClientError =
412 ClientError::Api(ApiError::AssetInvalid(AssetIndex(0), "bad".into()));
413 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
414
415 let err: ClientError =
416 ClientError::Api(ApiError::BalanceInsufficient(AssetIndex(0), "low".into()));
417 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
418
419 let err: ClientError = ClientError::Api(ApiError::InstrumentInvalid(
420 InstrumentIndex(0),
421 "bad".into(),
422 ));
423 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
424
425 let err: ClientError = ClientError::Api(ApiError::OrderRejected("rejected".into()));
426 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
427
428 let err: ClientError = ClientError::Api(ApiError::OrderAlreadyCancelled);
429 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
430
431 let err: ClientError = ClientError::Api(ApiError::OrderAlreadyFullyFilled);
432 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
433
434 let err: ClientError =
435 ClientError::Api(ApiError::Unauthenticated("invalid signature".into()));
436 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
437 }
438
439 #[test]
440 fn test_client_error_not_transient_task_failed() {
441 let err: ClientError = ClientError::TaskFailed("task panicked".into());
442 assert!(!err.is_transient());
443 }
444
445 #[test]
446 fn test_client_error_not_transient_internal() {
447 let err: ClientError = ClientError::Internal("unknown error".into());
448 assert!(!err.is_transient());
449 }
450
451 #[test]
452 fn test_client_error_not_transient_truncated() {
453 let err: ClientError = ClientError::Truncated { limit: 100 };
454 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
455
456 let err: ClientError = ClientError::TruncatedSnapshot { limit: 500 };
457 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
458 }
459
460 #[test]
461 fn test_client_error_is_transient_exchange_offline() {
462 let err: ClientError =
463 ClientError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
464 assert!(err.is_transient(), "expected transient for {:?}", err);
465 }
466
467 #[test]
468 fn test_order_error_is_transient_connectivity() {
469 let err: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
470 assert!(err.is_transient(), "expected transient for {:?}", err);
471
472 let err: UnindexedOrderError =
473 OrderError::Connectivity(ConnectivityError::Socket("connection reset".into()));
474 assert!(err.is_transient(), "expected transient for {:?}", err);
475
476 let err: UnindexedOrderError =
477 OrderError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
478 assert!(err.is_transient(), "expected transient for {:?}", err);
479 }
480
481 #[test]
482 fn test_order_error_is_transient_rate_limit() {
483 let err: UnindexedOrderError = OrderError::Rejected(ApiError::RateLimit);
484 assert!(err.is_transient(), "expected transient for {:?}", err);
485 }
486
487 #[test]
488 fn test_order_error_not_transient_api_errors() {
489 let err: UnindexedOrderError =
490 OrderError::Rejected(ApiError::OrderRejected("price out of range".into()));
491 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
492
493 let err: UnindexedOrderError = OrderError::Rejected(ApiError::OrderAlreadyCancelled);
494 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
495
496 let err: UnindexedOrderError = OrderError::Rejected(ApiError::BalanceInsufficient(
497 AssetNameExchange::from("BTC"),
498 "insufficient".into(),
499 ));
500 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
501 }
502}