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};
#[cfg(test)]
use crate::shared::error::retry_after_secs;
#[cfg(test)]
use crate::usdm::weight::MAX_COOLDOWN;
#[cfg(test)]
use polyoxide_core::ErrorResponse;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum UsdmError {
#[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 UsdmError {
pub fn code(&self) -> Option<i64> {
match self {
Self::Venue { code, .. } => Some(*code),
_ => None,
}
}
}
impl From<ApiError> for UsdmError {
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 UsdmError {
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 super::*;
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) -> UsdmError {
UsdmError::from(response(status, retry_after, body))
}
#[test]
fn an_unknown_symbol_is_a_venue_error_with_its_code() {
let err = parts(400, None, r#"{"code":-1121,"msg":"Invalid symbol."}"#);
assert!(
matches!(&err, UsdmError::Venue { status: 400, code: -1121, msg } if msg == "Invalid symbol.")
);
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, UsdmError::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, UsdmError::IpBanned { .. }));
assert_eq!(banned.retry_after(), Some(Duration::from_secs(120)));
assert!(!banned.is_retriable());
let region = parts(451, None, body);
assert!(matches!(®ion, UsdmError::RegionBlocked { msg } if msg.contains("-1003")));
assert!(!region.is_retriable());
let firewall = parts(403, None, "<html>denied</html>");
assert!(matches!(&firewall, UsdmError::Forbidden { msg } if msg == "<html>denied</html>"));
assert!(!firewall.is_retriable());
let firewall_json = parts(403, None, body);
assert!(matches!(firewall_json, UsdmError::Forbidden { .. }));
}
#[test]
fn a_5xx_venue_error_is_retriable_and_a_4xx_is_not() {
let body = r#"{"code":-1001,"msg":"Internal error; unable to process your request."}"#;
assert!(parts(503, None, body).is_retriable());
assert!(parts(500, None, body).is_retriable());
assert!(!parts(499, None, body).is_retriable());
assert!(!parts(400, None, body).is_retriable());
}
#[test]
fn a_body_in_another_shape_is_left_to_core() {
let err = parts(502, None, "<html>bad gateway</html>");
assert!(matches!(
&err,
UsdmError::Api(ApiError::Response(r)) if r.status.as_u16() == 502
));
assert!(err.is_retriable());
assert_eq!(err.code(), None);
}
#[test]
fn every_kept_body_is_clipped() {
let long = "x".repeat(10_000);
let venue = format!(r#"{{"code":-1121,"msg":"{long}"}}"#);
let kept = |err: UsdmError| match err {
UsdmError::Venue { msg, .. }
| UsdmError::RegionBlocked { msg }
| UsdmError::Forbidden { msg } => vec![msg],
UsdmError::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 core_reads_a_long_json_body_before_it_is_clipped() {
let body = format!(r#"{{"message":"short","pad":"{}"}}"#, "p".repeat(600));
let err = parts(502, None, &body);
assert!(
matches!(&err, UsdmError::Api(ApiError::Response(r)) if r.status.as_u16() == 502 && r.message == "short"),
"{err:?}"
);
}
#[test]
fn venue_and_api_errors_agree_on_retriability() {
let body = r#"{"code":-1,"msg":"x"}"#;
for status in [400u16, 401, 404, 408, 409, 425, 500, 502, 503, 504] {
let venue = parts(status, None, body);
let api = response(status, None, "x");
assert_eq!(
venue.is_retriable(),
Classify::is_retriable(&api),
"status {status}"
);
}
}
#[test]
fn a_core_response_keeps_binance_s_clamped_wait() {
let err = parts(503, Some("604800"), "<html>down</html>");
match &err {
UsdmError::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 retry_after_reads_seconds_and_rejects_what_is_not_a_wait() {
assert_eq!(retry_after_secs(Some("2")), Some(Duration::from_secs(2)));
assert_eq!(
retry_after_secs(Some(" 1.5 ")),
Some(Duration::from_millis(1500))
);
for junk in ["0", "-3", "soon", "NaN", "inf", "0.0000000004", "1e400"] {
assert_eq!(retry_after_secs(Some(junk)), None, "{junk:?}");
}
assert_eq!(retry_after_secs(None), None);
assert_eq!(retry_after_secs(Some("604800")), 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,
),
(
parts(502, None, "<html>bad gateway</html>"),
Class::Unavailable { code: None },
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),
(
UsdmError::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 binance_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<UsdmError>();
}
}