1use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use deribit_http::HttpError;
18use deribit_websocket::error::WebSocketError;
19
20const DEFAULT_RATE_LIMIT_RETRY_MS: u64 = 1_000;
24
25#[derive(Debug, Error, Serialize, Deserialize, PartialEq)]
36#[serde(tag = "kind")]
37pub enum AdapterError {
38 #[error("authentication failed: {reason:?}")]
40 Auth {
41 reason: AuthFailureReason,
43 },
44
45 #[error("rate limited; retry after {retry_after_ms} ms")]
48 RateLimited {
49 retry_after_ms: u64,
52 },
53
54 #[error("upstream error: {inner:?}")]
57 Upstream {
58 #[serde(rename = "source")]
60 inner: UpstreamErrorKind,
61 },
62
63 #[error("validation failed for `{field}`: {message}")]
66 Validation {
67 field: String,
69 message: String,
71 },
72
73 #[error("requested {requested} USD exceeds cap {cap} USD")]
76 SizeCapExceeded {
77 requested: f64,
79 cap: f64,
81 },
82
83 #[error("tool `{tool}` requires `{flag}`")]
86 NotEnabled {
87 tool: String,
90 flag: String,
92 },
93
94 #[error("internal error: {reason}")]
99 Internal {
100 reason: String,
104 },
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "kind", rename_all = "snake_case")]
114pub enum AuthFailureReason {
115 MissingCredentials,
119 Unauthorized,
122 TokenExpiredAndRefreshFailed,
126 Suspended,
130 ScopeInsufficient {
136 needed: String,
139 },
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(tag = "transport", rename_all = "snake_case")]
145pub enum UpstreamErrorKind {
146 Api {
149 code: Option<i64>,
151 message: String,
153 },
154 Network {
156 message: String,
158 },
159 Http {
162 message: String,
164 },
165 Websocket {
167 message: String,
169 },
170 #[cfg(feature = "fix")]
178 Fix {
179 kind: FixErrorKind,
181 message: String,
183 },
184}
185
186#[cfg(feature = "fix")]
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum FixErrorKind {
192 Disconnected,
194 SessionReject,
200 Config,
203 Other,
207}
208
209impl AdapterError {
210 #[cold]
212 #[inline(never)]
213 #[must_use]
214 pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
215 Self::Validation {
216 field: field.into(),
217 message: message.into(),
218 }
219 }
220
221 #[cold]
224 #[inline(never)]
225 #[must_use]
226 pub fn rate_limited(retry_after: Duration) -> Self {
227 let retry_after_ms = u64::try_from(retry_after.as_millis()).unwrap_or(u64::MAX);
228 Self::RateLimited { retry_after_ms }
229 }
230}
231
232impl From<HttpError> for AdapterError {
233 fn from(err: HttpError) -> Self {
234 match err {
235 HttpError::AuthenticationFailed(message) => AdapterError::Auth {
236 reason: classify_auth_failure_reason(&message),
237 },
238 HttpError::RateLimitExceeded => AdapterError::RateLimited {
239 retry_after_ms: DEFAULT_RATE_LIMIT_RETRY_MS,
240 },
241 HttpError::NetworkError(message) => AdapterError::Upstream {
242 inner: UpstreamErrorKind::Network { message },
243 },
244 HttpError::RequestFailed(message)
245 | HttpError::InvalidResponse(message)
246 | HttpError::ParseError(message) => {
247 if let Some((code, msg)) = parse_api_error(&message) {
256 return AdapterError::Upstream {
257 inner: UpstreamErrorKind::Api {
258 code: Some(code),
259 message: msg,
260 },
261 };
262 }
263 AdapterError::Upstream {
264 inner: UpstreamErrorKind::Http { message },
265 }
266 }
267 HttpError::ConfigError(_) => {
268 AdapterError::internal("upstream HTTP client misconfigured")
269 }
270 }
271 }
272}
273
274#[cfg(feature = "fix")]
275impl From<deribit_fix::error::DeribitFixError> for AdapterError {
276 fn from(err: deribit_fix::error::DeribitFixError) -> Self {
277 use deribit_fix::error::DeribitFixError as Fx;
278 match err {
282 Fx::Authentication(message) => AdapterError::Auth {
283 reason: classify_auth_failure_reason(&message),
284 },
285 Fx::Connection(message) => AdapterError::Upstream {
286 inner: UpstreamErrorKind::Fix {
287 kind: FixErrorKind::Disconnected,
288 message,
289 },
290 },
291 Fx::Io(io) => AdapterError::Upstream {
292 inner: UpstreamErrorKind::Fix {
293 kind: FixErrorKind::Disconnected,
294 message: io.to_string(),
295 },
296 },
297 Fx::Session(message)
298 | Fx::Protocol(message)
299 | Fx::MessageParsing(message)
300 | Fx::MessageConstruction(message) => AdapterError::Upstream {
301 inner: UpstreamErrorKind::Fix {
302 kind: FixErrorKind::SessionReject,
303 message,
304 },
305 },
306 Fx::Config(message) => AdapterError::Upstream {
307 inner: UpstreamErrorKind::Fix {
308 kind: FixErrorKind::Config,
309 message,
310 },
311 },
312 Fx::Timeout(message) | Fx::Generic(message) => AdapterError::Upstream {
313 inner: UpstreamErrorKind::Fix {
314 kind: FixErrorKind::Other,
315 message,
316 },
317 },
318 Fx::Json(json) => AdapterError::Upstream {
319 inner: UpstreamErrorKind::Fix {
320 kind: FixErrorKind::Other,
321 message: json.to_string(),
322 },
323 },
324 Fx::Http(http) => AdapterError::Upstream {
325 inner: UpstreamErrorKind::Fix {
326 kind: FixErrorKind::Other,
327 message: http.to_string(),
328 },
329 },
330 }
331 }
332}
333
334#[cfg(test)]
335#[cfg(feature = "fix")]
336mod fix_wire_tests {
337 use super::*;
338
339 #[test]
342 fn fix_upstream_round_trips_through_serde() {
343 for (kind, expected_kind) in [
344 (FixErrorKind::Disconnected, "disconnected"),
345 (FixErrorKind::SessionReject, "session_reject"),
346 (FixErrorKind::Config, "config"),
347 (FixErrorKind::Other, "other"),
348 ] {
349 let err = AdapterError::Upstream {
350 inner: UpstreamErrorKind::Fix {
351 kind,
352 message: "boom".to_string(),
353 },
354 };
355 let value = serde_json::to_value(&err).expect("ser");
356 assert_eq!(value["kind"], "Upstream", "outer tag");
360 assert_eq!(value["source"]["transport"], "fix");
361 assert_eq!(value["source"]["kind"], expected_kind);
362 assert_eq!(value["source"]["message"], "boom");
363 let round_trip: AdapterError = serde_json::from_value(value).expect("de");
364 assert_eq!(round_trip, err);
365 }
366 }
367}
368
369#[cold]
377#[inline(never)]
378fn parse_api_error(message: &str) -> Option<(i64, String)> {
379 let after = message.split_once("API error:")?.1.trim_start();
380 let (code_str, rest) = after.split_once(" - ").or_else(|| after.split_once('-'))?;
381 let code: i64 = code_str.trim().parse().ok()?;
382 Some((code, rest.trim().to_string()))
383}
384
385#[cold]
403#[inline(never)]
404fn classify_auth_failure_reason(message: &str) -> AuthFailureReason {
405 let lower = message.to_ascii_lowercase();
406
407 if lower.contains("10005") || lower.contains("suspend") {
408 return AuthFailureReason::Suspended;
409 }
410 if is_scope_insufficient(&lower) {
411 if let Some(needed) = extract_scope(&lower) {
417 return AuthFailureReason::ScopeInsufficient { needed };
418 }
419 }
420 if lower.contains("13004")
421 || lower.contains("invalid_token")
422 || lower.contains("token expired")
423 || lower.contains("token has expired")
424 {
425 return AuthFailureReason::TokenExpiredAndRefreshFailed;
426 }
427
428 AuthFailureReason::Unauthorized
429}
430
431fn is_scope_insufficient(lower: &str) -> bool {
434 if lower.contains("13009") {
435 return true;
436 }
437 const PHRASES: &[&str] = &[
438 "scope insufficient",
439 "insufficient scope",
440 "unauthorized scope",
441 "scope required",
442 "missing scope",
443 ];
444 PHRASES.iter().any(|p| lower.contains(p))
445}
446
447fn extract_scope(lower: &str) -> Option<String> {
451 for marker in ["scope ", "needs ", "requires "] {
452 if let Some(idx) = lower.find(marker) {
453 let rest = &lower[idx + marker.len()..];
454 let token = rest
455 .split(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ',' || c == '.')
456 .find(|s| !s.is_empty())?;
457 return Some(token.to_string());
458 }
459 }
460 None
461}
462
463impl From<WebSocketError> for AdapterError {
464 fn from(err: WebSocketError) -> Self {
465 match err {
466 WebSocketError::AuthenticationFailed(_) => AdapterError::Auth {
467 reason: AuthFailureReason::Unauthorized,
468 },
469 WebSocketError::ApiError { code, message, .. } => match code {
470 10009 | 10028 | 10040 | 10041 => AdapterError::RateLimited {
471 retry_after_ms: DEFAULT_RATE_LIMIT_RETRY_MS,
472 },
473 10000 | 10001 | 10002 | 13004 | 13005 | 13007 | 13008 | 13009 => {
474 AdapterError::Auth {
475 reason: AuthFailureReason::Unauthorized,
476 }
477 }
478 _ => AdapterError::Upstream {
479 inner: UpstreamErrorKind::Api {
480 code: Some(code),
481 message,
482 },
483 },
484 },
485 other => AdapterError::Upstream {
490 inner: UpstreamErrorKind::Websocket {
491 message: ws_short(&other.to_string()),
492 },
493 },
494 }
495 }
496}
497
498#[inline]
500fn ws_short(s: &str) -> String {
501 const MAX: usize = 256;
502 if s.len() <= MAX {
503 s.to_string()
504 } else {
505 let mut end = MAX;
506 while !s.is_char_boundary(end) {
507 end -= 1;
508 }
509 let mut out = String::with_capacity(end + 1);
510 out.push_str(&s[..end]);
511 out.push('…');
512 out
513 }
514}
515
516impl From<serde_json::Error> for AdapterError {
517 fn from(_err: serde_json::Error) -> Self {
518 AdapterError::internal("upstream payload schema mismatch")
519 }
520}
521
522impl AdapterError {
523 #[cold]
525 #[inline(never)]
526 #[must_use]
527 pub fn not_enabled(tool: &'static str, flag: &'static str) -> Self {
528 Self::NotEnabled {
529 tool: tool.to_string(),
530 flag: flag.to_string(),
531 }
532 }
533
534 #[cold]
536 #[inline(never)]
537 #[must_use]
538 pub fn internal(reason: &'static str) -> Self {
539 Self::Internal {
540 reason: reason.to_string(),
541 }
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 fn round_trip(err: &AdapterError) -> AdapterError {
550 let json = serde_json::to_string(err).expect("serialize");
551 serde_json::from_str(&json).expect("deserialize")
552 }
553
554 #[test]
555 fn auth_round_trip() {
556 for reason in [
557 AuthFailureReason::MissingCredentials,
558 AuthFailureReason::Unauthorized,
559 AuthFailureReason::TokenExpiredAndRefreshFailed,
560 AuthFailureReason::Suspended,
561 AuthFailureReason::ScopeInsufficient {
562 needed: "trade:read_write".to_string(),
563 },
564 ] {
565 let err = AdapterError::Auth { reason };
566 assert_eq!(err, round_trip(&err));
567 }
568 }
569
570 #[test]
571 fn http_authentication_failed_classifies_suspended() {
572 let err: AdapterError =
573 HttpError::AuthenticationFailed("api error 10005: account suspended".into()).into();
574 assert_eq!(
575 err,
576 AdapterError::Auth {
577 reason: AuthFailureReason::Suspended
578 }
579 );
580 }
581
582 #[test]
583 fn http_authentication_failed_classifies_scope_insufficient() {
584 let err: AdapterError =
585 HttpError::AuthenticationFailed("api error 13009: scope trade:read_write".into())
586 .into();
587 assert_eq!(
588 err,
589 AdapterError::Auth {
590 reason: AuthFailureReason::ScopeInsufficient {
591 needed: "trade:read_write".to_string(),
592 }
593 }
594 );
595 }
596
597 #[test]
598 fn auth_failure_with_word_scope_in_unrelated_phrase_is_unauthorized() {
599 let err: AdapterError =
604 HttpError::AuthenticationFailed("error 10004: out of scope of current session".into())
605 .into();
606 assert_eq!(
607 err,
608 AdapterError::Auth {
609 reason: AuthFailureReason::Unauthorized
610 }
611 );
612 }
613
614 #[test]
615 fn auth_failure_with_13009_but_no_scope_name_falls_back_to_unauthorized() {
616 let err: AdapterError =
621 HttpError::AuthenticationFailed("api error 13009: unspecified".into()).into();
622 assert_eq!(
623 err,
624 AdapterError::Auth {
625 reason: AuthFailureReason::Unauthorized
626 }
627 );
628 }
629
630 #[test]
631 fn http_authentication_failed_classifies_token_expired() {
632 let err: AdapterError =
633 HttpError::AuthenticationFailed("api error 13004: invalid_token".into()).into();
634 assert_eq!(
635 err,
636 AdapterError::Auth {
637 reason: AuthFailureReason::TokenExpiredAndRefreshFailed,
638 }
639 );
640 }
641
642 #[test]
643 fn rate_limited_round_trip() {
644 let err = AdapterError::RateLimited {
645 retry_after_ms: 2_000,
646 };
647 assert_eq!(err, round_trip(&err));
648 }
649
650 #[test]
651 fn upstream_api_round_trip() {
652 let err = AdapterError::Upstream {
653 inner: UpstreamErrorKind::Api {
654 code: Some(10000),
655 message: "boom".to_string(),
656 },
657 };
658 assert_eq!(err, round_trip(&err));
659 }
660
661 #[test]
662 fn upstream_network_round_trip() {
663 let err = AdapterError::Upstream {
664 inner: UpstreamErrorKind::Network {
665 message: "dns".to_string(),
666 },
667 };
668 assert_eq!(err, round_trip(&err));
669 }
670
671 #[test]
672 fn upstream_websocket_round_trip() {
673 let err = AdapterError::Upstream {
674 inner: UpstreamErrorKind::Websocket {
675 message: "closed".to_string(),
676 },
677 };
678 assert_eq!(err, round_trip(&err));
679 }
680
681 #[test]
682 fn validation_round_trip() {
683 let err = AdapterError::validation("instrument_name", "must be non-empty");
684 assert_eq!(err, round_trip(&err));
685 }
686
687 #[test]
688 fn size_cap_exceeded_round_trip() {
689 let err = AdapterError::SizeCapExceeded {
690 requested: 25_000.0,
691 cap: 10_000.0,
692 };
693 assert_eq!(err, round_trip(&err));
694 }
695
696 #[test]
697 fn not_enabled_round_trip() {
698 let err = AdapterError::not_enabled("place_order", "--allow-trading");
699 assert_eq!(err, round_trip(&err));
700 }
701
702 #[test]
703 fn internal_round_trip() {
704 let err = AdapterError::internal("upstream payload schema mismatch");
705 assert_eq!(err, round_trip(&err));
706 }
707
708 #[test]
709 fn http_authentication_failed_maps_to_auth_unauthorized() {
710 let err: AdapterError = HttpError::AuthenticationFailed("bad creds".into()).into();
713 assert_eq!(
714 err,
715 AdapterError::Auth {
716 reason: AuthFailureReason::Unauthorized
717 }
718 );
719 }
720
721 #[test]
722 fn http_rate_limit_exceeded_maps_to_rate_limited() {
723 let err: AdapterError = HttpError::RateLimitExceeded.into();
724 assert!(matches!(err, AdapterError::RateLimited { .. }));
725 }
726
727 #[test]
728 fn http_network_error_maps_to_upstream_network() {
729 let err: AdapterError = HttpError::NetworkError("connect".into()).into();
730 assert!(matches!(
731 err,
732 AdapterError::Upstream {
733 inner: UpstreamErrorKind::Network { .. }
734 }
735 ));
736 }
737
738 #[test]
739 fn http_request_failed_maps_to_upstream_http() {
740 let err: AdapterError = HttpError::RequestFailed("500".into()).into();
741 assert!(matches!(
742 err,
743 AdapterError::Upstream {
744 inner: UpstreamErrorKind::Http { .. }
745 }
746 ));
747 }
748
749 #[test]
750 fn http_config_error_maps_to_internal() {
751 let err: AdapterError = HttpError::ConfigError("bad url".into()).into();
752 assert!(matches!(err, AdapterError::Internal { .. }));
753 }
754
755 #[test]
756 fn ws_authentication_failed_maps_to_auth_unauthorized() {
757 let err: AdapterError = WebSocketError::AuthenticationFailed("bad".into()).into();
758 assert_eq!(
759 err,
760 AdapterError::Auth {
761 reason: AuthFailureReason::Unauthorized
762 }
763 );
764 }
765
766 #[test]
767 fn ws_api_error_rate_limit_code_maps_to_rate_limited() {
768 let err: AdapterError = WebSocketError::ApiError {
769 code: 10028,
770 message: "too many".into(),
771 method: None,
772 params: None,
773 raw_response: None,
774 }
775 .into();
776 assert!(matches!(err, AdapterError::RateLimited { .. }));
777 }
778
779 #[test]
780 fn ws_api_error_other_code_maps_to_upstream_api() {
781 let err: AdapterError = WebSocketError::ApiError {
782 code: 11099,
783 message: "boom".into(),
784 method: None,
785 params: None,
786 raw_response: None,
787 }
788 .into();
789 match err {
790 AdapterError::Upstream {
791 inner: UpstreamErrorKind::Api { code, .. },
792 } => {
793 assert_eq!(code, Some(11099));
794 }
795 other => panic!("unexpected: {other:?}"),
796 }
797 }
798
799 #[test]
800 fn serde_json_error_maps_to_internal() {
801 let parse: Result<i32, _> = serde_json::from_str("not json");
802 let err: AdapterError = parse.unwrap_err().into();
803 assert!(matches!(err, AdapterError::Internal { .. }));
804 }
805}