1use std::time::Duration;
4
5use polyoxide_core::{truncate_for_log, ApiError};
6use serde::Deserialize;
7use thiserror::Error;
8
9use crate::weight::MAX_COOLDOWN;
10
11#[derive(Error, Debug)]
13#[non_exhaustive]
14pub enum BinanceError {
15 #[error(transparent)]
17 Api(#[from] ApiError),
18
19 #[error("binance answered {status}: {code} {msg}")]
22 Venue {
23 status: u16,
25 code: i64,
27 msg: String,
29 },
30
31 #[error("binance rate limit (429), retry after {retry_after:?}")]
33 RateLimited {
34 retry_after: Option<Duration>,
36 },
37
38 #[error("binance has banned this IP (418), retry after {retry_after:?}")]
42 IpBanned {
43 retry_after: Option<Duration>,
45 },
46
47 #[error("binance does not serve this location (451): {msg}")]
49 RegionBlocked {
50 msg: String,
52 },
53
54 #[error("binance's firewall refused the request (403): {msg}")]
56 Forbidden {
57 msg: String,
59 },
60}
61
62#[derive(Deserialize)]
63struct VenueBody {
64 code: i64,
65 msg: String,
66}
67
68impl BinanceError {
69 pub(crate) fn from_response_parts(status: u16, retry_after: Option<&str>, body: &str) -> Self {
76 let retry_after = retry_after_secs(retry_after);
77 match status {
78 418 => Self::IpBanned { retry_after },
79 429 => Self::RateLimited { retry_after },
80 451 => Self::RegionBlocked { msg: clip(body) },
81 403 => Self::Forbidden { msg: clip(body) },
82 _ => match serde_json::from_str::<VenueBody>(body) {
83 Ok(venue) => Self::Venue {
84 status,
85 code: venue.code,
86 msg: clip(&venue.msg),
87 },
88 Err(_) => Self::Api(core_error(status, body)),
89 },
90 }
91 }
92
93 pub fn is_retriable(&self) -> bool {
100 match self {
101 Self::Api(err) => err.is_retriable(),
102 Self::Venue { status, .. } => matches!(*status, 408 | 425) || *status >= 500,
103 Self::RateLimited { .. } => true,
104 Self::IpBanned { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => false,
105 }
106 }
107
108 pub fn code(&self) -> Option<i64> {
110 match self {
111 Self::Venue { code, .. } => Some(*code),
112 _ => None,
113 }
114 }
115
116 pub fn retry_after(&self) -> Option<Duration> {
118 match self {
119 Self::RateLimited { retry_after } | Self::IpBanned { retry_after } => *retry_after,
120 _ => None,
121 }
122 }
123}
124
125pub(crate) fn retry_after_secs(value: Option<&str>) -> Option<Duration> {
129 let secs = value?.trim().parse::<f64>().ok()?;
130 if !secs.is_finite() || secs <= 0.0 {
131 return None;
132 }
133 Some(Duration::from_secs_f64(
134 secs.min(MAX_COOLDOWN.as_secs_f64()),
135 ))
136 .filter(|wait| !wait.is_zero())
137}
138
139fn core_error(status: u16, body: &str) -> ApiError {
142 match ApiError::from_status_and_body(status, body) {
143 ApiError::Api { status, message } => ApiError::Api {
144 status,
145 message: clip(&message),
146 },
147 ApiError::Authentication(message) => ApiError::Authentication(clip(&message)),
148 ApiError::Validation(message) => ApiError::Validation(clip(&message)),
149 ApiError::RateLimit(message) => ApiError::RateLimit(clip(&message)),
150 other => other,
151 }
152}
153
154fn clip(text: &str) -> String {
155 truncate_for_log(text).into_owned()
156}
157
158polyoxide_core::impl_api_error_conversions!(BinanceError);
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn an_unknown_symbol_is_a_venue_error_with_its_code() {
166 let err = BinanceError::from_response_parts(
168 400,
169 None,
170 r#"{"code":-1121,"msg":"Invalid symbol."}"#,
171 );
172 assert!(
173 matches!(&err, BinanceError::Venue { status: 400, code: -1121, msg } if msg == "Invalid symbol.")
174 );
175 assert_eq!(err.code(), Some(-1121));
176 assert!(!err.is_retriable());
177 }
178
179 #[test]
180 fn statuses_that_decide_the_caller_s_next_step_win_over_the_body() {
181 let body = r#"{"code":-1003,"msg":"Too many requests."}"#;
184 let limited = BinanceError::from_response_parts(429, Some("7"), body);
185 assert!(matches!(limited, BinanceError::RateLimited { .. }));
186 assert_eq!(limited.retry_after(), Some(Duration::from_secs(7)));
187 assert!(limited.is_retriable());
188
189 let banned = BinanceError::from_response_parts(418, Some("120"), body);
190 assert!(matches!(banned, BinanceError::IpBanned { .. }));
191 assert_eq!(banned.retry_after(), Some(Duration::from_secs(120)));
192 assert!(!banned.is_retriable());
193
194 let region = BinanceError::from_response_parts(451, None, body);
195 assert!(matches!(®ion, BinanceError::RegionBlocked { msg } if msg.contains("-1003")));
196 assert!(!region.is_retriable());
197
198 let firewall = BinanceError::from_response_parts(403, None, "<html>denied</html>");
199 assert!(
200 matches!(&firewall, BinanceError::Forbidden { msg } if msg == "<html>denied</html>")
201 );
202 assert!(!firewall.is_retriable());
203
204 let firewall_json = BinanceError::from_response_parts(403, None, body);
205 assert!(matches!(firewall_json, BinanceError::Forbidden { .. }));
206 }
207
208 #[test]
209 fn a_5xx_venue_error_is_retriable_and_a_4xx_is_not() {
210 let body = r#"{"code":-1001,"msg":"Internal error; unable to process your request."}"#;
211 assert!(BinanceError::from_response_parts(503, None, body).is_retriable());
212 assert!(BinanceError::from_response_parts(500, None, body).is_retriable());
213 assert!(!BinanceError::from_response_parts(499, None, body).is_retriable());
214 assert!(!BinanceError::from_response_parts(400, None, body).is_retriable());
215 }
216
217 #[test]
218 fn a_body_in_another_shape_is_left_to_core() {
219 let err = BinanceError::from_response_parts(502, None, "<html>bad gateway</html>");
220 assert!(matches!(
221 err,
222 BinanceError::Api(ApiError::Api { status: 502, .. })
223 ));
224 assert!(err.is_retriable());
225 assert_eq!(err.code(), None);
226 }
227
228 #[test]
229 fn every_kept_body_is_clipped() {
230 let long = "x".repeat(10_000);
231 let venue = format!(r#"{{"code":-1121,"msg":"{long}"}}"#);
232 let kept = |err: BinanceError| match err {
233 BinanceError::Venue { msg, .. }
234 | BinanceError::RegionBlocked { msg }
235 | BinanceError::Forbidden { msg } => msg,
236 BinanceError::Api(ApiError::Api { message, .. }) => message,
237 other => panic!("unexpected {other:?}"),
238 };
239 for err in [
240 BinanceError::from_response_parts(400, None, &venue),
241 BinanceError::from_response_parts(451, None, &long),
242 BinanceError::from_response_parts(403, None, &long),
243 BinanceError::from_response_parts(502, None, &long),
244 ] {
245 let msg = kept(err);
246 assert!(
247 msg.len() <= 512 + "... [truncated]".len(),
248 "kept {} bytes",
249 msg.len()
250 );
251 }
252 }
253
254 #[test]
255 fn core_reads_a_long_json_body_before_it_is_clipped() {
256 let body = format!(r#"{{"message":"short","pad":"{}"}}"#, "p".repeat(600));
257 let err = BinanceError::from_response_parts(502, None, &body);
258 assert!(
259 matches!(&err, BinanceError::Api(ApiError::Api { status: 502, message }) if message == "short"),
260 "{err:?}"
261 );
262 }
263
264 #[test]
265 fn venue_and_api_errors_agree_on_retriability() {
266 let body = r#"{"code":-1,"msg":"x"}"#;
269 for status in [400u16, 401, 404, 408, 409, 425, 500, 502, 503, 504] {
270 let venue = BinanceError::from_response_parts(status, None, body);
271 let api = ApiError::from_status_and_body(status, "x");
272 assert_eq!(venue.is_retriable(), api.is_retriable(), "status {status}");
273 }
274 }
275
276 #[test]
277 fn retry_after_reads_seconds_and_rejects_what_is_not_a_wait() {
278 assert_eq!(retry_after_secs(Some("2")), Some(Duration::from_secs(2)));
279 assert_eq!(
280 retry_after_secs(Some(" 1.5 ")),
281 Some(Duration::from_millis(1500))
282 );
283 for junk in ["0", "-3", "soon", "NaN", "inf", "0.0000000004", "1e400"] {
284 assert_eq!(retry_after_secs(Some(junk)), None, "{junk:?}");
285 }
286 assert_eq!(retry_after_secs(None), None);
287 assert_eq!(retry_after_secs(Some("604800")), Some(MAX_COOLDOWN));
289 }
290
291 #[test]
292 fn binance_error_is_send_sync() {
293 fn assert_send_sync<T: Send + Sync>() {}
294 assert_send_sync::<BinanceError>();
295 }
296}