use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use deribit_http::HttpError;
use deribit_websocket::error::WebSocketError;
const DEFAULT_RATE_LIMIT_RETRY_MS: u64 = 1_000;
#[derive(Debug, Error, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind")]
pub enum AdapterError {
#[error("authentication failed: {reason:?}")]
Auth {
reason: AuthFailureReason,
},
#[error("rate limited; retry after {retry_after_ms} ms")]
RateLimited {
retry_after_ms: u64,
},
#[error("upstream error: {inner:?}")]
Upstream {
#[serde(rename = "source")]
inner: UpstreamErrorKind,
},
#[error("validation failed for `{field}`: {message}")]
Validation {
field: String,
message: String,
},
#[error("requested {requested} USD exceeds cap {cap} USD")]
SizeCapExceeded {
requested: f64,
cap: f64,
},
#[error("tool `{tool}` requires `{flag}`")]
NotEnabled {
tool: String,
flag: String,
},
#[error("internal error: {reason}")]
Internal {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AuthFailureReason {
MissingCredentials,
Unauthorized,
TokenExpiredAndRefreshFailed,
Suspended,
ScopeInsufficient {
needed: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "transport", rename_all = "snake_case")]
pub enum UpstreamErrorKind {
Api {
code: Option<i64>,
message: String,
},
Network {
message: String,
},
Http {
message: String,
},
Websocket {
message: String,
},
#[cfg(feature = "fix")]
Fix {
kind: FixErrorKind,
message: String,
},
}
#[cfg(feature = "fix")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FixErrorKind {
Disconnected,
SessionReject,
Config,
Other,
}
impl AdapterError {
#[cold]
#[inline(never)]
#[must_use]
pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
Self::Validation {
field: field.into(),
message: message.into(),
}
}
#[cold]
#[inline(never)]
#[must_use]
pub fn rate_limited(retry_after: Duration) -> Self {
let retry_after_ms = u64::try_from(retry_after.as_millis()).unwrap_or(u64::MAX);
Self::RateLimited { retry_after_ms }
}
}
impl From<HttpError> for AdapterError {
fn from(err: HttpError) -> Self {
match err {
HttpError::AuthenticationFailed(message) => AdapterError::Auth {
reason: classify_auth_failure_reason(&message),
},
HttpError::RateLimitExceeded => AdapterError::RateLimited {
retry_after_ms: DEFAULT_RATE_LIMIT_RETRY_MS,
},
HttpError::NetworkError(message) => AdapterError::Upstream {
inner: UpstreamErrorKind::Network { message },
},
HttpError::RequestFailed(message)
| HttpError::InvalidResponse(message)
| HttpError::ParseError(message) => {
if let Some((code, msg)) = parse_api_error(&message) {
return AdapterError::Upstream {
inner: UpstreamErrorKind::Api {
code: Some(code),
message: msg,
},
};
}
AdapterError::Upstream {
inner: UpstreamErrorKind::Http { message },
}
}
HttpError::ConfigError(_) => {
AdapterError::internal("upstream HTTP client misconfigured")
}
}
}
}
#[cfg(feature = "fix")]
impl From<deribit_fix::error::DeribitFixError> for AdapterError {
fn from(err: deribit_fix::error::DeribitFixError) -> Self {
use deribit_fix::error::DeribitFixError as Fx;
match err {
Fx::Authentication(message) => AdapterError::Auth {
reason: classify_auth_failure_reason(&message),
},
Fx::Connection(message) => AdapterError::Upstream {
inner: UpstreamErrorKind::Fix {
kind: FixErrorKind::Disconnected,
message,
},
},
Fx::Io(io) => AdapterError::Upstream {
inner: UpstreamErrorKind::Fix {
kind: FixErrorKind::Disconnected,
message: io.to_string(),
},
},
Fx::Session(message)
| Fx::Protocol(message)
| Fx::MessageParsing(message)
| Fx::MessageConstruction(message) => AdapterError::Upstream {
inner: UpstreamErrorKind::Fix {
kind: FixErrorKind::SessionReject,
message,
},
},
Fx::Config(message) => AdapterError::Upstream {
inner: UpstreamErrorKind::Fix {
kind: FixErrorKind::Config,
message,
},
},
Fx::Timeout(message) | Fx::Generic(message) => AdapterError::Upstream {
inner: UpstreamErrorKind::Fix {
kind: FixErrorKind::Other,
message,
},
},
Fx::Json(json) => AdapterError::Upstream {
inner: UpstreamErrorKind::Fix {
kind: FixErrorKind::Other,
message: json.to_string(),
},
},
Fx::Http(http) => AdapterError::Upstream {
inner: UpstreamErrorKind::Fix {
kind: FixErrorKind::Other,
message: http.to_string(),
},
},
}
}
}
#[cfg(test)]
#[cfg(feature = "fix")]
mod fix_wire_tests {
use super::*;
#[test]
fn fix_upstream_round_trips_through_serde() {
for (kind, expected_kind) in [
(FixErrorKind::Disconnected, "disconnected"),
(FixErrorKind::SessionReject, "session_reject"),
(FixErrorKind::Config, "config"),
(FixErrorKind::Other, "other"),
] {
let err = AdapterError::Upstream {
inner: UpstreamErrorKind::Fix {
kind,
message: "boom".to_string(),
},
};
let value = serde_json::to_value(&err).expect("ser");
assert_eq!(value["kind"], "Upstream", "outer tag");
assert_eq!(value["source"]["transport"], "fix");
assert_eq!(value["source"]["kind"], expected_kind);
assert_eq!(value["source"]["message"], "boom");
let round_trip: AdapterError = serde_json::from_value(value).expect("de");
assert_eq!(round_trip, err);
}
}
}
#[cold]
#[inline(never)]
fn parse_api_error(message: &str) -> Option<(i64, String)> {
let after = message.split_once("API error:")?.1.trim_start();
let (code_str, rest) = after.split_once(" - ").or_else(|| after.split_once('-'))?;
let code: i64 = code_str.trim().parse().ok()?;
Some((code, rest.trim().to_string()))
}
#[cold]
#[inline(never)]
fn classify_auth_failure_reason(message: &str) -> AuthFailureReason {
let lower = message.to_ascii_lowercase();
if lower.contains("10005") || lower.contains("suspend") {
return AuthFailureReason::Suspended;
}
if is_scope_insufficient(&lower) {
if let Some(needed) = extract_scope(&lower) {
return AuthFailureReason::ScopeInsufficient { needed };
}
}
if lower.contains("13004")
|| lower.contains("invalid_token")
|| lower.contains("token expired")
|| lower.contains("token has expired")
{
return AuthFailureReason::TokenExpiredAndRefreshFailed;
}
AuthFailureReason::Unauthorized
}
fn is_scope_insufficient(lower: &str) -> bool {
if lower.contains("13009") {
return true;
}
const PHRASES: &[&str] = &[
"scope insufficient",
"insufficient scope",
"unauthorized scope",
"scope required",
"missing scope",
];
PHRASES.iter().any(|p| lower.contains(p))
}
fn extract_scope(lower: &str) -> Option<String> {
for marker in ["scope ", "needs ", "requires "] {
if let Some(idx) = lower.find(marker) {
let rest = &lower[idx + marker.len()..];
let token = rest
.split(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ',' || c == '.')
.find(|s| !s.is_empty())?;
return Some(token.to_string());
}
}
None
}
impl From<WebSocketError> for AdapterError {
fn from(err: WebSocketError) -> Self {
match err {
WebSocketError::AuthenticationFailed(_) => AdapterError::Auth {
reason: AuthFailureReason::Unauthorized,
},
WebSocketError::ApiError { code, message, .. } => match code {
10009 | 10028 | 10040 | 10041 => AdapterError::RateLimited {
retry_after_ms: DEFAULT_RATE_LIMIT_RETRY_MS,
},
10000 | 10001 | 10002 | 13004 | 13005 | 13007 | 13008 | 13009 => {
AdapterError::Auth {
reason: AuthFailureReason::Unauthorized,
}
}
_ => AdapterError::Upstream {
inner: UpstreamErrorKind::Api {
code: Some(code),
message,
},
},
},
other => AdapterError::Upstream {
inner: UpstreamErrorKind::Websocket {
message: ws_short(&other.to_string()),
},
},
}
}
}
#[inline]
fn ws_short(s: &str) -> String {
const MAX: usize = 256;
if s.len() <= MAX {
s.to_string()
} else {
let mut end = MAX;
while !s.is_char_boundary(end) {
end -= 1;
}
let mut out = String::with_capacity(end + 1);
out.push_str(&s[..end]);
out.push('…');
out
}
}
impl From<serde_json::Error> for AdapterError {
fn from(_err: serde_json::Error) -> Self {
AdapterError::internal("upstream payload schema mismatch")
}
}
impl AdapterError {
#[cold]
#[inline(never)]
#[must_use]
pub fn not_enabled(tool: &'static str, flag: &'static str) -> Self {
Self::NotEnabled {
tool: tool.to_string(),
flag: flag.to_string(),
}
}
#[cold]
#[inline(never)]
#[must_use]
pub fn internal(reason: &'static str) -> Self {
Self::Internal {
reason: reason.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(err: &AdapterError) -> AdapterError {
let json = serde_json::to_string(err).expect("serialize");
serde_json::from_str(&json).expect("deserialize")
}
#[test]
fn auth_round_trip() {
for reason in [
AuthFailureReason::MissingCredentials,
AuthFailureReason::Unauthorized,
AuthFailureReason::TokenExpiredAndRefreshFailed,
AuthFailureReason::Suspended,
AuthFailureReason::ScopeInsufficient {
needed: "trade:read_write".to_string(),
},
] {
let err = AdapterError::Auth { reason };
assert_eq!(err, round_trip(&err));
}
}
#[test]
fn http_authentication_failed_classifies_suspended() {
let err: AdapterError =
HttpError::AuthenticationFailed("api error 10005: account suspended".into()).into();
assert_eq!(
err,
AdapterError::Auth {
reason: AuthFailureReason::Suspended
}
);
}
#[test]
fn http_authentication_failed_classifies_scope_insufficient() {
let err: AdapterError =
HttpError::AuthenticationFailed("api error 13009: scope trade:read_write".into())
.into();
assert_eq!(
err,
AdapterError::Auth {
reason: AuthFailureReason::ScopeInsufficient {
needed: "trade:read_write".to_string(),
}
}
);
}
#[test]
fn auth_failure_with_word_scope_in_unrelated_phrase_is_unauthorized() {
let err: AdapterError =
HttpError::AuthenticationFailed("error 10004: out of scope of current session".into())
.into();
assert_eq!(
err,
AdapterError::Auth {
reason: AuthFailureReason::Unauthorized
}
);
}
#[test]
fn auth_failure_with_13009_but_no_scope_name_falls_back_to_unauthorized() {
let err: AdapterError =
HttpError::AuthenticationFailed("api error 13009: unspecified".into()).into();
assert_eq!(
err,
AdapterError::Auth {
reason: AuthFailureReason::Unauthorized
}
);
}
#[test]
fn http_authentication_failed_classifies_token_expired() {
let err: AdapterError =
HttpError::AuthenticationFailed("api error 13004: invalid_token".into()).into();
assert_eq!(
err,
AdapterError::Auth {
reason: AuthFailureReason::TokenExpiredAndRefreshFailed,
}
);
}
#[test]
fn rate_limited_round_trip() {
let err = AdapterError::RateLimited {
retry_after_ms: 2_000,
};
assert_eq!(err, round_trip(&err));
}
#[test]
fn upstream_api_round_trip() {
let err = AdapterError::Upstream {
inner: UpstreamErrorKind::Api {
code: Some(10000),
message: "boom".to_string(),
},
};
assert_eq!(err, round_trip(&err));
}
#[test]
fn upstream_network_round_trip() {
let err = AdapterError::Upstream {
inner: UpstreamErrorKind::Network {
message: "dns".to_string(),
},
};
assert_eq!(err, round_trip(&err));
}
#[test]
fn upstream_websocket_round_trip() {
let err = AdapterError::Upstream {
inner: UpstreamErrorKind::Websocket {
message: "closed".to_string(),
},
};
assert_eq!(err, round_trip(&err));
}
#[test]
fn validation_round_trip() {
let err = AdapterError::validation("instrument_name", "must be non-empty");
assert_eq!(err, round_trip(&err));
}
#[test]
fn size_cap_exceeded_round_trip() {
let err = AdapterError::SizeCapExceeded {
requested: 25_000.0,
cap: 10_000.0,
};
assert_eq!(err, round_trip(&err));
}
#[test]
fn not_enabled_round_trip() {
let err = AdapterError::not_enabled("place_order", "--allow-trading");
assert_eq!(err, round_trip(&err));
}
#[test]
fn internal_round_trip() {
let err = AdapterError::internal("upstream payload schema mismatch");
assert_eq!(err, round_trip(&err));
}
#[test]
fn http_authentication_failed_maps_to_auth_unauthorized() {
let err: AdapterError = HttpError::AuthenticationFailed("bad creds".into()).into();
assert_eq!(
err,
AdapterError::Auth {
reason: AuthFailureReason::Unauthorized
}
);
}
#[test]
fn http_rate_limit_exceeded_maps_to_rate_limited() {
let err: AdapterError = HttpError::RateLimitExceeded.into();
assert!(matches!(err, AdapterError::RateLimited { .. }));
}
#[test]
fn http_network_error_maps_to_upstream_network() {
let err: AdapterError = HttpError::NetworkError("connect".into()).into();
assert!(matches!(
err,
AdapterError::Upstream {
inner: UpstreamErrorKind::Network { .. }
}
));
}
#[test]
fn http_request_failed_maps_to_upstream_http() {
let err: AdapterError = HttpError::RequestFailed("500".into()).into();
assert!(matches!(
err,
AdapterError::Upstream {
inner: UpstreamErrorKind::Http { .. }
}
));
}
#[test]
fn http_config_error_maps_to_internal() {
let err: AdapterError = HttpError::ConfigError("bad url".into()).into();
assert!(matches!(err, AdapterError::Internal { .. }));
}
#[test]
fn ws_authentication_failed_maps_to_auth_unauthorized() {
let err: AdapterError = WebSocketError::AuthenticationFailed("bad".into()).into();
assert_eq!(
err,
AdapterError::Auth {
reason: AuthFailureReason::Unauthorized
}
);
}
#[test]
fn ws_api_error_rate_limit_code_maps_to_rate_limited() {
let err: AdapterError = WebSocketError::ApiError {
code: 10028,
message: "too many".into(),
method: None,
params: None,
raw_response: None,
}
.into();
assert!(matches!(err, AdapterError::RateLimited { .. }));
}
#[test]
fn ws_api_error_other_code_maps_to_upstream_api() {
let err: AdapterError = WebSocketError::ApiError {
code: 11099,
message: "boom".into(),
method: None,
params: None,
raw_response: None,
}
.into();
match err {
AdapterError::Upstream {
inner: UpstreamErrorKind::Api { code, .. },
} => {
assert_eq!(code, Some(11099));
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn serde_json_error_maps_to_internal() {
let parse: Result<i32, _> = serde_json::from_str("not json");
let err: AdapterError = parse.unwrap_err().into();
assert!(matches!(err, AdapterError::Internal { .. }));
}
}