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, time::Duration};
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), though not before its `retry_after`
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 /// The venue's minimum wait before a retry: `Some` only for an
134 /// [`Api::RateLimit`](ApiError::RateLimit) whose venue sent a hint the client could read. See
135 /// [`ApiError::retry_after`].
136 pub fn retry_after(&self) -> Option<Duration> {
137 match self {
138 Self::Api(error) => error.retry_after(),
139 _ => None,
140 }
141 }
142}
143
144/// Represents all connectivity-centric errors.
145///
146/// Connectivity errors are generally intermittent / non-deterministic (eg/ Timeout).
147/// All variants are transient — retry with exponential backoff (typically 1-30s).
148#[non_exhaustive]
149#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
150pub enum ConnectivityError {
151 /// Exchange is offline, likely due to scheduled maintenance.
152 ///
153 /// Transient — retry with backoff. Maintenance windows typically last minutes
154 /// to hours; consider longer backoff intervals (30s-5min) to avoid log spam.
155 #[error("Exchange offline: {0}")]
156 ExchangeOffline(ExchangeId),
157
158 /// Request timed out before a response was received.
159 ///
160 /// Transient — retry with backoff. May indicate network congestion, server
161 /// overload, or an overly aggressive timeout. Consider increasing timeout
162 /// on subsequent attempts.
163 #[error("request timed out")]
164 Timeout,
165
166 /// Network-level socket error (connection refused, reset, DNS failure, etc.).
167 ///
168 /// Transient — retry with backoff. If persistent, may indicate firewall
169 /// issues, incorrect endpoint configuration, or prolonged server outage.
170 #[error("{0}")]
171 Socket(String),
172}
173
174impl From<SocketError> for ConnectivityError {
175 fn from(value: SocketError) -> Self {
176 Self::Socket(value.to_string())
177 }
178}
179
180impl ConnectivityError {
181 /// Returns `true` if this connectivity error is transient.
182 ///
183 /// All connectivity errors are considered transient — they represent
184 /// temporary network or server conditions that may resolve with retry.
185 pub fn is_transient(&self) -> bool {
186 match self {
187 Self::ExchangeOffline(_) => true,
188 Self::Timeout => true,
189 Self::Socket(_) => true,
190 }
191 }
192}
193
194/// Represents all API errors generated by an exchange.
195///
196/// These typically indicate a request is invalid for some reason (eg/ BalanceInsufficient).
197/// Most variants are **not transient** — the same request will fail identically on retry.
198/// The exception is [`RateLimit`](Self::RateLimit), which is transient.
199#[non_exhaustive]
200#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
201pub enum ApiError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
202 /// Provided asset identifier is invalid or not supported.
203 ///
204 /// For example:
205 /// - The [`AssetNameExchange`] was an invalid format.
206 ///
207 /// Not transient — do not retry. The asset identifier must be corrected.
208 ///
209 /// Indexed only when the [`ExecutionInstrumentMap`] holds the asset. Otherwise there is no
210 /// index to carry, and [`AccountEventIndexer`] reports it as
211 /// [`OrderRejected`](Self::OrderRejected) (from an order request) or
212 /// [`RequestRejected`](Self::RequestRejected) (from any other request), holding this error's
213 /// own message.
214 ///
215 /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
216 /// [`AccountEventIndexer`]: crate::indexer::AccountEventIndexer
217 #[error("asset {0} invalid: {1}")]
218 AssetInvalid(AssetKey, String),
219
220 /// Provided instrument identifier is invalid or not supported.
221 ///
222 /// For example:
223 /// - The exchange does not have a market for an instrument.
224 /// - The [`InstrumentNameExchange`] was an invalid format.
225 ///
226 /// Not transient — do not retry. The instrument identifier must be corrected.
227 ///
228 /// Indexed only when the [`ExecutionInstrumentMap`] holds the instrument; otherwise reported
229 /// as [`AssetInvalid`](Self::AssetInvalid) describes.
230 ///
231 /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
232 #[error("instrument {0} invalid: {1}")]
233 InstrumentInvalid(InstrumentKey, String),
234
235 /// Request was rejected due to rate limiting.
236 ///
237 /// The exchange enforces request quotas and the caller has exceeded them. The client may
238 /// already have waited and retried within its own bounded retries before surfacing this.
239 ///
240 /// `retry_after` is the venue's own minimum wait, measured from when the client read the
241 /// refusal: do not retry sooner. `None` means the venue sent no hint, or the client cannot
242 /// read it, and is not permission to retry at once. Which clients fill it:
243 /// - **Alpaca:** from a 429's `Retry-After`, or else `X-RateLimit-Reset`, once the client's
244 /// own retries are exhausted.
245 /// - **Binance:** from a REST 429's or 418's `Retry-After`, or the time a WebSocket API
246 /// refusal's `retryAfter` names, measured against the local clock. Uncapped, since a ban can
247 /// last days, so add it to an `Instant` with `checked_add`. A request the client held back
248 /// unsent, because an earlier refusal's wait had longer left than the client sleeps
249 /// through, reports the time left.
250 /// - **Hyperliquid:** never: its SDK keeps no headers, and Hyperliquid documents no hint.
251 ///
252 /// A venue ban (Binance's HTTP 418) is reported the same way: its hint, when readable, says
253 /// how long it lasts.
254 ///
255 /// Transient — retry after `retry_after` when given, otherwise with backoff.
256 ///
257 /// The variant is `#[non_exhaustive]`: outside this crate, build one with
258 /// [`ApiError::rate_limited`], match it as `RateLimit { .. }`, and read the wait with
259 /// [`ApiError::retry_after`].
260 #[error("rate limit exceeded{}", RetryAfter(*retry_after))]
261 #[non_exhaustive]
262 RateLimit { retry_after: Option<Duration> },
263
264 /// The venue refused because a cap on something held at once is full, such as IBKR's market
265 /// data lines, which every TWS session and API client on the account shares.
266 ///
267 /// Unlike [`RateLimit`](Self::RateLimit), waiting does not clear it: releasing something
268 /// does, such as cancelling another subscription, whether this client holds it or another
269 /// session on the same account does. The message carries the venue's words.
270 ///
271 /// Not transient: the same request retried with backoff alone meets the same refusal while
272 /// the cap stays full.
273 #[error("venue capacity exceeded: {0}")]
274 CapacityExceeded(String),
275
276 /// Authentication failed (invalid credentials, expired key, bad signature).
277 ///
278 /// Unlike other API errors which affect a single request, authentication
279 /// failures indicate that **all** subsequent requests will fail until
280 /// credentials are corrected. Callers should halt trading and alert operators.
281 ///
282 /// Not transient — do not retry. Fix credentials and restart.
283 #[error("authentication failed: {0}")]
284 Unauthenticated(String),
285
286 /// Balance of an asset is insufficient to execute the requested operation.
287 ///
288 /// The asset is `None` when it is not known which asset ran short:
289 /// - the venue's rejection does not name it (e.g. Binance's "Account has insufficient
290 /// balance for requested action", or any Hyperliquid margin or spot-balance refusal), and
291 /// a client does not guess it from the order;
292 /// - the venue named an asset that the [`ExecutionInstrumentMap`] does not hold, so it has no
293 /// index. [`AccountEventIndexer::api_error`] logs the name it dropped.
294 ///
295 /// The `String` carries the venue's message.
296 ///
297 /// Not transient — do not retry the same request. Reduce order size or
298 /// deposit additional funds.
299 ///
300 /// [`ExecutionInstrumentMap`]: crate::map::ExecutionInstrumentMap
301 /// [`AccountEventIndexer::api_error`]: crate::indexer::AccountEventIndexer::api_error
302 #[error(
303 "balance insufficient{asset}: {1}",
304 asset = .0.as_ref().map(|asset| format!(" for asset {asset}")).unwrap_or_default()
305 )]
306 BalanceInsufficient(Option<AssetKey>, String),
307
308 /// Order was rejected by the exchange for a business rule violation.
309 ///
310 /// Common causes include: price outside allowed range, quantity below
311 /// minimum, post-only order would cross, reduce-only with no position.
312 ///
313 /// Not transient — do not retry the same request. Adjust order parameters.
314 #[error("order rejected: {0}")]
315 OrderRejected(String),
316
317 /// Cancel request failed because the order was already cancelled.
318 ///
319 /// This is a state conflict, not an error per se — the desired end state
320 /// (order cancelled) has already been achieved.
321 ///
322 /// Not transient — do not retry. The order is already in the cancelled state.
323 #[error("order already cancelled")]
324 OrderAlreadyCancelled,
325
326 /// Cancel request failed because the order was already fully filled.
327 ///
328 /// This is a state conflict — the order completed before the cancel arrived.
329 /// The caller should reconcile their local state with the fill.
330 ///
331 /// Not transient — do not retry. The order no longer exists to cancel.
332 #[error("order already fully filled")]
333 OrderAlreadyFullyFilled,
334
335 /// Cancel request failed because the order had already expired.
336 ///
337 /// This is a state conflict — the order's own deadline retired it before the cancel arrived,
338 /// so the desired end state (order no longer working) has already been achieved. Distinct from
339 /// [`OrderAlreadyCancelled`](Self::OrderAlreadyCancelled) because nobody asked for it: a
340 /// time-in-force such as [`GoodTillDate`](crate::order::TimeInForce::GoodTillDate) retired it
341 /// unprompted, and a caller reconciling its local state needs to know which of the two happened.
342 ///
343 /// Not transient — do not retry. The order no longer exists to cancel.
344 #[error("order already expired")]
345 OrderAlreadyExpired,
346
347 /// Open request refused because its [`ClientOrderId`] is already in use at the venue.
348 ///
349 /// The refusal concerns this request alone: the order already under the id is **unaffected**,
350 /// and is not reported ended by it. What "in use" means is the venue's rule. On the
351 /// [`SimulatedVenue`] and the IBKR client it is an order still working under the id, and an id
352 /// is free again once its order has ended. Binance documents an id as unique among open
353 /// orders. The `String` carries the venue's message, or the client's own where it refused the
354 /// request before sending it.
355 ///
356 /// Reported by the [`SimulatedVenue`], by the IBKR client, which refuses the request before it
357 /// reaches TWS, since TWS never sees a client order id, and by the Binance spot and margin
358 /// clients, from Binance's `"Duplicate order sent."`. Other clients report a venue's duplicate
359 /// rejection as [`OrderRejected`](Self::OrderRejected) until its message has been confirmed.
360 ///
361 /// Not transient — the same request fails identically while the order under the id works.
362 /// Use another id, or wait for that order to end.
363 ///
364 /// [`ClientOrderId`]: crate::order::id::ClientOrderId
365 /// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
366 #[error("client order id in use: {0}")]
367 DuplicateClientOrderId(String),
368
369 /// The exchange refused the request itself, rather than an order it carried.
370 ///
371 /// The request was malformed or asked for something the venue does not serve — a missing,
372 /// malformed or out-of-range parameter, such as a query time window longer than the venue
373 /// allows. Distinct from [`OrderRejected`](Self::OrderRejected), which is an order failing a
374 /// business rule; this variant arises from queries and other non-order requests. The message
375 /// carries the venue's own error code where it provides one (e.g. `-1127 ...` on Binance).
376 ///
377 /// Not transient — the same request fails identically on retry. Change the request.
378 #[error("request rejected: {0}")]
379 RequestRejected(String),
380
381 /// Order refused because the venue would not lend what it needed: a short sale, or a margin
382 /// order that borrows to cover itself.
383 ///
384 /// Distinct from [`OrderRejected`](Self::OrderRejected) so a caller can tell a refused borrow
385 /// apart from a malformed order, and from [`BalanceInsufficient`](Self::BalanceInsufficient),
386 /// which is the account's own funds running short. A borrow can be refused for a long too: on
387 /// Binance margin, an order that borrows to buy is refused when the venue has too little of
388 /// the asset to lend or the borrow would exceed the account's limit. [`BorrowReject`] says
389 /// why.
390 ///
391 /// Reported by the Binance margin client, by Binance's code, by the Alpaca client, by the
392 /// text of a 403 or 422, and by the
393 /// [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue), for a CFD short its
394 /// [`ShortabilityProvider`](crate::shortability::ShortabilityProvider) does not allow. Other
395 /// clients report such refusals as [`OrderRejected`](Self::OrderRejected) until their venue's
396 /// wording is confirmed.
397 ///
398 /// Not transient — the same request fails identically until the venue's lending changes.
399 #[error("borrow rejected: {0}")]
400 BorrowRejected(BorrowReject),
401}
402
403impl<AssetKey, InstrumentKey> ApiError<AssetKey, InstrumentKey> {
404 /// A [`RateLimit`](Self::RateLimit) refusal, with the venue's minimum wait when it sent one.
405 pub fn rate_limited(retry_after: Option<Duration>) -> Self {
406 Self::RateLimit { retry_after }
407 }
408
409 /// The venue's minimum wait before a retry: `Some` only for a
410 /// [`RateLimit`](Self::RateLimit) whose venue sent a hint the client could read.
411 pub fn retry_after(&self) -> Option<Duration> {
412 match self {
413 Self::RateLimit { retry_after } => *retry_after,
414 _ => None,
415 }
416 }
417}
418
419/// Formats a [`RateLimit`](ApiError::RateLimit)'s hint for its message: nothing when there is
420/// none.
421struct RetryAfter(Option<Duration>);
422
423impl fmt::Display for RetryAfter {
424 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425 match self.0 {
426 Some(retry_after) => write!(f, "; retry after {retry_after:?}"),
427 None => Ok(()),
428 }
429 }
430}
431
432/// Why a venue refused to lend for an order, as carried by [`ApiError::BorrowRejected`].
433#[non_exhaustive]
434#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
435pub struct BorrowReject {
436 /// The kind of refusal.
437 pub reason: BorrowRejectReason,
438 /// The venue's own code for the refusal, where it gave one (e.g. `-3045` on Binance,
439 /// `40310000` on Alpaca).
440 ///
441 /// For diagnostics only, not a stable contract: match on [`reason`](Self::reason) instead. A
442 /// venue may reuse a code for several refusals, as Alpaca's generic `40310000` is.
443 pub venue_code: Option<String>,
444 /// The venue's message. It may itself carry the code, as Binance's does.
445 pub message: String,
446 /// How much the venue could still have lent, in the units the order was sized in, where
447 /// known: the most a smaller order could have sold short. Set by the simulated venue; no live
448 /// client reports it yet.
449 pub available: Option<Decimal>,
450}
451
452impl BorrowReject {
453 /// A refusal for `reason`, with the venue's code where it gave one, and its message.
454 pub fn new(reason: BorrowRejectReason, venue_code: Option<String>, message: String) -> Self {
455 Self {
456 reason,
457 venue_code,
458 message,
459 available: None,
460 }
461 }
462
463 /// With how much the venue could still have lent.
464 #[must_use]
465 pub fn with_available(mut self, available: Decimal) -> Self {
466 self.available = Some(available);
467 self
468 }
469}
470
471impl fmt::Display for BorrowReject {
472 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473 let Self {
474 reason,
475 venue_code,
476 message,
477 available,
478 } = self;
479 write!(f, "{reason}")?;
480 if let Some(code) = venue_code {
481 write!(f, " (code {code})")?;
482 }
483 write!(f, ": {message}")?;
484 if let Some(available) = available {
485 write!(f, " ({available} available)")?;
486 }
487 Ok(())
488 }
489}
490
491/// The kind of borrow refusal carried by a [`BorrowReject`].
492#[non_exhaustive]
493#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
494pub enum BorrowRejectReason {
495 /// The instrument cannot be sold short at all, whoever asks (e.g. Alpaca's "asset X cannot be
496 /// sold short").
497 NotShortable,
498 /// Borrowing is barred for this account, or for this asset on this account (e.g. Binance
499 /// `-3008`, `-3012` and `-3014`, Alpaca's "account is not allowed to short").
500 BorrowDisabled,
501 /// The venue has too little of the asset to lend right now (e.g. Binance `-3045`). It may
502 /// lend again later.
503 InventoryUnavailable,
504 /// The borrow would exceed what the account may borrow (e.g. Binance `-3006`).
505 BorrowLimitExceeded,
506 /// A short-sale restriction is in effect, such as the SEC's Rule 201 circuit breaker after a
507 /// 10% fall, and the order would sell short at or below the best bid. A short sale priced
508 /// above the bid may still be accepted.
509 ShortSaleRestricted,
510 /// A borrow refusal of a kind not listed here. The [`BorrowReject`] message says which.
511 Other,
512}
513
514impl fmt::Display for BorrowRejectReason {
515 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516 f.write_str(match self {
517 Self::NotShortable => "not shortable",
518 Self::BorrowDisabled => "borrow disabled",
519 Self::InventoryUnavailable => "inventory unavailable",
520 Self::BorrowLimitExceeded => "borrow limit exceeded",
521 Self::ShortSaleRestricted => "short sale restricted",
522 Self::Other => "other",
523 })
524 }
525}
526
527/// Represents all errors that can be generated when cancelling or opening orders.
528///
529/// This is a subset of [`ClientError`] for order-specific operations. Use
530/// [`is_transient()`](Self::is_transient) to determine retry eligibility.
531#[non_exhaustive]
532#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
533pub enum OrderError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
534 /// Connectivity-based error (timeout, socket failure, exchange offline), or a venue failure
535 /// that leaves the order's status unknown.
536 ///
537 /// Transient — retry with backoff. See [`ConnectivityError`] for details. The order may or
538 /// may not have reached the matching engine, so before resubmitting an open request,
539 /// reconcile its state (open orders, fills); a blind retry can place it twice.
540 #[error("connectivity: {0}")]
541 Connectivity(#[from] ConnectivityError),
542
543 /// API-based error (rate limit, invalid instrument, order rejected, etc.).
544 ///
545 /// Retry semantics depend on the specific [`ApiError`] variant. Only
546 /// [`ApiError::RateLimit`] is transient; other variants are not.
547 #[error("order rejected: {0}")]
548 Rejected(#[from] ApiError<AssetKey, InstrumentKey>),
549
550 /// The order type is not supported by this connector.
551 ///
552 /// Non-transient — the connector does not support this order type (e.g.,
553 /// trailing stop orders on a connector that only supports market/limit).
554 #[error("unsupported order type: {0}")]
555 UnsupportedOrderType(String),
556
557 /// A quantity or price in the request has more precision than the venue accepts, so the
558 /// client refused it without sending it.
559 ///
560 /// Non-transient — change the request. A client that refuses this way never rounds a value
561 /// itself, because rounding would place a different order than the one requested; it
562 /// documents how to read the venue's precision so the caller can round first.
563 #[error("invalid precision: {0}")]
564 InvalidPrecision(PrecisionViolation),
565}
566
567/// A value in an order request with more precision than the venue accepts, or that is not
568/// positive.
569///
570/// Carried by [`OrderError::InvalidPrecision`].
571#[non_exhaustive]
572#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
573pub struct PrecisionViolation {
574 /// Which value of the request broke the rule.
575 pub field: OrderField,
576 /// The value as requested.
577 pub value: Decimal,
578 /// The rule it broke.
579 pub limit: PrecisionLimit,
580}
581
582impl PrecisionViolation {
583 /// The request's `field`, of `value`, breaks `limit`.
584 pub fn new(field: OrderField, value: Decimal, limit: PrecisionLimit) -> Self {
585 Self {
586 field,
587 value,
588 limit,
589 }
590 }
591}
592
593impl fmt::Display for PrecisionViolation {
594 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
595 let Self {
596 field,
597 value,
598 limit,
599 } = self;
600 write!(f, "{field} {value} ")?;
601 match limit {
602 PrecisionLimit::DecimalPlaces { max } => {
603 write!(f, "has more than {max} decimal places")
604 }
605 PrecisionLimit::SignificantFigures { max } => {
606 write!(
607 f,
608 "is not an integer and has more than {max} significant figures"
609 )
610 }
611 PrecisionLimit::NotRepresentable => {
612 write!(f, "cannot be sent exactly in the venue's number format")
613 }
614 PrecisionLimit::NotPositive => write!(f, "is not positive"),
615 }
616 }
617}
618
619/// A value of an order request, as named by a [`PrecisionViolation`].
620#[non_exhaustive]
621#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
622pub enum OrderField {
623 /// The order's quantity.
624 Quantity,
625 /// The order's limit price.
626 Price,
627 /// The price that triggers a stop or take-profit order.
628 TriggerPrice,
629 /// A trailing order's offset.
630 TrailingOffset,
631}
632
633impl fmt::Display for OrderField {
634 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635 f.write_str(match self {
636 Self::Quantity => "quantity",
637 Self::Price => "price",
638 Self::TriggerPrice => "trigger price",
639 Self::TrailingOffset => "trailing offset",
640 })
641 }
642}
643
644/// A venue's precision rule, as broken by a [`PrecisionViolation`].
645#[non_exhaustive]
646#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)]
647pub enum PrecisionLimit {
648 /// The value may have at most `max` decimal places.
649 DecimalPlaces {
650 /// The most decimal places allowed.
651 max: u32,
652 },
653 /// The value may have at most `max` significant figures, unless it is an integer.
654 SignificantFigures {
655 /// The most significant figures allowed.
656 max: u32,
657 },
658 /// The value cannot be sent exactly: the venue's client library converts it to a number
659 /// format that would change it.
660 NotRepresentable,
661 /// The value is zero or negative. A quantity, price or trailing offset must be positive.
662 NotPositive,
663}
664
665impl<AssetKey, InstrumentKey> OrderError<AssetKey, InstrumentKey> {
666 /// Returns `true` if this error is likely transient and the operation
667 /// may succeed if retried after a suitable backoff.
668 ///
669 /// # Transient errors
670 /// - [`Connectivity`](Self::Connectivity) errors (timeout, socket, offline)
671 /// - [`Rejected(ApiError::RateLimit)`](ApiError::RateLimit), though not before its
672 /// `retry_after`
673 ///
674 /// # Non-transient errors
675 /// - Other [`Rejected`](Self::Rejected) errors (invalid instrument, insufficient balance,
676 /// refused borrow, etc.)
677 /// - [`UnsupportedOrderType`](Self::UnsupportedOrderType) and
678 /// [`InvalidPrecision`](Self::InvalidPrecision)
679 pub fn is_transient(&self) -> bool {
680 match self {
681 Self::Connectivity(e) => e.is_transient(),
682 Self::Rejected(ApiError::RateLimit { .. }) => true,
683 Self::Rejected(_) => false,
684 Self::UnsupportedOrderType(_) | Self::InvalidPrecision(_) => false,
685 }
686 }
687}
688
689/// Why an account event stream ended.
690///
691/// Delivered in-band as the payload of
692/// [`AccountEventKind::StreamTerminated`](crate::AccountEventKind::StreamTerminated) so that
693/// stream death is a programmatic signal on the account feed rather than a log-only event. A
694/// consumer can react (re-sync via REST, re-establish the stream, halt trading) according to its
695/// own policy — the library reports *why* the stream ended without prescribing the response.
696///
697/// # Only in-band-deliverable terminations are represented
698///
699/// Every variant here is a termination the library can actually deliver to a consumer that is
700/// still listening. A consumer-initiated drop is deliberately **not** a variant: by the time a
701/// stream task observes the drop, the receiving half of the channel is already gone, so a
702/// `StreamTerminated` send would be a guaranteed no-op. `StreamTerminated` therefore always means
703/// "the stream died while you were still listening".
704///
705/// `#[non_exhaustive]` so venues may distinguish further termination causes in future without a
706/// breaking change (e.g. a venue able to signal a graceful shutdown over a still-open channel).
707#[non_exhaustive]
708#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
709pub enum StreamTerminationReason {
710 /// A venue with built-in reconnection exhausted its retry budget and gave up.
711 ///
712 /// Emitted by venues that reconnect internally (e.g. Binance spot/margin, Alpaca) once the
713 /// final attempt fails. `last_error` is the most recent underlying failure; `attempts` is the
714 /// number of reconnects tried before surrendering. The stream will not recover on its own —
715 /// the consumer must re-establish it.
716 #[error("reconnect budget exhausted after {attempts} attempts: {last_error}")]
717 ReconnectBudgetExhausted {
718 /// Number of reconnect attempts made before giving up.
719 attempts: u32,
720 /// The most recent underlying error that triggered surrender.
721 last_error: String,
722 },
723
724 /// The stream ended on an unrecoverable error with no automatic recovery.
725 ///
726 /// Emitted by venues without library-managed reconnection — e.g. IBKR (the underlying
727 /// subscription errored and the client does not retry), Hyperliquid (the SDK's internal
728 /// reconnection gave up and closed the channel), or the mock exchange (its broadcast stream
729 /// lagged past the buffer). The consumer is responsible for any re-establishment.
730 #[error("unrecoverable stream error: {0}")]
731 Error(String),
732
733 /// The venue is refusing this client for exceeding its rate limits, for longer than the
734 /// stream waits through in place.
735 ///
736 /// Emitted by venues that reconnect internally (Binance spot/margin) when a reconnect faces a
737 /// rate-limit cooldown, or a ban, with more than a few seconds left. The stream ends at once
738 /// rather than spend its reconnect budget against a venue that refuses every attempt, and
739 /// reconnects that would lengthen a ban. `retry_after` is how long the cooldown had left: the
740 /// earliest the consumer should re-establish the stream. Opening it sooner fails with
741 /// [`ApiError::RateLimit`], unsent.
742 ///
743 /// Nothing that happens on the account until the stream is re-established reaches the
744 /// consumer through this stream, as after any other termination: re-sync from the new
745 /// stream's snapshot, and read fills of the gap with a trades query. An
746 /// `ExecutionManager` re-opens the stream itself, no sooner than `retry_after`, and reports
747 /// each refused attempt as a `ReinitFailed` carrying the time left.
748 #[error("rate-limited by the venue; retry after {retry_after:?}")]
749 RateLimited {
750 /// How long the venue's cooldown had left when the stream ended.
751 retry_after: Duration,
752 },
753}
754
755/// Type alias for an [`AccountStreamInitError`] that is keyed on [`AssetNameExchange`] and
756/// [`InstrumentNameExchange`] (yet to be indexed).
757pub type UnindexedAccountStreamInitError =
758 AccountStreamInitError<AssetNameExchange, InstrumentNameExchange>;
759
760/// Why an attempt to initialise an account stream, its snapshot and its updates, failed.
761#[non_exhaustive]
762#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
763pub enum AccountStreamInitError<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
764 /// The [`ExecutionClient`](crate::client::ExecutionClient) failed to fetch the account
765 /// snapshot or to open the account stream. See [`ClientError::is_transient`].
766 #[error("{0}")]
767 Client(#[from] ClientError<AssetKey, InstrumentKey>),
768
769 /// The account snapshot named an exchange, asset or instrument that is not indexed. This does
770 /// not resolve on retry while the venue keeps reporting it.
771 #[error("IndexError: {0}")]
772 Index(#[from] IndexError),
773}
774
775impl<AssetKey, InstrumentKey> AccountStreamInitError<AssetKey, InstrumentKey> {
776 /// The venue's minimum wait before the next attempt: `Some` only for a client's
777 /// [`Api::RateLimit`](ApiError::RateLimit) whose venue sent a hint the client could read. See
778 /// [`ClientError::retry_after`].
779 pub fn retry_after(&self) -> Option<Duration> {
780 match self {
781 Self::Client(error) => error.retry_after(),
782 Self::Index(_) => None,
783 }
784 }
785}
786
787/// A failed attempt to re-initialise an account stream after it ended.
788///
789/// Delivered in-band as the payload of
790/// [`AccountEventKind::ReinitFailed`](crate::AccountEventKind::ReinitFailed), once per failed
791/// attempt. Attempts continue with backoff; giving up is the consumer's decision.
792#[non_exhaustive]
793#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Constructor)]
794pub struct AccountReinitFailure<AssetKey = AssetIndex, InstrumentKey = InstrumentIndex> {
795 /// Consecutive failed attempts since the stream last initialised, starting at 1.
796 pub attempt: u32,
797 /// Why this attempt failed.
798 pub error: AccountStreamInitError<AssetKey, InstrumentKey>,
799}
800
801/// Represents errors related to exchange, asset and instrument identifier key lookups.
802#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize, Error)]
803pub enum KeyError {
804 /// Indicates an [`ExchangeId`] was encountered that was not indexed, so does not have a
805 /// corresponding `ExchangeIndex`.
806 #[error("ExchangeId: {0}")]
807 ExchangeId(String),
808
809 /// Indicates an [`AssetNameExchange`] was encountered that was not indexed, so does not have a
810 /// corresponding [`AssetIndex`].
811 #[error("AssetKey: {0}")]
812 AssetKey(String),
813
814 /// Indicates an [`InstrumentNameExchange`] was encountered that was no indexed, so does
815 /// not have a corresponding [`InstrumentIndex`].
816 #[error("InstrumentKey: {0}")]
817 InstrumentKey(String),
818}
819
820#[cfg(test)]
821#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
822mod tests {
823 use super::*;
824 use rust_decimal_macros::dec;
825
826 #[test]
827 fn a_rate_limit_carries_and_shows_the_venues_wait() {
828 let hinted: UnindexedApiError = ApiError::rate_limited(Some(Duration::from_secs(7)));
829 assert_eq!(hinted.retry_after(), Some(Duration::from_secs(7)));
830 assert_eq!(hinted.to_string(), "rate limit exceeded; retry after 7s");
831 assert!(UnindexedClientError::Api(hinted.clone()).is_transient());
832 assert!(UnindexedOrderError::Rejected(hinted).is_transient());
833
834 let unhinted: UnindexedApiError = ApiError::rate_limited(None);
835 assert_eq!(unhinted.retry_after(), None);
836 assert_eq!(unhinted.to_string(), "rate limit exceeded");
837
838 let other: UnindexedApiError = ApiError::Unauthenticated("bad key".to_string());
839 assert_eq!(other.retry_after(), None);
840 }
841
842 #[test]
843 fn a_client_or_init_error_reads_the_wait_of_its_rate_limit() {
844 let wait = Some(Duration::from_secs(7));
845 let hinted = UnindexedClientError::Api(ApiError::rate_limited(wait));
846 assert_eq!(hinted.retry_after(), wait);
847 assert_eq!(
848 UnindexedAccountStreamInitError::Client(hinted).retry_after(),
849 wait
850 );
851
852 let unhinted = UnindexedClientError::Api(ApiError::rate_limited(None));
853 assert_eq!(unhinted.retry_after(), None);
854 let socket = UnindexedClientError::Connectivity(ConnectivityError::Timeout);
855 assert_eq!(socket.retry_after(), None);
856 assert_eq!(
857 UnindexedAccountStreamInitError::Client(socket).retry_after(),
858 None
859 );
860 }
861
862 #[test]
863 fn a_rate_limited_termination_shows_and_round_trips_its_wait() {
864 let reason = StreamTerminationReason::RateLimited {
865 retry_after: Duration::from_secs(120),
866 };
867 assert_eq!(
868 reason.to_string(),
869 "rate-limited by the venue; retry after 120s"
870 );
871 let json = serde_json::to_string(&reason).unwrap();
872 assert_eq!(
873 serde_json::from_str::<StreamTerminationReason>(&json).unwrap(),
874 reason
875 );
876 }
877
878 #[test]
879 fn a_rate_limit_round_trips_through_serde() {
880 for retry_after in [None, Some(Duration::from_millis(7_250))] {
881 let error: UnindexedApiError = ApiError::rate_limited(retry_after);
882 let json = serde_json::to_string(&error).unwrap();
883 assert_eq!(
884 serde_json::from_str::<UnindexedApiError>(&json).unwrap(),
885 error,
886 "{json}"
887 );
888 }
889 }
890
891 #[test]
892 fn test_connectivity_error_is_transient() {
893 assert!(ConnectivityError::Timeout.is_transient());
894 assert!(ConnectivityError::Socket("connection refused".into()).is_transient());
895 assert!(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot).is_transient());
896 }
897
898 #[test]
899 fn test_client_error_is_transient_connectivity() {
900 let err: ClientError = ClientError::Connectivity(ConnectivityError::Timeout);
901 assert!(err.is_transient());
902
903 let err: ClientError = ClientError::Connectivity(ConnectivityError::Socket("err".into()));
904 assert!(err.is_transient());
905 }
906
907 #[test]
908 fn test_client_error_is_transient_rate_limit() {
909 let err: ClientError = ClientError::Api(ApiError::rate_limited(None));
910 assert!(err.is_transient());
911 }
912
913 #[test]
914 fn test_client_error_not_transient_api_errors() {
915 let err: ClientError =
916 ClientError::Api(ApiError::AssetInvalid(AssetIndex(0), "bad".into()));
917 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
918
919 let err: ClientError = ClientError::Api(ApiError::BalanceInsufficient(
920 Some(AssetIndex(0)),
921 "low".into(),
922 ));
923 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
924
925 let err: ClientError = ClientError::Api(ApiError::InstrumentInvalid(
926 InstrumentIndex(0),
927 "bad".into(),
928 ));
929 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
930
931 let err: ClientError = ClientError::Api(ApiError::OrderRejected("rejected".into()));
932 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
933
934 let err: ClientError = ClientError::Api(ApiError::OrderAlreadyCancelled);
935 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
936
937 let err: ClientError = ClientError::Api(ApiError::OrderAlreadyFullyFilled);
938 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
939
940 let err: ClientError =
941 ClientError::Api(ApiError::Unauthenticated("invalid signature".into()));
942 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
943
944 let err: ClientError =
945 ClientError::Api(ApiError::RequestRejected("-1127 More than 24 hours".into()));
946 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
947
948 let err: ClientError = ClientError::Api(ApiError::BorrowRejected(BorrowReject::new(
949 BorrowRejectReason::InventoryUnavailable,
950 Some("-3045".into()),
951 "-3045 The system doesn't have enough asset now.".into(),
952 )));
953 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
954 }
955
956 #[test]
957 fn borrow_rejected_shows_its_reason_and_code() {
958 let coded: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
959 BorrowRejectReason::BorrowDisabled,
960 Some("40310000".into()),
961 "account is not allowed to short".into(),
962 ));
963 assert_eq!(
964 coded.to_string(),
965 "borrow rejected: borrow disabled (code 40310000): account is not allowed to short"
966 );
967
968 let uncoded: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
969 BorrowRejectReason::Other,
970 None,
971 "no borrow".into(),
972 ));
973 assert_eq!(uncoded.to_string(), "borrow rejected: other: no borrow");
974
975 let short: UnindexedApiError = ApiError::BorrowRejected(BorrowReject::new(
976 BorrowRejectReason::ShortSaleRestricted,
977 None,
978 "priced at the bid".into(),
979 ));
980 assert_eq!(
981 short.to_string(),
982 "borrow rejected: short sale restricted: priced at the bid"
983 );
984 }
985
986 #[test]
987 fn borrow_rejected_shows_what_is_available_only_when_known() {
988 let reject = BorrowReject::new(
989 BorrowRejectReason::InventoryUnavailable,
990 None,
991 "too little to lend".into(),
992 );
993 assert_eq!(reject.available, None);
994
995 let reject = reject.with_available(dec!(2.5));
996 assert_eq!(reject.available, Some(dec!(2.5)));
997 assert_eq!(
998 reject.to_string(),
999 "inventory unavailable: too little to lend (2.5 available)"
1000 );
1001 }
1002
1003 #[test]
1004 fn balance_insufficient_names_its_asset_only_when_known() {
1005 let named: UnindexedApiError =
1006 ApiError::BalanceInsufficient(Some(AssetNameExchange::new("BTC")), "low".to_string());
1007 assert_eq!(named.to_string(), "balance insufficient for asset BTC: low");
1008
1009 let unnamed: UnindexedApiError = ApiError::BalanceInsufficient(None, "low".to_string());
1010 assert_eq!(unnamed.to_string(), "balance insufficient: low");
1011 }
1012
1013 #[test]
1014 fn test_client_error_not_transient_task_failed() {
1015 let err: ClientError = ClientError::TaskFailed("task panicked".into());
1016 assert!(!err.is_transient());
1017 }
1018
1019 #[test]
1020 fn test_client_error_not_transient_internal() {
1021 let err: ClientError = ClientError::Internal("unknown error".into());
1022 assert!(!err.is_transient());
1023 }
1024
1025 #[test]
1026 fn test_client_error_not_transient_truncated() {
1027 let err: ClientError = ClientError::Truncated { fills_read: 5_000 };
1028 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1029
1030 let err: ClientError = ClientError::TruncatedSnapshot { limit: 500 };
1031 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1032 }
1033
1034 #[test]
1035 fn test_client_error_is_transient_exchange_offline() {
1036 let err: ClientError =
1037 ClientError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
1038 assert!(err.is_transient(), "expected transient for {:?}", err);
1039 }
1040
1041 #[test]
1042 fn test_order_error_is_transient_connectivity() {
1043 let err: UnindexedOrderError = OrderError::Connectivity(ConnectivityError::Timeout);
1044 assert!(err.is_transient(), "expected transient for {:?}", err);
1045
1046 let err: UnindexedOrderError =
1047 OrderError::Connectivity(ConnectivityError::Socket("connection reset".into()));
1048 assert!(err.is_transient(), "expected transient for {:?}", err);
1049
1050 let err: UnindexedOrderError =
1051 OrderError::Connectivity(ConnectivityError::ExchangeOffline(ExchangeId::BinanceSpot));
1052 assert!(err.is_transient(), "expected transient for {:?}", err);
1053 }
1054
1055 #[test]
1056 fn test_order_error_is_transient_rate_limit() {
1057 let err: UnindexedOrderError = OrderError::Rejected(ApiError::rate_limited(None));
1058 assert!(err.is_transient(), "expected transient for {:?}", err);
1059 }
1060
1061 #[test]
1062 fn test_order_error_not_transient_api_errors() {
1063 let err: UnindexedOrderError =
1064 OrderError::Rejected(ApiError::OrderRejected("price out of range".into()));
1065 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1066
1067 let err: UnindexedOrderError = OrderError::Rejected(ApiError::OrderAlreadyCancelled);
1068 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1069
1070 let err: UnindexedOrderError =
1071 OrderError::Rejected(ApiError::BorrowRejected(BorrowReject::new(
1072 BorrowRejectReason::NotShortable,
1073 None,
1074 "asset XYZ cannot be sold short".into(),
1075 )));
1076 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1077
1078 let err: UnindexedOrderError = OrderError::Rejected(ApiError::BalanceInsufficient(
1079 Some(AssetNameExchange::from("BTC")),
1080 "insufficient".into(),
1081 ));
1082 assert!(!err.is_transient(), "expected non-transient for {:?}", err);
1083 }
1084}