1use std::time::Duration;
4
5use polyoxide_core::{truncate_for_log, ApiError, ErrorResponse};
6use polyoxide_venue::{class_for_status, Class, Classify};
7use serde::Deserialize;
8use thiserror::Error;
9
10use crate::usdm::weight::MAX_COOLDOWN;
11
12#[derive(Error, Debug)]
14#[non_exhaustive]
15pub enum UsdmError {
16 #[error(transparent)]
18 Api(ApiError),
19
20 #[error("binance answered {status}: {code} {msg}")]
23 Venue {
24 status: u16,
26 code: i64,
28 msg: String,
30 },
31
32 #[error("binance rate limit (429), retry after {retry_after:?}")]
34 RateLimited {
35 retry_after: Option<Duration>,
37 },
38
39 #[error("binance has banned this IP (418), retry after {retry_after:?}")]
43 IpBanned {
44 retry_after: Option<Duration>,
46 },
47
48 #[error("binance does not serve this location (451): {msg}")]
50 RegionBlocked {
51 msg: String,
53 },
54
55 #[error("binance's firewall refused the request (403): {msg}")]
57 Forbidden {
58 msg: String,
60 },
61}
62
63#[derive(Deserialize)]
64struct VenueBody {
65 code: i64,
66 msg: String,
67}
68
69impl UsdmError {
70 pub fn code(&self) -> Option<i64> {
72 match self {
73 Self::Venue { code, .. } => Some(*code),
74 _ => None,
75 }
76 }
77}
78
79impl From<ApiError> for UsdmError {
94 fn from(err: ApiError) -> Self {
95 match err {
96 ApiError::Response(response) => Self::from_response(*response),
97 other => Self::Api(other),
98 }
99 }
100}
101
102impl UsdmError {
103 fn from_response(mut response: ErrorResponse) -> Self {
104 let status = response.status.as_u16();
105 let retry_after = retry_after_secs(
106 response
107 .headers
108 .get(polyoxide_core::reqwest::header::RETRY_AFTER)
109 .and_then(|value| value.to_str().ok()),
110 );
111 match status {
112 418 => {
113 tracing::warn!("418: IP banned: {}", truncate_for_log(&response.body));
114 Self::IpBanned { retry_after }
115 }
116 429 => Self::RateLimited { retry_after },
117 451 => Self::RegionBlocked {
118 msg: clip(&response.body),
119 },
120 403 => Self::Forbidden {
121 msg: clip(&response.body),
122 },
123 _ => match serde_json::from_str::<VenueBody>(&response.body) {
124 Ok(venue) => Self::Venue {
125 status,
126 code: venue.code,
127 msg: clip(&venue.msg),
128 },
129 Err(_) => {
133 response.message = clip(&response.message);
134 response.body = clip(&response.body);
135 response.retry_after = retry_after;
136 Self::Api(response.into())
137 }
138 },
139 }
140 }
141}
142
143pub(crate) fn retry_after_secs(value: Option<&str>) -> Option<Duration> {
147 value.and_then(|value| polyoxide_venue::parse_retry_after(value, MAX_COOLDOWN))
148}
149
150fn clip(text: &str) -> String {
151 truncate_for_log(text).into_owned()
152}
153
154impl Classify for UsdmError {
166 fn class(&self) -> Class {
167 match self {
168 Self::Api(err) => err.class(),
169 Self::Venue { status, code, .. } => class_for_status(*status)
170 .unwrap_or(Class::Decode)
171 .with_code(code.to_string()),
172 Self::RateLimited { retry_after } => {
173 Class::RateLimited { retry_after: None }.with_retry_after(*retry_after)
174 }
175 Self::IpBanned { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => {
176 Class::Restricted
177 }
178 }
179 }
180
181 fn is_fault(&self) -> bool {
182 match self {
183 Self::Api(err) => err.is_fault(),
184 Self::RegionBlocked { .. } => false,
185 Self::Venue { status, .. } => *status != 451,
188 Self::RateLimited { .. } | Self::IpBanned { .. } | Self::Forbidden { .. } => true,
189 }
190 }
191
192 fn retry_after(&self) -> Option<Duration> {
193 match self {
194 Self::Api(err) => Classify::retry_after(err),
195 Self::RateLimited { retry_after } | Self::IpBanned { retry_after } => {
196 retry_after.filter(|wait| !wait.is_zero())
197 }
198 Self::Venue { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => None,
199 }
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 fn response(status: u16, retry_after: Option<&str>, body: &str) -> ApiError {
209 let mut headers = polyoxide_core::reqwest::header::HeaderMap::new();
210 if let Some(value) = retry_after {
211 headers.insert(
212 polyoxide_core::reqwest::header::RETRY_AFTER,
213 value.parse().unwrap(),
214 );
215 }
216 ErrorResponse::new(
217 polyoxide_core::reqwest::StatusCode::from_u16(status).unwrap(),
218 headers,
219 body,
220 )
221 .into()
222 }
223
224 fn parts(status: u16, retry_after: Option<&str>, body: &str) -> UsdmError {
226 UsdmError::from(response(status, retry_after, body))
227 }
228
229 #[test]
230 fn an_unknown_symbol_is_a_venue_error_with_its_code() {
231 let err = parts(400, None, r#"{"code":-1121,"msg":"Invalid symbol."}"#);
233 assert!(
234 matches!(&err, UsdmError::Venue { status: 400, code: -1121, msg } if msg == "Invalid symbol.")
235 );
236 assert_eq!(err.code(), Some(-1121));
237 assert!(!err.is_retriable());
238 }
239
240 #[test]
241 fn statuses_that_decide_the_caller_s_next_step_win_over_the_body() {
242 let body = r#"{"code":-1003,"msg":"Too many requests."}"#;
245 let limited = parts(429, Some("7"), body);
246 assert!(matches!(limited, UsdmError::RateLimited { .. }));
247 assert_eq!(limited.retry_after(), Some(Duration::from_secs(7)));
248 assert!(limited.is_retriable());
249
250 let banned = parts(418, Some("120"), body);
251 assert!(matches!(banned, UsdmError::IpBanned { .. }));
252 assert_eq!(banned.retry_after(), Some(Duration::from_secs(120)));
253 assert!(!banned.is_retriable());
254
255 let region = parts(451, None, body);
256 assert!(matches!(®ion, UsdmError::RegionBlocked { msg } if msg.contains("-1003")));
257 assert!(!region.is_retriable());
258
259 let firewall = parts(403, None, "<html>denied</html>");
260 assert!(matches!(&firewall, UsdmError::Forbidden { msg } if msg == "<html>denied</html>"));
261 assert!(!firewall.is_retriable());
262
263 let firewall_json = parts(403, None, body);
264 assert!(matches!(firewall_json, UsdmError::Forbidden { .. }));
265 }
266
267 #[test]
268 fn a_5xx_venue_error_is_retriable_and_a_4xx_is_not() {
269 let body = r#"{"code":-1001,"msg":"Internal error; unable to process your request."}"#;
270 assert!(parts(503, None, body).is_retriable());
271 assert!(parts(500, None, body).is_retriable());
272 assert!(!parts(499, None, body).is_retriable());
273 assert!(!parts(400, None, body).is_retriable());
274 }
275
276 #[test]
277 fn a_body_in_another_shape_is_left_to_core() {
278 let err = parts(502, None, "<html>bad gateway</html>");
279 assert!(matches!(
280 &err,
281 UsdmError::Api(ApiError::Response(r)) if r.status.as_u16() == 502
282 ));
283 assert!(err.is_retriable());
284 assert_eq!(err.code(), None);
285 }
286
287 #[test]
288 fn every_kept_body_is_clipped() {
289 let long = "x".repeat(10_000);
290 let venue = format!(r#"{{"code":-1121,"msg":"{long}"}}"#);
291 let kept = |err: UsdmError| match err {
293 UsdmError::Venue { msg, .. }
294 | UsdmError::RegionBlocked { msg }
295 | UsdmError::Forbidden { msg } => vec![msg],
296 UsdmError::Api(ApiError::Response(r)) => vec![r.message, r.body],
297 other => panic!("unexpected {other:?}"),
298 };
299 for err in [
300 parts(400, None, &venue),
301 parts(451, None, &long),
302 parts(403, None, &long),
303 parts(502, None, &long),
304 ] {
305 for msg in kept(err) {
306 assert!(
307 msg.len() <= 512 + "... [truncated]".len(),
308 "kept {} bytes",
309 msg.len()
310 );
311 }
312 }
313 }
314
315 #[test]
316 fn core_reads_a_long_json_body_before_it_is_clipped() {
317 let body = format!(r#"{{"message":"short","pad":"{}"}}"#, "p".repeat(600));
318 let err = parts(502, None, &body);
319 assert!(
320 matches!(&err, UsdmError::Api(ApiError::Response(r)) if r.status.as_u16() == 502 && r.message == "short"),
321 "{err:?}"
322 );
323 }
324
325 #[test]
326 fn venue_and_api_errors_agree_on_retriability() {
327 let body = r#"{"code":-1,"msg":"x"}"#;
330 for status in [400u16, 401, 404, 408, 409, 425, 500, 502, 503, 504] {
331 let venue = parts(status, None, body);
332 let api = response(status, None, "x");
333 assert_eq!(
334 venue.is_retriable(),
335 Classify::is_retriable(&api),
336 "status {status}"
337 );
338 }
339 }
340
341 #[test]
342 fn a_core_response_keeps_binance_s_clamped_wait() {
343 let err = parts(503, Some("604800"), "<html>down</html>");
346 match &err {
347 UsdmError::Api(ApiError::Response(r)) => {
348 assert_eq!(r.retry_after, Some(MAX_COOLDOWN));
349 }
350 other => panic!("unexpected {other:?}"),
351 }
352 assert_eq!(Classify::retry_after(&err), Some(MAX_COOLDOWN));
353 }
354
355 #[test]
356 fn retry_after_reads_seconds_and_rejects_what_is_not_a_wait() {
357 assert_eq!(retry_after_secs(Some("2")), Some(Duration::from_secs(2)));
358 assert_eq!(
359 retry_after_secs(Some(" 1.5 ")),
360 Some(Duration::from_millis(1500))
361 );
362 for junk in ["0", "-3", "soon", "NaN", "inf", "0.0000000004", "1e400"] {
363 assert_eq!(retry_after_secs(Some(junk)), None, "{junk:?}");
364 }
365 assert_eq!(retry_after_secs(None), None);
366 assert_eq!(retry_after_secs(Some("604800")), Some(MAX_COOLDOWN));
368 }
369
370 #[test]
371 fn every_variant_classifies() {
372 let body = r#"{"code":-1121,"msg":"Invalid symbol."}"#;
373 let coded = |status, retry_after| parts(status, retry_after, body);
374 let code = |c: &str| Some(std::sync::Arc::from(c));
375 let week = Some(MAX_COOLDOWN);
376 let rows = [
378 (
379 coded(502, None),
380 Class::Unavailable {
381 code: code("-1121"),
382 },
383 true,
384 None,
385 ),
386 (
387 parts(502, None, "<html>bad gateway</html>"),
388 Class::Unavailable { code: None },
389 true,
390 None,
391 ),
392 (
393 coded(400, None),
394 Class::VenueRefusal {
395 code: code("-1121"),
396 },
397 true,
398 None,
399 ),
400 (
401 coded(408, None),
402 Class::Unavailable {
403 code: code("-1121"),
404 },
405 true,
406 None,
407 ),
408 (
409 coded(429, Some("7")),
410 Class::RateLimited {
411 retry_after: Some(Duration::from_secs(7)),
412 },
413 true,
414 Some(Duration::from_secs(7)),
415 ),
416 (
417 coded(429, None),
418 Class::RateLimited { retry_after: None },
419 true,
420 None,
421 ),
422 (coded(418, Some("604800")), Class::Restricted, true, week),
424 (coded(451, None), Class::Restricted, false, None),
425 (
428 UsdmError::Venue {
429 status: 451,
430 code: -1,
431 msg: "restricted".into(),
432 },
433 Class::Restricted,
434 false,
435 None,
436 ),
437 (coded(403, None), Class::Restricted, true, None),
438 ];
439 for (err, class, fault, wait) in rows {
440 assert_eq!(err.class(), class, "{err:?}");
441 assert_eq!(err.is_fault(), fault, "{err:?}");
442 assert_eq!(Classify::retry_after(&err), wait, "{err:?}");
443 assert_eq!(
444 Classify::is_retriable(&err),
445 class.is_retriable(),
446 "{err:?}"
447 );
448 }
449 }
450
451 #[test]
452 fn binance_error_is_send_sync() {
453 fn assert_send_sync<T: Send + Sync>() {}
454 assert_send_sync::<UsdmError>();
455 }
456}