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}