1use std::fmt;
2use std::time::Duration;
3
4use derive_builder::Builder;
5use mkt_types::ExchangeId;
6use strum_macros::{Display, EnumString, IntoStaticStr};
7use thiserror::Error;
8
9use crate::Capability;
10
11pub type Result<T> = std::result::Result<T, Error>;
12
13#[non_exhaustive]
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum CapabilityUnavailableReason {
16 NotAdvertised,
17 NotBound,
18}
19
20impl fmt::Display for CapabilityUnavailableReason {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 Self::NotAdvertised => f.write_str("capability is not advertised by this adapter"),
24 Self::NotBound => f.write_str("capability is advertised but not bound into the handle"),
25 }
26 }
27}
28
29#[non_exhaustive]
30#[derive(Debug, Clone, Builder)]
31#[builder(pattern = "owned", setter(into, strip_option))]
32pub struct InvalidConfigContext {
33 #[builder(default)]
34 pub exchange: Option<ExchangeId>,
35 #[builder(default)]
36 pub config_key: Option<String>,
37 pub message: String,
38}
39
40impl InvalidConfigContext {
41 pub fn builder() -> InvalidConfigContextBuilder {
42 InvalidConfigContextBuilder::default()
43 }
44}
45
46#[non_exhaustive]
47#[derive(Debug, Clone, Builder)]
48#[builder(pattern = "owned", setter(into, strip_option))]
49pub struct BadRequestContext {
50 #[builder(default)]
51 pub exchange: Option<ExchangeId>,
52 #[builder(default)]
53 pub operation: Option<String>,
54 pub message: String,
55}
56
57impl BadRequestContext {
58 pub fn builder() -> BadRequestContextBuilder {
59 BadRequestContextBuilder::default()
60 }
61}
62
63#[non_exhaustive]
64#[derive(Debug, Clone, Builder)]
65#[builder(pattern = "owned", setter(into, strip_option))]
66pub struct TransportContext {
67 #[builder(default)]
68 pub exchange: Option<ExchangeId>,
69 #[builder(default)]
70 pub operation: Option<String>,
71 #[builder(default)]
72 pub status: Option<u16>,
73 pub message: String,
74}
75
76impl TransportContext {
77 pub fn builder() -> TransportContextBuilder {
78 TransportContextBuilder::default()
79 }
80}
81
82#[non_exhaustive]
83#[derive(Debug, Clone, Builder)]
84#[builder(pattern = "owned", setter(into, strip_option))]
85pub struct DecodeContext {
86 #[builder(default)]
87 pub exchange: Option<ExchangeId>,
88 #[builder(default)]
89 pub operation: Option<String>,
90 pub message: String,
91}
92
93impl DecodeContext {
94 pub fn builder() -> DecodeContextBuilder {
95 DecodeContextBuilder::default()
96 }
97}
98
99#[non_exhaustive]
100#[derive(Debug, Clone, Builder)]
101#[builder(pattern = "owned", setter(into, strip_option))]
102pub struct AuthenticationContext {
103 #[builder(default)]
104 pub exchange: Option<ExchangeId>,
105 #[builder(default)]
106 pub operation: Option<String>,
107 #[builder(default)]
108 pub code: Option<String>,
109 pub message: String,
110}
111
112impl AuthenticationContext {
113 pub fn builder() -> AuthenticationContextBuilder {
114 AuthenticationContextBuilder::default()
115 }
116}
117
118#[non_exhaustive]
119#[derive(Debug, Clone, Builder)]
120#[builder(pattern = "owned", setter(into, strip_option))]
121pub struct TimeoutContext {
122 #[builder(default)]
123 pub exchange: Option<ExchangeId>,
124 #[builder(default)]
125 pub operation: Option<String>,
126 pub message: String,
127}
128
129impl TimeoutContext {
130 pub fn builder() -> TimeoutContextBuilder {
131 TimeoutContextBuilder::default()
132 }
133}
134
135#[non_exhaustive]
136#[derive(Debug, Clone, Builder)]
137#[builder(pattern = "owned", setter(into, strip_option))]
138pub struct RateLimitedContext {
139 #[builder(default)]
140 pub exchange: Option<ExchangeId>,
141 #[builder(default)]
142 pub operation: Option<String>,
143 #[builder(default)]
144 pub retry_after: Option<Duration>,
145 pub message: String,
146}
147
148impl RateLimitedContext {
149 pub fn builder() -> RateLimitedContextBuilder {
150 RateLimitedContextBuilder::default()
151 }
152}
153
154#[non_exhaustive]
155#[derive(Debug, Clone, Builder)]
156#[builder(pattern = "owned", setter(into, strip_option))]
157pub struct ExchangeErrorContext {
158 pub exchange: ExchangeId,
159 #[builder(default)]
160 pub operation: Option<String>,
161 #[builder(default)]
162 pub code: Option<String>,
163 #[builder(default)]
164 pub status: Option<u16>,
165 pub message: String,
166}
167
168impl ExchangeErrorContext {
169 pub fn builder() -> ExchangeErrorContextBuilder {
170 ExchangeErrorContextBuilder::default()
171 }
172}
173
174#[non_exhaustive]
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, IntoStaticStr)]
176#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
177pub enum ErrorKind {
178 CapabilityUnavailable,
179 MissingCredentials,
180 InvalidConfig,
181 BadRequest,
182 Transport,
183 Decode,
184 Authentication,
185 Timeout,
186 RateLimited,
187 Exchange,
188}
189
190#[non_exhaustive]
191#[derive(Debug, Error)]
192pub enum Error {
193 #[error("{exchange} cannot provide {capability}: {reason}")]
194 CapabilityUnavailable {
195 exchange: ExchangeId,
196 capability: Capability,
197 reason: CapabilityUnavailableReason,
198 },
199 #[error("missing API credentials for {exchange}")]
200 MissingCredentials { exchange: ExchangeId },
201 #[error(
202 "invalid config{exchange}{config_key}: {}",
203 .context.message,
204 exchange = suffix::exchange(.context.exchange.as_ref()),
205 config_key = suffix::config_key(.context.config_key.as_deref()),
206 )]
207 InvalidConfig { context: InvalidConfigContext },
208 #[error(
209 "bad request{exchange}{operation}: {}",
210 .context.message,
211 exchange = suffix::exchange(.context.exchange.as_ref()),
212 operation = suffix::operation(.context.operation.as_deref()),
213 )]
214 BadRequest { context: BadRequestContext },
215 #[error(
216 "transport error{exchange}{operation}{status}: {}",
217 .context.message,
218 exchange = suffix::exchange(.context.exchange.as_ref()),
219 operation = suffix::operation(.context.operation.as_deref()),
220 status = suffix::status(.context.status),
221 )]
222 Transport { context: TransportContext },
223 #[error(
224 "decode error{exchange}{operation}: {}",
225 .context.message,
226 exchange = suffix::exchange(.context.exchange.as_ref()),
227 operation = suffix::operation(.context.operation.as_deref()),
228 )]
229 Decode { context: DecodeContext },
230 #[error(
231 "authentication error{exchange}{operation}{code}: {}",
232 .context.message,
233 exchange = suffix::exchange(.context.exchange.as_ref()),
234 operation = suffix::operation(.context.operation.as_deref()),
235 code = suffix::code(.context.code.as_deref()),
236 )]
237 Authentication { context: AuthenticationContext },
238 #[error(
239 "request timed out{exchange}{operation}: {}",
240 .context.message,
241 exchange = suffix::exchange(.context.exchange.as_ref()),
242 operation = suffix::operation(.context.operation.as_deref()),
243 )]
244 Timeout { context: TimeoutContext },
245 #[error(
246 "rate limited{exchange}{operation}{retry_after}: {}",
247 .context.message,
248 exchange = suffix::exchange(.context.exchange.as_ref()),
249 operation = suffix::operation(.context.operation.as_deref()),
250 retry_after = suffix::retry_after(.context.retry_after),
251 )]
252 RateLimited { context: RateLimitedContext },
253 #[error(
254 "exchange error for {exchange}{operation}{code}{status}: {}",
255 .context.message,
256 exchange = .context.exchange,
257 operation = suffix::operation(.context.operation.as_deref()),
258 code = suffix::code(.context.code.as_deref()),
259 status = suffix::status(.context.status),
260 )]
261 Exchange { context: ExchangeErrorContext },
262}
263
264macro_rules! impl_context_error_conversions {
265 ($context:ident, $builder:ident, $variant:ident, $expect:literal) => {
266 impl From<$context> for Error {
267 fn from(context: $context) -> Self {
268 Self::$variant { context }
269 }
270 }
271
272 impl From<$builder> for Error {
273 fn from(builder: $builder) -> Self {
274 Self::$variant {
275 context: builder.build().expect($expect),
276 }
277 }
278 }
279 };
280}
281
282impl_context_error_conversions!(
283 InvalidConfigContext,
284 InvalidConfigContextBuilder,
285 InvalidConfig,
286 "invalid config builder must include a message"
287);
288impl_context_error_conversions!(
289 BadRequestContext,
290 BadRequestContextBuilder,
291 BadRequest,
292 "bad request builder must include a message"
293);
294impl_context_error_conversions!(
295 TransportContext,
296 TransportContextBuilder,
297 Transport,
298 "transport builder must include a message"
299);
300impl_context_error_conversions!(
301 DecodeContext,
302 DecodeContextBuilder,
303 Decode,
304 "decode builder must include a message"
305);
306impl_context_error_conversions!(
307 AuthenticationContext,
308 AuthenticationContextBuilder,
309 Authentication,
310 "authentication builder must include a message"
311);
312impl_context_error_conversions!(
313 TimeoutContext,
314 TimeoutContextBuilder,
315 Timeout,
316 "timeout builder must include a message"
317);
318impl_context_error_conversions!(
319 RateLimitedContext,
320 RateLimitedContextBuilder,
321 RateLimited,
322 "rate-limited builder must include a message"
323);
324impl_context_error_conversions!(
325 ExchangeErrorContext,
326 ExchangeErrorContextBuilder,
327 Exchange,
328 "exchange error builder must include exchange and message"
329);
330
331impl Error {
332 pub fn capability_unavailable(
333 exchange: ExchangeId,
334 capability: Capability,
335 reason: CapabilityUnavailableReason,
336 ) -> Self {
337 Self::CapabilityUnavailable {
338 exchange,
339 capability,
340 reason,
341 }
342 }
343
344 pub fn unsupported(exchange: ExchangeId, capability: Capability) -> Self {
345 Self::capability_unavailable(
346 exchange,
347 capability,
348 CapabilityUnavailableReason::NotAdvertised,
349 )
350 }
351
352 pub fn missing_credentials(exchange: ExchangeId) -> Self {
353 Self::MissingCredentials { exchange }
354 }
355
356 pub fn invalid_config(message: impl Into<String>) -> InvalidConfigContextBuilder {
357 InvalidConfigContext::builder().message(message)
358 }
359
360 pub fn bad_request(message: impl Into<String>) -> BadRequestContextBuilder {
361 BadRequestContext::builder().message(message)
362 }
363
364 pub fn transport(message: impl Into<String>) -> TransportContextBuilder {
365 TransportContext::builder().message(message)
366 }
367
368 pub fn decode(message: impl Into<String>) -> DecodeContextBuilder {
369 DecodeContext::builder().message(message)
370 }
371
372 pub fn authentication(message: impl Into<String>) -> AuthenticationContextBuilder {
373 AuthenticationContext::builder().message(message)
374 }
375
376 pub fn timeout(message: impl Into<String>) -> TimeoutContextBuilder {
377 TimeoutContext::builder().message(message)
378 }
379
380 pub fn rate_limited(message: impl Into<String>) -> RateLimitedContextBuilder {
381 RateLimitedContext::builder().message(message)
382 }
383
384 pub fn exchange_error(
385 exchange: ExchangeId,
386 message: impl Into<String>,
387 ) -> ExchangeErrorContextBuilder {
388 ExchangeErrorContext::builder()
389 .exchange(exchange)
390 .message(message)
391 }
392
393 pub fn kind(&self) -> ErrorKind {
394 match self {
395 Self::CapabilityUnavailable { .. } => ErrorKind::CapabilityUnavailable,
396 Self::MissingCredentials { .. } => ErrorKind::MissingCredentials,
397 Self::InvalidConfig { .. } => ErrorKind::InvalidConfig,
398 Self::BadRequest { .. } => ErrorKind::BadRequest,
399 Self::Transport { .. } => ErrorKind::Transport,
400 Self::Decode { .. } => ErrorKind::Decode,
401 Self::Authentication { .. } => ErrorKind::Authentication,
402 Self::Timeout { .. } => ErrorKind::Timeout,
403 Self::RateLimited { .. } => ErrorKind::RateLimited,
404 Self::Exchange { .. } => ErrorKind::Exchange,
405 }
406 }
407
408 pub fn exchange(&self) -> Option<&ExchangeId> {
409 match self {
410 Self::CapabilityUnavailable { exchange, .. }
411 | Self::MissingCredentials { exchange } => Some(exchange),
412 Self::InvalidConfig { context } => context.exchange.as_ref(),
413 Self::BadRequest { context } => context.exchange.as_ref(),
414 Self::Transport { context } => context.exchange.as_ref(),
415 Self::Decode { context } => context.exchange.as_ref(),
416 Self::Authentication { context } => context.exchange.as_ref(),
417 Self::Timeout { context } => context.exchange.as_ref(),
418 Self::RateLimited { context } => context.exchange.as_ref(),
419 Self::Exchange { context } => Some(&context.exchange),
420 }
421 }
422
423 pub fn operation(&self) -> Option<&str> {
424 match self {
425 Self::BadRequest { context } => context.operation.as_deref(),
426 Self::Transport { context } => context.operation.as_deref(),
427 Self::Decode { context } => context.operation.as_deref(),
428 Self::Authentication { context } => context.operation.as_deref(),
429 Self::Timeout { context } => context.operation.as_deref(),
430 Self::RateLimited { context } => context.operation.as_deref(),
431 Self::Exchange { context } => context.operation.as_deref(),
432 Self::CapabilityUnavailable { .. }
433 | Self::MissingCredentials { .. }
434 | Self::InvalidConfig { .. } => None,
435 }
436 }
437
438 pub fn config_key(&self) -> Option<&str> {
439 match self {
440 Self::InvalidConfig { context } => context.config_key.as_deref(),
441 Self::CapabilityUnavailable { .. }
442 | Self::MissingCredentials { .. }
443 | Self::BadRequest { .. }
444 | Self::Transport { .. }
445 | Self::Decode { .. }
446 | Self::Authentication { .. }
447 | Self::Timeout { .. }
448 | Self::RateLimited { .. }
449 | Self::Exchange { .. } => None,
450 }
451 }
452
453 pub fn message(&self) -> Option<&str> {
454 match self {
455 Self::InvalidConfig { context } => Some(context.message.as_str()),
456 Self::BadRequest { context } => Some(context.message.as_str()),
457 Self::Transport { context } => Some(context.message.as_str()),
458 Self::Decode { context } => Some(context.message.as_str()),
459 Self::Authentication { context } => Some(context.message.as_str()),
460 Self::Timeout { context } => Some(context.message.as_str()),
461 Self::RateLimited { context } => Some(context.message.as_str()),
462 Self::Exchange { context } => Some(context.message.as_str()),
463 Self::CapabilityUnavailable { .. } | Self::MissingCredentials { .. } => None,
464 }
465 }
466
467 pub fn status(&self) -> Option<u16> {
468 match self {
469 Self::Transport { context } => context.status,
470 Self::Exchange { context } => context.status,
471 Self::CapabilityUnavailable { .. }
472 | Self::MissingCredentials { .. }
473 | Self::InvalidConfig { .. }
474 | Self::BadRequest { .. }
475 | Self::Decode { .. }
476 | Self::Authentication { .. }
477 | Self::Timeout { .. }
478 | Self::RateLimited { .. } => None,
479 }
480 }
481
482 pub fn code(&self) -> Option<&str> {
483 match self {
484 Self::Authentication { context } => context.code.as_deref(),
485 Self::Exchange { context } => context.code.as_deref(),
486 Self::CapabilityUnavailable { .. }
487 | Self::MissingCredentials { .. }
488 | Self::InvalidConfig { .. }
489 | Self::BadRequest { .. }
490 | Self::Transport { .. }
491 | Self::Decode { .. }
492 | Self::Timeout { .. }
493 | Self::RateLimited { .. } => None,
494 }
495 }
496
497 pub fn retry_after(&self) -> Option<Duration> {
498 match self {
499 Self::RateLimited { context } => context.retry_after,
500 Self::CapabilityUnavailable { .. }
501 | Self::MissingCredentials { .. }
502 | Self::InvalidConfig { .. }
503 | Self::BadRequest { .. }
504 | Self::Transport { .. }
505 | Self::Decode { .. }
506 | Self::Authentication { .. }
507 | Self::Timeout { .. }
508 | Self::Exchange { .. } => None,
509 }
510 }
511
512 pub fn capability(&self) -> Option<Capability> {
513 match self {
514 Self::CapabilityUnavailable { capability, .. } => Some(*capability),
515 Self::MissingCredentials { .. }
516 | Self::InvalidConfig { .. }
517 | Self::BadRequest { .. }
518 | Self::Transport { .. }
519 | Self::Decode { .. }
520 | Self::Authentication { .. }
521 | Self::Timeout { .. }
522 | Self::RateLimited { .. }
523 | Self::Exchange { .. } => None,
524 }
525 }
526
527 pub fn capability_reason(&self) -> Option<CapabilityUnavailableReason> {
528 match self {
529 Self::CapabilityUnavailable { reason, .. } => Some(*reason),
530 Self::MissingCredentials { .. }
531 | Self::InvalidConfig { .. }
532 | Self::BadRequest { .. }
533 | Self::Transport { .. }
534 | Self::Decode { .. }
535 | Self::Authentication { .. }
536 | Self::Timeout { .. }
537 | Self::RateLimited { .. }
538 | Self::Exchange { .. } => None,
539 }
540 }
541}
542
543mod suffix {
544 use std::time::Duration;
545
546 use mkt_types::ExchangeId;
547
548 pub(super) fn exchange(exchange: Option<&ExchangeId>) -> String {
549 exchange
550 .map(|exchange| format!(" for {exchange}"))
551 .unwrap_or_default()
552 }
553
554 pub(super) fn operation(operation: Option<&str>) -> String {
555 operation
556 .map(|operation| format!(" during {operation}"))
557 .unwrap_or_default()
558 }
559
560 pub(super) fn config_key(config_key: Option<&str>) -> String {
561 config_key
562 .map(|config_key| format!(" at {config_key}"))
563 .unwrap_or_default()
564 }
565
566 pub(super) fn status(status: Option<u16>) -> String {
567 status
568 .map(|status| format!(" (status {status})"))
569 .unwrap_or_default()
570 }
571
572 pub(super) fn code(code: Option<&str>) -> String {
573 code.map(|code| format!(" [code={code}]"))
574 .unwrap_or_default()
575 }
576
577 pub(super) fn retry_after(retry_after: Option<Duration>) -> String {
578 retry_after
579 .map(|retry_after| format!(" [retry_after={}s]", retry_after.as_secs()))
580 .unwrap_or_default()
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use std::time::Duration;
587
588 use mkt_types::{ExchangeId, KnownExchange};
589
590 use super::{CapabilityUnavailableReason, Error, ErrorKind};
591 use crate::Capability;
592
593 #[test]
594 fn timeout_and_transport_stay_distinct_for_error_classification() {
595 let exchange = ExchangeId::from(KnownExchange::Binance);
596 let timeout: Error = Error::timeout("socket stalled waiting for the next frame")
597 .exchange(exchange.clone())
598 .operation("private_stream.read")
599 .into();
600 let transport: Error = Error::transport("connection reset by peer")
601 .exchange(exchange.clone())
602 .operation("private_stream.read")
603 .status(503_u16)
604 .into();
605
606 assert_eq!(timeout.kind(), ErrorKind::Timeout);
607 assert_eq!(transport.kind(), ErrorKind::Transport);
608 assert_eq!(timeout.exchange(), Some(&exchange));
609 assert_eq!(timeout.operation(), Some("private_stream.read"));
610 assert_eq!(transport.status(), Some(503));
611 assert_eq!(
612 timeout.message(),
613 Some("socket stalled waiting for the next frame")
614 );
615 }
616
617 #[test]
618 fn structured_fields_survive_without_reparsing_display_text() {
619 let exchange = ExchangeId::from(KnownExchange::Okx);
620 let err: Error = Error::exchange_error(exchange.clone(), "system busy")
621 .operation("spot.place_order")
622 .code("30084")
623 .status(429_u16)
624 .into();
625 let unsupported = Error::capability_unavailable(
626 exchange.clone(),
627 Capability::PublicStream,
628 CapabilityUnavailableReason::NotBound,
629 );
630
631 assert_eq!(err.kind(), ErrorKind::Exchange);
632 assert_eq!(err.exchange(), Some(&exchange));
633 assert_eq!(err.operation(), Some("spot.place_order"));
634 assert_eq!(err.code(), Some("30084"));
635 assert_eq!(err.status(), Some(429));
636 assert_eq!(err.message(), Some("system busy"));
637
638 assert_eq!(unsupported.kind(), ErrorKind::CapabilityUnavailable);
639 assert_eq!(unsupported.capability(), Some(Capability::PublicStream));
640 assert_eq!(
641 unsupported.capability_reason(),
642 Some(CapabilityUnavailableReason::NotBound)
643 );
644 let rate_limited: Error = Error::rate_limited("slow down")
645 .retry_after(Duration::from_secs(3))
646 .into();
647 assert_eq!(rate_limited.retry_after(), Some(Duration::from_secs(3)));
648 }
649
650 #[test]
651 fn invalid_config_is_not_a_request_error() {
652 let exchange = ExchangeId::from(KnownExchange::Binance);
653 let err: Error = Error::invalid_config("relative URL without a base")
654 .exchange(exchange.clone())
655 .config_key("config.rest")
656 .into();
657
658 assert_eq!(err.kind(), ErrorKind::InvalidConfig);
659 assert_eq!(err.exchange(), Some(&exchange));
660 assert_eq!(err.operation(), None);
661 assert_eq!(err.config_key(), Some("config.rest"));
662 assert_eq!(err.message(), Some("relative URL without a base"));
663 assert_eq!(err.status(), None);
664 }
665}