Skip to main content

mkt_core/
error.rs

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}