wickra_exchange_core/
error.rs1use crate::options::MarketType;
9use std::time::Duration;
10
11pub type Result<T> = std::result::Result<T, Error>;
13
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
16#[non_exhaustive]
17pub enum Error {
18 #[error("unsupported exchange: {0}")]
20 UnsupportedExchange(String),
21
22 #[error("invalid symbol: {0}")]
24 InvalidSymbol(String),
25
26 #[error("invalid credentials: {0}")]
28 InvalidCredentials(&'static str),
29
30 #[error("invalid order: {0}")]
32 InvalidOrder(&'static str),
33
34 #[error("order violates exchange filter: {0}")]
37 Filter(String),
38
39 #[error("authentication failed: {0}")]
41 Auth(String),
42
43 #[error("insufficient balance")]
45 InsufficientBalance,
46
47 #[error("rate limited{}", .retry_after.map(|d| format!(" (retry after {:.1}s)", d.as_secs_f64())).unwrap_or_default())]
50 RateLimited {
51 retry_after: Option<Duration>,
53 },
54
55 #[error("order rejected by exchange (code {code}): {message}")]
57 OrderRejected {
58 code: String,
60 message: String,
62 },
63
64 #[error("exchange error (code {code}): {message}")]
66 Exchange {
67 code: String,
69 message: String,
71 },
72
73 #[error("not found: {0}")]
75 NotFound(String),
76
77 #[error("request timed out")]
79 Timeout,
80
81 #[error("not connected")]
83 NotConnected,
84
85 #[error("network error: {0}")]
87 Network(String),
88
89 #[error("deserialization error: {0}")]
91 Deserialization(String),
92}
93
94impl Error {
95 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 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 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 #[must_use]
152 pub fn is_retryable(&self) -> bool {
153 matches!(
154 self,
155 Error::RateLimited { .. } | Error::Timeout | Error::Network(_)
156 )
157 }
158
159 #[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 #[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 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}