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#[derive(Error, Debug)]
13#[non_exhaustive]
14pub enum SpotError {
15 #[error(transparent)]
17 Api(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
62impl SpotError {
63 pub fn code(&self) -> Option<i64> {
65 match self {
66 Self::Venue { code, .. } => Some(*code),
67 _ => None,
68 }
69 }
70}
71
72impl From<ApiError> for SpotError {
86 fn from(err: ApiError) -> Self {
87 match decode(err) {
88 Decoded::Api(err) => Self::Api(err),
89 Decoded::Venue { status, code, msg } => Self::Venue { status, code, msg },
90 Decoded::RateLimited { retry_after } => Self::RateLimited { retry_after },
91 Decoded::IpBanned { retry_after, body } => {
92 tracing::warn!("418: IP banned: {}", truncate_for_log(&body));
93 Self::IpBanned { retry_after }
94 }
95 Decoded::RegionBlocked { msg } => Self::RegionBlocked { msg },
96 Decoded::Forbidden { msg } => Self::Forbidden { msg },
97 }
98 }
99}
100
101impl Classify for SpotError {
113 fn class(&self) -> Class {
114 match self {
115 Self::Api(err) => err.class(),
116 Self::Venue { status, code, .. } => venue_class(*status, *code),
117 Self::RateLimited { retry_after } => {
118 Class::RateLimited { retry_after: None }.with_retry_after(*retry_after)
119 }
120 Self::IpBanned { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => {
121 Class::Restricted
122 }
123 }
124 }
125
126 fn is_fault(&self) -> bool {
127 match self {
128 Self::Api(err) => err.is_fault(),
129 Self::RegionBlocked { .. } => false,
130 Self::Venue { status, .. } => *status != 451,
133 Self::RateLimited { .. } | Self::IpBanned { .. } | Self::Forbidden { .. } => true,
134 }
135 }
136
137 fn retry_after(&self) -> Option<Duration> {
138 match self {
139 Self::Api(err) => Classify::retry_after(err),
140 Self::RateLimited { retry_after } | Self::IpBanned { retry_after } => {
141 retry_after.filter(|wait| !wait.is_zero())
142 }
143 Self::Venue { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => None,
144 }
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use polyoxide_core::ErrorResponse;
151 use polyoxide_test_support::fixtures;
152
153 use super::*;
154 use crate::spot::weight::MAX_COOLDOWN;
155
156 fn response(status: u16, retry_after: Option<&str>, body: &str) -> ApiError {
158 let mut headers = polyoxide_core::http::header::HeaderMap::new();
159 if let Some(value) = retry_after {
160 headers.insert(
161 polyoxide_core::http::header::RETRY_AFTER,
162 value.parse().unwrap(),
163 );
164 }
165 ErrorResponse::new(
166 polyoxide_core::http::StatusCode::from_u16(status).unwrap(),
167 headers,
168 body,
169 )
170 .into()
171 }
172
173 fn parts(status: u16, retry_after: Option<&str>, body: &str) -> SpotError {
175 SpotError::from(response(status, retry_after, body))
176 }
177
178 #[test]
179 fn the_captured_invalid_symbol_body_is_a_venue_error_with_its_code() {
180 let body = fixtures!("spot/rest").text("error_invalid_symbol");
183 let err = parts(400, None, &body);
184 assert!(
185 matches!(&err, SpotError::Venue { status: 400, code: -1121, msg } if msg == "Invalid symbol."),
186 "{err:?}"
187 );
188 assert_eq!(err.code(), Some(-1121));
189 assert!(!err.is_retriable());
190 }
191
192 #[test]
193 fn statuses_that_decide_the_caller_s_next_step_win_over_the_body() {
194 let body = r#"{"code":-1003,"msg":"Too many requests."}"#;
197 let limited = parts(429, Some("7"), body);
198 assert!(matches!(limited, SpotError::RateLimited { .. }));
199 assert_eq!(limited.retry_after(), Some(Duration::from_secs(7)));
200 assert!(limited.is_retriable());
201
202 let banned = parts(418, Some("120"), body);
203 assert!(matches!(banned, SpotError::IpBanned { .. }));
204 assert_eq!(banned.retry_after(), Some(Duration::from_secs(120)));
205 assert!(!banned.is_retriable());
206
207 let region = parts(451, None, body);
208 assert!(matches!(®ion, SpotError::RegionBlocked { msg } if msg.contains("-1003")));
209 assert!(!region.is_retriable());
210
211 let firewall = parts(403, None, "<html>denied</html>");
212 assert!(matches!(&firewall, SpotError::Forbidden { msg } if msg == "<html>denied</html>"));
213 assert!(!firewall.is_retriable());
214
215 let firewall_json = parts(403, None, body);
216 assert!(matches!(firewall_json, SpotError::Forbidden { .. }));
217 }
218
219 #[test]
220 fn a_body_in_another_shape_is_left_to_core() {
221 let err = parts(502, None, "<html>bad gateway</html>");
222 assert!(matches!(
223 &err,
224 SpotError::Api(ApiError::Response(r)) if r.status.as_u16() == 502
225 ));
226 assert!(err.is_retriable());
227 assert_eq!(err.code(), None);
228 }
229
230 #[test]
231 fn a_core_error_that_is_not_a_response_stays_core_s() {
232 let err = SpotError::from(ApiError::Validation("no symbol".into()));
233 assert!(
234 matches!(err, SpotError::Api(ApiError::Validation(_))),
235 "{err:?}"
236 );
237 assert_eq!(err.class(), Class::InvalidRequest);
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: SpotError| match err {
245 SpotError::Venue { msg, .. }
246 | SpotError::RegionBlocked { msg }
247 | SpotError::Forbidden { msg } => vec![msg],
248 SpotError::Api(ApiError::Response(r)) => vec![r.message, r.body],
249 other => panic!("unexpected {other:?}"),
250 };
251 for err in [
252 parts(400, None, &venue),
253 parts(451, None, &long),
254 parts(403, None, &long),
255 parts(502, None, &long),
256 ] {
257 for msg in kept(err) {
258 assert!(
259 msg.len() <= 512 + "... [truncated]".len(),
260 "kept {} bytes",
261 msg.len()
262 );
263 }
264 }
265 }
266
267 #[test]
268 fn a_core_response_keeps_binance_s_clamped_wait() {
269 let err = parts(503, Some("604800"), "<html>down</html>");
270 match &err {
271 SpotError::Api(ApiError::Response(r)) => {
272 assert_eq!(r.retry_after, Some(MAX_COOLDOWN));
273 }
274 other => panic!("unexpected {other:?}"),
275 }
276 assert_eq!(Classify::retry_after(&err), Some(MAX_COOLDOWN));
277 }
278
279 #[test]
280 fn every_variant_classifies() {
281 let body = r#"{"code":-1121,"msg":"Invalid symbol."}"#;
282 let coded = |status, retry_after| parts(status, retry_after, body);
283 let code = |c: &str| Some(std::sync::Arc::from(c));
284 let week = Some(MAX_COOLDOWN);
285 let rows = [
287 (
288 coded(502, None),
289 Class::Unavailable {
290 code: code("-1121"),
291 },
292 true,
293 None,
294 ),
295 (
296 coded(400, None),
297 Class::VenueRefusal {
298 code: code("-1121"),
299 },
300 true,
301 None,
302 ),
303 (
304 coded(408, None),
305 Class::Unavailable {
306 code: code("-1121"),
307 },
308 true,
309 None,
310 ),
311 (
312 coded(429, Some("7")),
313 Class::RateLimited {
314 retry_after: Some(Duration::from_secs(7)),
315 },
316 true,
317 Some(Duration::from_secs(7)),
318 ),
319 (
320 coded(429, None),
321 Class::RateLimited { retry_after: None },
322 true,
323 None,
324 ),
325 (coded(418, Some("604800")), Class::Restricted, true, week),
327 (coded(451, None), Class::Restricted, false, None),
328 (
331 SpotError::Venue {
332 status: 451,
333 code: -1,
334 msg: "restricted".into(),
335 },
336 Class::Restricted,
337 false,
338 None,
339 ),
340 (coded(403, None), Class::Restricted, true, None),
341 ];
342 for (err, class, fault, wait) in rows {
343 assert_eq!(err.class(), class, "{err:?}");
344 assert_eq!(err.is_fault(), fault, "{err:?}");
345 assert_eq!(Classify::retry_after(&err), wait, "{err:?}");
346 assert_eq!(
347 Classify::is_retriable(&err),
348 class.is_retriable(),
349 "{err:?}"
350 );
351 }
352 }
353
354 #[test]
355 fn spot_error_is_send_sync() {
356 fn assert_send_sync<T: Send + Sync>() {}
357 assert_send_sync::<SpotError>();
358 }
359}