use std::time::Duration;
use polyoxide_core::{truncate_for_log, ApiError};
use polyoxide_venue::{Class, Classify};
use thiserror::Error;
use crate::shared::error::{decode, venue_class, Decoded};
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum SpotError {
#[error(transparent)]
Api(ApiError),
#[error("binance answered {status}: {code} {msg}")]
Venue {
status: u16,
code: i64,
msg: String,
},
#[error("binance rate limit (429), retry after {retry_after:?}")]
RateLimited {
retry_after: Option<Duration>,
},
#[error("binance has banned this IP (418), retry after {retry_after:?}")]
IpBanned {
retry_after: Option<Duration>,
},
#[error("binance does not serve this location (451): {msg}")]
RegionBlocked {
msg: String,
},
#[error("binance's firewall refused the request (403): {msg}")]
Forbidden {
msg: String,
},
}
impl SpotError {
pub fn code(&self) -> Option<i64> {
match self {
Self::Venue { code, .. } => Some(*code),
_ => None,
}
}
}
impl From<ApiError> for SpotError {
fn from(err: ApiError) -> Self {
match decode(err) {
Decoded::Api(err) => Self::Api(err),
Decoded::Venue { status, code, msg } => Self::Venue { status, code, msg },
Decoded::RateLimited { retry_after } => Self::RateLimited { retry_after },
Decoded::IpBanned { retry_after, body } => {
tracing::warn!("418: IP banned: {}", truncate_for_log(&body));
Self::IpBanned { retry_after }
}
Decoded::RegionBlocked { msg } => Self::RegionBlocked { msg },
Decoded::Forbidden { msg } => Self::Forbidden { msg },
}
}
}
impl Classify for SpotError {
fn class(&self) -> Class {
match self {
Self::Api(err) => err.class(),
Self::Venue { status, code, .. } => venue_class(*status, *code),
Self::RateLimited { retry_after } => {
Class::RateLimited { retry_after: None }.with_retry_after(*retry_after)
}
Self::IpBanned { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => {
Class::Restricted
}
}
}
fn is_fault(&self) -> bool {
match self {
Self::Api(err) => err.is_fault(),
Self::RegionBlocked { .. } => false,
Self::Venue { status, .. } => *status != 451,
Self::RateLimited { .. } | Self::IpBanned { .. } | Self::Forbidden { .. } => true,
}
}
fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api(err) => Classify::retry_after(err),
Self::RateLimited { retry_after } | Self::IpBanned { retry_after } => {
retry_after.filter(|wait| !wait.is_zero())
}
Self::Venue { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use polyoxide_core::ErrorResponse;
use polyoxide_test_support::fixtures;
use super::*;
use crate::spot::weight::MAX_COOLDOWN;
fn response(status: u16, retry_after: Option<&str>, body: &str) -> ApiError {
let mut headers = polyoxide_core::http::header::HeaderMap::new();
if let Some(value) = retry_after {
headers.insert(
polyoxide_core::http::header::RETRY_AFTER,
value.parse().unwrap(),
);
}
ErrorResponse::new(
polyoxide_core::http::StatusCode::from_u16(status).unwrap(),
headers,
body,
)
.into()
}
fn parts(status: u16, retry_after: Option<&str>, body: &str) -> SpotError {
SpotError::from(response(status, retry_after, body))
}
#[test]
fn the_captured_invalid_symbol_body_is_a_venue_error_with_its_code() {
let body = fixtures!("spot/rest").text("error_invalid_symbol");
let err = parts(400, None, &body);
assert!(
matches!(&err, SpotError::Venue { status: 400, code: -1121, msg } if msg == "Invalid symbol."),
"{err:?}"
);
assert_eq!(err.code(), Some(-1121));
assert!(!err.is_retriable());
}
#[test]
fn statuses_that_decide_the_caller_s_next_step_win_over_the_body() {
let body = r#"{"code":-1003,"msg":"Too many requests."}"#;
let limited = parts(429, Some("7"), body);
assert!(matches!(limited, SpotError::RateLimited { .. }));
assert_eq!(limited.retry_after(), Some(Duration::from_secs(7)));
assert!(limited.is_retriable());
let banned = parts(418, Some("120"), body);
assert!(matches!(banned, SpotError::IpBanned { .. }));
assert_eq!(banned.retry_after(), Some(Duration::from_secs(120)));
assert!(!banned.is_retriable());
let region = parts(451, None, body);
assert!(matches!(®ion, SpotError::RegionBlocked { msg } if msg.contains("-1003")));
assert!(!region.is_retriable());
let firewall = parts(403, None, "<html>denied</html>");
assert!(matches!(&firewall, SpotError::Forbidden { msg } if msg == "<html>denied</html>"));
assert!(!firewall.is_retriable());
let firewall_json = parts(403, None, body);
assert!(matches!(firewall_json, SpotError::Forbidden { .. }));
}
#[test]
fn a_body_in_another_shape_is_left_to_core() {
let err = parts(502, None, "<html>bad gateway</html>");
assert!(matches!(
&err,
SpotError::Api(ApiError::Response(r)) if r.status.as_u16() == 502
));
assert!(err.is_retriable());
assert_eq!(err.code(), None);
}
#[test]
fn a_core_error_that_is_not_a_response_stays_core_s() {
let err = SpotError::from(ApiError::Validation("no symbol".into()));
assert!(
matches!(err, SpotError::Api(ApiError::Validation(_))),
"{err:?}"
);
assert_eq!(err.class(), Class::InvalidRequest);
}
#[test]
fn every_kept_body_is_clipped() {
let long = "x".repeat(10_000);
let venue = format!(r#"{{"code":-1121,"msg":"{long}"}}"#);
let kept = |err: SpotError| match err {
SpotError::Venue { msg, .. }
| SpotError::RegionBlocked { msg }
| SpotError::Forbidden { msg } => vec![msg],
SpotError::Api(ApiError::Response(r)) => vec![r.message, r.body],
other => panic!("unexpected {other:?}"),
};
for err in [
parts(400, None, &venue),
parts(451, None, &long),
parts(403, None, &long),
parts(502, None, &long),
] {
for msg in kept(err) {
assert!(
msg.len() <= 512 + "... [truncated]".len(),
"kept {} bytes",
msg.len()
);
}
}
}
#[test]
fn a_core_response_keeps_binance_s_clamped_wait() {
let err = parts(503, Some("604800"), "<html>down</html>");
match &err {
SpotError::Api(ApiError::Response(r)) => {
assert_eq!(r.retry_after, Some(MAX_COOLDOWN));
}
other => panic!("unexpected {other:?}"),
}
assert_eq!(Classify::retry_after(&err), Some(MAX_COOLDOWN));
}
#[test]
fn every_variant_classifies() {
let body = r#"{"code":-1121,"msg":"Invalid symbol."}"#;
let coded = |status, retry_after| parts(status, retry_after, body);
let code = |c: &str| Some(std::sync::Arc::from(c));
let week = Some(MAX_COOLDOWN);
let rows = [
(
coded(502, None),
Class::Unavailable {
code: code("-1121"),
},
true,
None,
),
(
coded(400, None),
Class::VenueRefusal {
code: code("-1121"),
},
true,
None,
),
(
coded(408, None),
Class::Unavailable {
code: code("-1121"),
},
true,
None,
),
(
coded(429, Some("7")),
Class::RateLimited {
retry_after: Some(Duration::from_secs(7)),
},
true,
Some(Duration::from_secs(7)),
),
(
coded(429, None),
Class::RateLimited { retry_after: None },
true,
None,
),
(coded(418, Some("604800")), Class::Restricted, true, week),
(coded(451, None), Class::Restricted, false, None),
(
SpotError::Venue {
status: 451,
code: -1,
msg: "restricted".into(),
},
Class::Restricted,
false,
None,
),
(coded(403, None), Class::Restricted, true, None),
];
for (err, class, fault, wait) in rows {
assert_eq!(err.class(), class, "{err:?}");
assert_eq!(err.is_fault(), fault, "{err:?}");
assert_eq!(Classify::retry_after(&err), wait, "{err:?}");
assert_eq!(
Classify::is_retriable(&err),
class.is_retriable(),
"{err:?}"
);
}
}
#[test]
fn spot_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<SpotError>();
}
}