Skip to main content

polyoxide_binance/usdm/
error.rs

1//! Error types for the USDⓈ-M futures API.
2
3use 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/// Error type for USDⓈ-M futures API operations.
13#[derive(Error, Debug)]
14#[non_exhaustive]
15pub enum UsdmError {
16    /// Transport, decoding, or an error body in a shape Binance does not use.
17    #[error(transparent)]
18    Api(ApiError),
19
20    /// Binance refused the request with its `{"code", "msg"}` body: an unknown
21    /// symbol is `400` with code `-1121`.
22    #[error("binance answered {status}: {code} {msg}")]
23    Venue {
24        /// HTTP status.
25        status: u16,
26        /// Binance's error code, always negative.
27        code: i64,
28        /// Binance's message, clipped to 512 bytes.
29        msg: String,
30    },
31
32    /// Still answered `429` after the retry schedule ran out.
33    #[error("binance rate limit (429), retry after {retry_after:?}")]
34    RateLimited {
35        /// The response's `Retry-After`.
36        retry_after: Option<Duration>,
37    },
38
39    /// `418`: Binance has banned this IP for continuing after a `429`. Every
40    /// request on the same [`WeightBudget`](crate::usdm::WeightBudget) waits
41    /// until the ban lifts.
42    #[error("binance has banned this IP (418), retry after {retry_after:?}")]
43    IpBanned {
44        /// The response's `Retry-After`. The docs give bans of 2 minutes to 3 days.
45        retry_after: Option<Duration>,
46    },
47
48    /// `451`: Binance does not serve the caller's location.
49    #[error("binance does not serve this location (451): {msg}")]
50    RegionBlocked {
51        /// The response body, clipped to 512 bytes.
52        msg: String,
53    },
54
55    /// `403`: Binance's web application firewall refused the request.
56    #[error("binance's firewall refused the request (403): {msg}")]
57    Forbidden {
58        /// The response body, clipped to 512 bytes.
59        msg: String,
60    },
61}
62
63#[derive(Deserialize)]
64struct VenueBody {
65    code: i64,
66    msg: String,
67}
68
69impl UsdmError {
70    /// Binance's error code, for a [`Venue`](Self::Venue) error.
71    pub fn code(&self) -> Option<i64> {
72        match self {
73            Self::Venue { code, .. } => Some(*code),
74            _ => None,
75        }
76    }
77}
78
79/// Binance's one decode: an unsuccessful response, by status first.
80///
81/// `418`, `429`, `451` and `403` go by status alone: their bodies were not
82/// observed from a test IP, and the status is what decides what a caller
83/// should do. Anything else with Binance's `{code, msg}` body is
84/// [`Venue`](UsdmError::Venue); any other body stays core's
85/// [`ApiError::Response`], its message and body clipped and its `Retry-After`
86/// clamped to 3 days, as every arm reads it. Every other [`ApiError`] stays
87/// core's.
88///
89/// A `418`'s body is Binance's `-1003` text, which names when the ban ends.
90/// The error's fields have no room for it, so it is logged at WARN under
91/// `polyoxide_binance::usdm::error`; the send loop warns of the hold itself,
92/// naming the path.
93impl 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                // Core read the whole body for its `error` or `message`
130                // field; only what is kept is clipped. The wait is Binance's
131                // reading, clamped as the other arms clamp it.
132                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
143/// Parses `Retry-After` with [`polyoxide_venue::parse_retry_after`], clamped
144/// to [`MAX_COOLDOWN`]: seconds, whole or fractional, and `None` for zero,
145/// negative, non-finite and unparsable values.
146pub(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
154/// `Api` delegates, and `Venue` follows the status rule with Binance's code
155/// in decimal. A `429` is `RateLimited`, and a ban, a region block and a
156/// firewall refusal are all `Restricted`: Binance documents its `403` as the
157/// firewall's, not as a credential failure (D14). So a `429`, and a `408`,
158/// `425` or 5xx however its body is shaped, are retriable, and a ban, a region
159/// block and a firewall refusal are not: sending again does not change them,
160/// and sending after a `418` lengthens the ban.
161///
162/// A region block is the venue answering as designed, so it is not a fault. A
163/// `429`'s or a ban's [`Classify::retry_after`] is its `Retry-After`, clamped
164/// to 3 days: for a ban, the time it lifts.
165impl 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            // Unreachable today, since a 451 is `RegionBlocked`, but a region
186            // block whichever variant carries it.
187            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    /// A response with `status`, `body` and, when given, a `Retry-After`.
208    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    /// That response, through Binance's decode.
225    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        // Captured 2026-10-07 from `GET /fapi/v1/ticker/24hr?symbol=NOTASYMBOLUSDT`.
232        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        // A 429 carries a {code, msg} body too; it must still be a rate limit,
243        // not a venue error a retry policy would treat as permanent.
244        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!(&region, 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        // Every text the error keeps: a core response keeps its body too.
292        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        // A status classifies the same whether or not the body had Binance's
328        // shape, so a retry policy does not depend on which layer answered.
329        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        // One reading of `Retry-After` in every arm: Binance's, clamped to 3
344        // days, where core's own reading is unclamped.
345        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        // A week is longer than any documented ban; it is clamped, not trusted.
367        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        // (error, class, is_fault, retry_after())
377        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            // A ban says when it lifts, though its class has no wait of its own.
423            (coded(418, Some("604800")), Class::Restricted, true, week),
424            (coded(451, None), Class::Restricted, false, None),
425            // Built by hand: a 451 always becomes `RegionBlocked`, but a region
426            // block is not a fault whichever variant carries it.
427            (
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}