Skip to main content

wickra_exchange_core/
error.rs

1//! The unified error taxonomy.
2//!
3//! Every exchange maps its own numeric/string error codes onto this single enum,
4//! so a consumer can react to a class of failure (`RateLimited`, `InsufficientBalance`,
5//! `InvalidSymbol`, …) without knowing which venue produced it. Variants that
6//! originate at a venue keep the raw `code`/`message` for diagnostics.
7
8use crate::options::MarketType;
9use std::time::Duration;
10
11/// The crate-wide result type.
12pub type Result<T> = std::result::Result<T, Error>;
13
14/// A unified connectivity / execution error.
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
16#[non_exhaustive]
17pub enum Error {
18    /// No exchange is registered under the given name.
19    #[error("unsupported exchange: {0}")]
20    UnsupportedExchange(String),
21
22    /// A symbol string could not be parsed or is not listed on the venue.
23    #[error("invalid symbol: {0}")]
24    InvalidSymbol(String),
25
26    /// Credentials are missing a field the venue requires (e.g. a passphrase).
27    #[error("invalid credentials: {0}")]
28    InvalidCredentials(&'static str),
29
30    /// An order request is malformed before it is ever sent.
31    #[error("invalid order: {0}")]
32    InvalidOrder(&'static str),
33
34    /// An order would violate one of the venue's symbol filters
35    /// (lot size, price tick, min-notional, …).
36    #[error("order violates exchange filter: {0}")]
37    Filter(String),
38
39    /// Request signing or authentication was rejected by the venue.
40    #[error("authentication failed: {0}")]
41    Auth(String),
42
43    /// The account has insufficient balance for the request.
44    #[error("insufficient balance")]
45    InsufficientBalance,
46
47    /// The venue rate-limited the request; `retry_after` is the advised wait if
48    /// the venue supplied one (`Retry-After`).
49    #[error("rate limited{}", .retry_after.map(|d| format!(" (retry after {:.1}s)", d.as_secs_f64())).unwrap_or_default())]
50    RateLimited {
51        /// The advised back-off, if the venue provided one.
52        retry_after: Option<Duration>,
53    },
54
55    /// The venue rejected the order after it was accepted for processing.
56    #[error("order rejected by exchange (code {code}): {message}")]
57    OrderRejected {
58        /// The venue's error code, verbatim.
59        code: String,
60        /// The venue's human-readable message.
61        message: String,
62    },
63
64    /// Any other error reported by the venue, kept verbatim for diagnostics.
65    #[error("exchange error (code {code}): {message}")]
66    Exchange {
67        /// The venue's error code, verbatim.
68        code: String,
69        /// The venue's human-readable message.
70        message: String,
71    },
72
73    /// A requested entity (order, symbol metadata, …) was not found.
74    #[error("not found: {0}")]
75    NotFound(String),
76
77    /// The request timed out before a response was received.
78    #[error("request timed out")]
79    Timeout,
80
81    /// A stream or client operation was attempted before connecting.
82    #[error("not connected")]
83    NotConnected,
84
85    /// A transport-level (socket/TLS/DNS) failure.
86    #[error("network error: {0}")]
87    Network(String),
88
89    /// A response could not be parsed into the expected shape.
90    #[error("deserialization error: {0}")]
91    Deserialization(String),
92}
93
94impl Error {
95    /// A trigger order on a venue path that cannot carry the trigger price.
96    ///
97    /// Placing it anyway is not a smaller version of the order the caller
98    /// asked for -- a stop-loss with no trigger executes at once, at the price
99    /// it was meant to protect against. So the paths that cannot express the
100    /// trigger refuse, and this is the refusal.
101    pub(crate) fn unsupported_trigger(venue: &str) -> Self {
102        Error::Exchange {
103            code: "unsupported".to_string(),
104            message: format!(
105                "{venue}: trigger (stop) orders are not implemented on this path;                  sending one without its trigger price would execute immediately                  instead of resting"
106            ),
107        }
108    }
109
110    /// A market this client does not route.
111    ///
112    /// [`MarketType`](crate::MarketType) names four markets, and a venue client
113    /// that quietly serves a different one than the caller asked for is the
114    /// same defect as an order that quietly drops a field: the answer looks
115    /// right and describes something else. Binance's spot host answers
116    /// `BTCUSD` -- it is a real spot pair -- so a coin-margined request there
117    /// returned real, wrong prices, and would have placed a real, wrong order.
118    ///
119    /// So a market a client does not route is refused before anything is sent.
120    pub(crate) fn unsupported_market(venue: &str, market: MarketType) -> Self {
121        Error::Exchange {
122            code: "unsupported".to_string(),
123            message: format!(
124                "{venue}: {} is not routed by this client; the request would reach                  a different market than the one asked for",
125                market.describe()
126            ),
127        }
128    }
129
130    /// An order field the caller set that this venue path cannot express.
131    ///
132    /// The same reasoning as [`Error::unsupported_trigger`], generalised to the
133    /// rest of the request. A field the caller set is part of what the order
134    /// *is*: an `Ioc` that is sent as a `Gtc` rests in the book the caller
135    /// asked it never to rest in, and a `post_only` that is dropped can take
136    /// liquidity at the taker fee. Sending the order without the field is not
137    /// a smaller version of it, so the paths that cannot express a field refuse
138    /// instead of quietly weakening the order.
139    pub(crate) fn unsupported_field(venue: &str, field: &str, detail: &str) -> Self {
140        Error::Exchange {
141            code: "unsupported".to_string(),
142            message: format!(
143                "{venue}: {field} cannot be expressed on this order path ({detail});                  sending the order without it would place a different order than requested"
144            ),
145        }
146    }
147
148    /// Whether retrying the operation could plausibly succeed: transient
149    /// transport failures, timeouts and rate limits. Permanent failures
150    /// (invalid order, auth, insufficient balance, …) return `false`.
151    #[must_use]
152    pub fn is_retryable(&self) -> bool {
153        matches!(
154            self,
155            Error::RateLimited { .. } | Error::Timeout | Error::Network(_)
156        )
157    }
158
159    /// The venue-supplied error code, if this error originated at an exchange.
160    #[must_use]
161    pub fn code(&self) -> Option<&str> {
162        match self {
163            Error::OrderRejected { code, .. } | Error::Exchange { code, .. } => Some(code),
164            _ => None,
165        }
166    }
167
168    /// Attach the venue's advised wait to a rate-limit error that was raised
169    /// without one.
170    ///
171    /// The venue clients recognise a rate limit from the *body* -- a numeric
172    /// code in the error envelope -- while the wait arrives in the
173    /// `Retry-After` *header*. The two are read in different places, so this
174    /// joins them at the call site that has both.
175    ///
176    /// Every other variant is returned untouched, and a `RateLimited` that
177    /// already carries a wait keeps it: a venue that states the delay in its
178    /// body is more specific than the header, and this must not overwrite it.
179    #[must_use]
180    pub fn with_retry_after(self, retry_after: Option<Duration>) -> Self {
181        match self {
182            Error::RateLimited { retry_after: None } => Error::RateLimited { retry_after },
183            other => other,
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn retryable_classification() {
194        assert!(Error::Timeout.is_retryable());
195        assert!(Error::Network("reset".into()).is_retryable());
196        assert!(Error::RateLimited { retry_after: None }.is_retryable());
197        assert!(!Error::InsufficientBalance.is_retryable());
198        assert!(!Error::InvalidOrder("no price").is_retryable());
199        assert!(!Error::Auth("bad signature".into()).is_retryable());
200    }
201
202    #[test]
203    fn code_is_exposed_only_for_venue_errors() {
204        let rejected = Error::OrderRejected {
205            code: "-2010".into(),
206            message: "insufficient balance".into(),
207        };
208        assert_eq!(rejected.code(), Some("-2010"));
209        let other = Error::Exchange {
210            code: "1021".into(),
211            message: "timestamp outside recvWindow".into(),
212        };
213        assert_eq!(other.code(), Some("1021"));
214        assert_eq!(Error::Timeout.code(), None);
215        assert_eq!(Error::InvalidSymbol("??".into()).code(), None);
216    }
217
218    #[test]
219    fn display_renders_each_variant() {
220        assert_eq!(
221            Error::UnsupportedExchange("foo".into()).to_string(),
222            "unsupported exchange: foo"
223        );
224        assert_eq!(
225            Error::InvalidSymbol("BTC".into()).to_string(),
226            "invalid symbol: BTC"
227        );
228        assert_eq!(
229            Error::InvalidCredentials("passphrase required").to_string(),
230            "invalid credentials: passphrase required"
231        );
232        assert_eq!(
233            Error::InvalidOrder("limit order needs a price").to_string(),
234            "invalid order: limit order needs a price"
235        );
236        assert_eq!(
237            Error::Filter("LOT_SIZE: step 0.001".into()).to_string(),
238            "order violates exchange filter: LOT_SIZE: step 0.001"
239        );
240        assert_eq!(
241            Error::Auth("bad signature".into()).to_string(),
242            "authentication failed: bad signature"
243        );
244        assert_eq!(
245            Error::InsufficientBalance.to_string(),
246            "insufficient balance"
247        );
248        assert_eq!(
249            Error::RateLimited { retry_after: None }.to_string(),
250            "rate limited"
251        );
252        assert_eq!(
253            Error::RateLimited {
254                retry_after: Some(Duration::from_millis(1500))
255            }
256            .to_string(),
257            "rate limited (retry after 1.5s)"
258        );
259        assert_eq!(
260            Error::OrderRejected {
261                code: "-2010".into(),
262                message: "rejected".into()
263            }
264            .to_string(),
265            "order rejected by exchange (code -2010): rejected"
266        );
267        assert_eq!(
268            Error::Exchange {
269                code: "1".into(),
270                message: "oops".into()
271            }
272            .to_string(),
273            "exchange error (code 1): oops"
274        );
275        assert_eq!(
276            Error::NotFound("order 7".into()).to_string(),
277            "not found: order 7"
278        );
279        assert_eq!(Error::Timeout.to_string(), "request timed out");
280        assert_eq!(Error::NotConnected.to_string(), "not connected");
281        assert_eq!(
282            Error::Network("eof".into()).to_string(),
283            "network error: eof"
284        );
285        assert_eq!(
286            Error::Deserialization("bad json".into()).to_string(),
287            "deserialization error: bad json"
288        );
289    }
290
291    #[test]
292    fn with_retry_after_fills_an_empty_rate_limit() {
293        let filled = Error::RateLimited { retry_after: None }
294            .with_retry_after(Some(Duration::from_millis(2500)));
295        let expected = Some(Duration::from_millis(2500));
296        assert!(matches!(filled, Error::RateLimited { retry_after } if retry_after == expected));
297    }
298
299    #[test]
300    fn with_retry_after_does_not_overwrite_what_the_venue_already_said() {
301        // A venue that states the delay in its body is more specific than the
302        // header, so the header must not replace it.
303        let body_said = Error::RateLimited {
304            retry_after: Some(Duration::from_secs(60)),
305        };
306        let kept = body_said.with_retry_after(Some(Duration::from_secs(1)));
307        let expected = Some(Duration::from_secs(60));
308        assert!(matches!(kept, Error::RateLimited { retry_after } if retry_after == expected));
309    }
310
311    #[test]
312    fn with_retry_after_leaves_every_other_variant_alone() {
313        let timeout = Error::Timeout.with_retry_after(Some(Duration::from_secs(5)));
314        assert!(matches!(timeout, Error::Timeout));
315        let balance = Error::InsufficientBalance.with_retry_after(Some(Duration::from_secs(5)));
316        assert!(matches!(balance, Error::InsufficientBalance));
317    }
318}