use std::time::Duration;
use polyoxide_core::{truncate_for_log, ApiError};
use serde::Deserialize;
use thiserror::Error;
use crate::weight::MAX_COOLDOWN;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum BinanceError {
#[error(transparent)]
Api(#[from] 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,
},
}
#[derive(Deserialize)]
struct VenueBody {
code: i64,
msg: String,
}
impl BinanceError {
pub(crate) fn from_response_parts(status: u16, retry_after: Option<&str>, body: &str) -> Self {
let retry_after = retry_after_secs(retry_after);
match status {
418 => Self::IpBanned { retry_after },
429 => Self::RateLimited { retry_after },
451 => Self::RegionBlocked { msg: clip(body) },
403 => Self::Forbidden { msg: clip(body) },
_ => match serde_json::from_str::<VenueBody>(body) {
Ok(venue) => Self::Venue {
status,
code: venue.code,
msg: clip(&venue.msg),
},
Err(_) => Self::Api(core_error(status, body)),
},
}
}
pub fn is_retriable(&self) -> bool {
match self {
Self::Api(err) => err.is_retriable(),
Self::Venue { status, .. } => matches!(*status, 408 | 425) || *status >= 500,
Self::RateLimited { .. } => true,
Self::IpBanned { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => false,
}
}
pub fn code(&self) -> Option<i64> {
match self {
Self::Venue { code, .. } => Some(*code),
_ => None,
}
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited { retry_after } | Self::IpBanned { retry_after } => *retry_after,
_ => None,
}
}
}
pub(crate) fn retry_after_secs(value: Option<&str>) -> Option<Duration> {
let secs = value?.trim().parse::<f64>().ok()?;
if !secs.is_finite() || secs <= 0.0 {
return None;
}
Some(Duration::from_secs_f64(
secs.min(MAX_COOLDOWN.as_secs_f64()),
))
.filter(|wait| !wait.is_zero())
}
fn core_error(status: u16, body: &str) -> ApiError {
match ApiError::from_status_and_body(status, body) {
ApiError::Api { status, message } => ApiError::Api {
status,
message: clip(&message),
},
ApiError::Authentication(message) => ApiError::Authentication(clip(&message)),
ApiError::Validation(message) => ApiError::Validation(clip(&message)),
ApiError::RateLimit(message) => ApiError::RateLimit(clip(&message)),
other => other,
}
}
fn clip(text: &str) -> String {
truncate_for_log(text).into_owned()
}
polyoxide_core::impl_api_error_conversions!(BinanceError);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unknown_symbol_is_a_venue_error_with_its_code() {
let err = BinanceError::from_response_parts(
400,
None,
r#"{"code":-1121,"msg":"Invalid symbol."}"#,
);
assert!(
matches!(&err, BinanceError::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 = BinanceError::from_response_parts(429, Some("7"), body);
assert!(matches!(limited, BinanceError::RateLimited { .. }));
assert_eq!(limited.retry_after(), Some(Duration::from_secs(7)));
assert!(limited.is_retriable());
let banned = BinanceError::from_response_parts(418, Some("120"), body);
assert!(matches!(banned, BinanceError::IpBanned { .. }));
assert_eq!(banned.retry_after(), Some(Duration::from_secs(120)));
assert!(!banned.is_retriable());
let region = BinanceError::from_response_parts(451, None, body);
assert!(matches!(®ion, BinanceError::RegionBlocked { msg } if msg.contains("-1003")));
assert!(!region.is_retriable());
let firewall = BinanceError::from_response_parts(403, None, "<html>denied</html>");
assert!(
matches!(&firewall, BinanceError::Forbidden { msg } if msg == "<html>denied</html>")
);
assert!(!firewall.is_retriable());
let firewall_json = BinanceError::from_response_parts(403, None, body);
assert!(matches!(firewall_json, BinanceError::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!(BinanceError::from_response_parts(503, None, body).is_retriable());
assert!(BinanceError::from_response_parts(500, None, body).is_retriable());
assert!(!BinanceError::from_response_parts(499, None, body).is_retriable());
assert!(!BinanceError::from_response_parts(400, None, body).is_retriable());
}
#[test]
fn a_body_in_another_shape_is_left_to_core() {
let err = BinanceError::from_response_parts(502, None, "<html>bad gateway</html>");
assert!(matches!(
err,
BinanceError::Api(ApiError::Api { status: 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: BinanceError| match err {
BinanceError::Venue { msg, .. }
| BinanceError::RegionBlocked { msg }
| BinanceError::Forbidden { msg } => msg,
BinanceError::Api(ApiError::Api { message, .. }) => message,
other => panic!("unexpected {other:?}"),
};
for err in [
BinanceError::from_response_parts(400, None, &venue),
BinanceError::from_response_parts(451, None, &long),
BinanceError::from_response_parts(403, None, &long),
BinanceError::from_response_parts(502, None, &long),
] {
let msg = 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 = BinanceError::from_response_parts(502, None, &body);
assert!(
matches!(&err, BinanceError::Api(ApiError::Api { status: 502, message }) if 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 = BinanceError::from_response_parts(status, None, body);
let api = ApiError::from_status_and_body(status, "x");
assert_eq!(venue.is_retriable(), api.is_retriable(), "status {status}");
}
}
#[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 binance_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<BinanceError>();
}
}