1use std::time::Duration;
4
5use polyoxide_core::{truncate_for_log, ApiError};
6use polyoxide_venue::{Class, Classify};
7use thiserror::Error;
8
9use crate::shared::error::{decode, venue_class, Decoded};
10
11#[cfg(test)]
12use crate::shared::error::retry_after_secs;
13#[cfg(test)]
14use crate::usdm::weight::MAX_COOLDOWN;
15#[cfg(test)]
16use polyoxide_core::ErrorResponse;
17
18#[derive(Error, Debug)]
20#[non_exhaustive]
21pub enum UsdmError {
22 #[error(transparent)]
24 Api(ApiError),
25
26 #[error("binance answered {status}: {code} {msg}")]
29 Venue {
30 status: u16,
32 code: i64,
34 msg: String,
36 },
37
38 #[error("binance rate limit (429), retry after {retry_after:?}")]
40 RateLimited {
41 retry_after: Option<Duration>,
43 },
44
45 #[error("binance has banned this IP (418), retry after {retry_after:?}")]
49 IpBanned {
50 retry_after: Option<Duration>,
52 },
53
54 #[error("binance does not serve this location (451): {msg}")]
56 RegionBlocked {
57 msg: String,
59 },
60
61 #[error("binance's firewall refused the request (403): {msg}")]
63 Forbidden {
64 msg: String,
66 },
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 decode(err) {
96 Decoded::Api(err) => Self::Api(err),
97 Decoded::Venue { status, code, msg } => Self::Venue { status, code, msg },
98 Decoded::RateLimited { retry_after } => Self::RateLimited { retry_after },
99 Decoded::IpBanned { retry_after, body } => {
100 tracing::warn!("418: IP banned: {}", truncate_for_log(&body));
101 Self::IpBanned { retry_after }
102 }
103 Decoded::RegionBlocked { msg } => Self::RegionBlocked { msg },
104 Decoded::Forbidden { msg } => Self::Forbidden { msg },
105 }
106 }
107}
108
109impl Classify for UsdmError {
121 fn class(&self) -> Class {
122 match self {
123 Self::Api(err) => err.class(),
124 Self::Venue { status, code, .. } => venue_class(*status, *code),
125 Self::RateLimited { retry_after } => {
126 Class::RateLimited { retry_after: None }.with_retry_after(*retry_after)
127 }
128 Self::IpBanned { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => {
129 Class::Restricted
130 }
131 }
132 }
133
134 fn is_fault(&self) -> bool {
135 match self {
136 Self::Api(err) => err.is_fault(),
137 Self::RegionBlocked { .. } => false,
138 Self::Venue { status, .. } => *status != 451,
141 Self::RateLimited { .. } | Self::IpBanned { .. } | Self::Forbidden { .. } => true,
142 }
143 }
144
145 fn retry_after(&self) -> Option<Duration> {
146 match self {
147 Self::Api(err) => Classify::retry_after(err),
148 Self::RateLimited { retry_after } | Self::IpBanned { retry_after } => {
149 retry_after.filter(|wait| !wait.is_zero())
150 }
151 Self::Venue { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => None,
152 }
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn response(status: u16, retry_after: Option<&str>, body: &str) -> ApiError {
162 let mut headers = polyoxide_core::http::header::HeaderMap::new();
163 if let Some(value) = retry_after {
164 headers.insert(
165 polyoxide_core::http::header::RETRY_AFTER,
166 value.parse().unwrap(),
167 );
168 }
169 ErrorResponse::new(
170 polyoxide_core::http::StatusCode::from_u16(status).unwrap(),
171 headers,
172 body,
173 )
174 .into()
175 }
176
177 fn parts(status: u16, retry_after: Option<&str>, body: &str) -> UsdmError {
179 UsdmError::from(response(status, retry_after, body))
180 }
181
182 #[test]
183 fn an_unknown_symbol_is_a_venue_error_with_its_code() {
184 let err = parts(400, None, r#"{"code":-1121,"msg":"Invalid symbol."}"#);
186 assert!(
187 matches!(&err, UsdmError::Venue { status: 400, code: -1121, msg } if msg == "Invalid symbol.")
188 );
189 assert_eq!(err.code(), Some(-1121));
190 assert!(!err.is_retriable());
191 }
192
193 #[test]
194 fn statuses_that_decide_the_caller_s_next_step_win_over_the_body() {
195 let body = r#"{"code":-1003,"msg":"Too many requests."}"#;
198 let limited = parts(429, Some("7"), body);
199 assert!(matches!(limited, UsdmError::RateLimited { .. }));
200 assert_eq!(limited.retry_after(), Some(Duration::from_secs(7)));
201 assert!(limited.is_retriable());
202
203 let banned = parts(418, Some("120"), body);
204 assert!(matches!(banned, UsdmError::IpBanned { .. }));
205 assert_eq!(banned.retry_after(), Some(Duration::from_secs(120)));
206 assert!(!banned.is_retriable());
207
208 let region = parts(451, None, body);
209 assert!(matches!(®ion, UsdmError::RegionBlocked { msg } if msg.contains("-1003")));
210 assert!(!region.is_retriable());
211
212 let firewall = parts(403, None, "<html>denied</html>");
213 assert!(matches!(&firewall, UsdmError::Forbidden { msg } if msg == "<html>denied</html>"));
214 assert!(!firewall.is_retriable());
215
216 let firewall_json = parts(403, None, body);
217 assert!(matches!(firewall_json, UsdmError::Forbidden { .. }));
218 }
219
220 #[test]
221 fn a_5xx_venue_error_is_retriable_and_a_4xx_is_not() {
222 let body = r#"{"code":-1001,"msg":"Internal error; unable to process your request."}"#;
223 assert!(parts(503, None, body).is_retriable());
224 assert!(parts(500, None, body).is_retriable());
225 assert!(!parts(499, None, body).is_retriable());
226 assert!(!parts(400, None, body).is_retriable());
227 }
228
229 #[test]
230 fn a_body_in_another_shape_is_left_to_core() {
231 let err = parts(502, None, "<html>bad gateway</html>");
232 assert!(matches!(
233 &err,
234 UsdmError::Api(ApiError::Response(r)) if r.status.as_u16() == 502
235 ));
236 assert!(err.is_retriable());
237 assert_eq!(err.code(), None);
238 }
239
240 #[test]
241 fn every_kept_body_is_clipped() {
242 let long = "x".repeat(10_000);
243 let venue = format!(r#"{{"code":-1121,"msg":"{long}"}}"#);
244 let kept = |err: UsdmError| match err {
246 UsdmError::Venue { msg, .. }
247 | UsdmError::RegionBlocked { msg }
248 | UsdmError::Forbidden { msg } => vec![msg],
249 UsdmError::Api(ApiError::Response(r)) => vec![r.message, r.body],
250 other => panic!("unexpected {other:?}"),
251 };
252 for err in [
253 parts(400, None, &venue),
254 parts(451, None, &long),
255 parts(403, None, &long),
256 parts(502, None, &long),
257 ] {
258 for msg in kept(err) {
259 assert!(
260 msg.len() <= 512 + "... [truncated]".len(),
261 "kept {} bytes",
262 msg.len()
263 );
264 }
265 }
266 }
267
268 #[test]
269 fn core_reads_a_long_json_body_before_it_is_clipped() {
270 let body = format!(r#"{{"message":"short","pad":"{}"}}"#, "p".repeat(600));
271 let err = parts(502, None, &body);
272 assert!(
273 matches!(&err, UsdmError::Api(ApiError::Response(r)) if r.status.as_u16() == 502 && r.message == "short"),
274 "{err:?}"
275 );
276 }
277
278 #[test]
279 fn venue_and_api_errors_agree_on_retriability() {
280 let body = r#"{"code":-1,"msg":"x"}"#;
283 for status in [400u16, 401, 404, 408, 409, 425, 500, 502, 503, 504] {
284 let venue = parts(status, None, body);
285 let api = response(status, None, "x");
286 assert_eq!(
287 venue.is_retriable(),
288 Classify::is_retriable(&api),
289 "status {status}"
290 );
291 }
292 }
293
294 #[test]
295 fn a_core_response_keeps_binance_s_clamped_wait() {
296 let err = parts(503, Some("604800"), "<html>down</html>");
299 match &err {
300 UsdmError::Api(ApiError::Response(r)) => {
301 assert_eq!(r.retry_after, Some(MAX_COOLDOWN));
302 }
303 other => panic!("unexpected {other:?}"),
304 }
305 assert_eq!(Classify::retry_after(&err), Some(MAX_COOLDOWN));
306 }
307
308 #[test]
309 fn retry_after_reads_seconds_and_rejects_what_is_not_a_wait() {
310 assert_eq!(retry_after_secs(Some("2")), Some(Duration::from_secs(2)));
311 assert_eq!(
312 retry_after_secs(Some(" 1.5 ")),
313 Some(Duration::from_millis(1500))
314 );
315 for junk in ["0", "-3", "soon", "NaN", "inf", "0.0000000004", "1e400"] {
316 assert_eq!(retry_after_secs(Some(junk)), None, "{junk:?}");
317 }
318 assert_eq!(retry_after_secs(None), None);
319 assert_eq!(retry_after_secs(Some("604800")), Some(MAX_COOLDOWN));
321 }
322
323 #[test]
324 fn every_variant_classifies() {
325 let body = r#"{"code":-1121,"msg":"Invalid symbol."}"#;
326 let coded = |status, retry_after| parts(status, retry_after, body);
327 let code = |c: &str| Some(std::sync::Arc::from(c));
328 let week = Some(MAX_COOLDOWN);
329 let rows = [
331 (
332 coded(502, None),
333 Class::Unavailable {
334 code: code("-1121"),
335 },
336 true,
337 None,
338 ),
339 (
340 parts(502, None, "<html>bad gateway</html>"),
341 Class::Unavailable { code: None },
342 true,
343 None,
344 ),
345 (
346 coded(400, None),
347 Class::VenueRefusal {
348 code: code("-1121"),
349 },
350 true,
351 None,
352 ),
353 (
354 coded(408, None),
355 Class::Unavailable {
356 code: code("-1121"),
357 },
358 true,
359 None,
360 ),
361 (
362 coded(429, Some("7")),
363 Class::RateLimited {
364 retry_after: Some(Duration::from_secs(7)),
365 },
366 true,
367 Some(Duration::from_secs(7)),
368 ),
369 (
370 coded(429, None),
371 Class::RateLimited { retry_after: None },
372 true,
373 None,
374 ),
375 (coded(418, Some("604800")), Class::Restricted, true, week),
377 (coded(451, None), Class::Restricted, false, None),
378 (
381 UsdmError::Venue {
382 status: 451,
383 code: -1,
384 msg: "restricted".into(),
385 },
386 Class::Restricted,
387 false,
388 None,
389 ),
390 (coded(403, None), Class::Restricted, true, None),
391 ];
392 for (err, class, fault, wait) in rows {
393 assert_eq!(err.class(), class, "{err:?}");
394 assert_eq!(err.is_fault(), fault, "{err:?}");
395 assert_eq!(Classify::retry_after(&err), wait, "{err:?}");
396 assert_eq!(
397 Classify::is_retriable(&err),
398 class.is_retriable(),
399 "{err:?}"
400 );
401 }
402 }
403
404 #[test]
405 fn binance_error_is_send_sync() {
406 fn assert_send_sync<T: Send + Sync>() {}
407 assert_send_sync::<UsdmError>();
408 }
409}