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};
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/// Error type for USDⓈ-M futures API operations.
19#[derive(Error, Debug)]
20#[non_exhaustive]
21pub enum UsdmError {
22    /// Transport, decoding, or an error body in a shape Binance does not use.
23    #[error(transparent)]
24    Api(ApiError),
25
26    /// Binance refused the request with its `{"code", "msg"}` body: an unknown
27    /// symbol is `400` with code `-1121`.
28    #[error("binance answered {status}: {code} {msg}")]
29    Venue {
30        /// HTTP status.
31        status: u16,
32        /// Binance's error code, always negative.
33        code: i64,
34        /// Binance's message, clipped to 512 bytes.
35        msg: String,
36    },
37
38    /// Still answered `429` after the retry schedule ran out.
39    #[error("binance rate limit (429), retry after {retry_after:?}")]
40    RateLimited {
41        /// The response's `Retry-After`.
42        retry_after: Option<Duration>,
43    },
44
45    /// `418`: Binance has banned this IP for continuing after a `429`. Every
46    /// request on the same [`WeightBudget`](crate::usdm::WeightBudget) waits
47    /// until the ban lifts.
48    #[error("binance has banned this IP (418), retry after {retry_after:?}")]
49    IpBanned {
50        /// The response's `Retry-After`. The docs give bans of 2 minutes to 3 days.
51        retry_after: Option<Duration>,
52    },
53
54    /// `451`: Binance does not serve the caller's location.
55    #[error("binance does not serve this location (451): {msg}")]
56    RegionBlocked {
57        /// The response body, clipped to 512 bytes.
58        msg: String,
59    },
60
61    /// `403`: Binance's web application firewall refused the request.
62    #[error("binance's firewall refused the request (403): {msg}")]
63    Forbidden {
64        /// The response body, clipped to 512 bytes.
65        msg: String,
66    },
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 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
109/// `Api` delegates, and `Venue` follows the status rule with Binance's code
110/// in decimal. A `429` is `RateLimited`, and a ban, a region block and a
111/// firewall refusal are all `Restricted`: Binance documents its `403` as the
112/// firewall's, not as a credential failure (D14). So a `429`, and a `408`,
113/// `425` or 5xx however its body is shaped, are retriable, and a ban, a region
114/// block and a firewall refusal are not: sending again does not change them,
115/// and sending after a `418` lengthens the ban.
116///
117/// A region block is the venue answering as designed, so it is not a fault. A
118/// `429`'s or a ban's [`Classify::retry_after`] is its `Retry-After`, clamped
119/// to 3 days: for a ban, the time it lifts.
120impl 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            // Unreachable today, since a 451 is `RegionBlocked`, but a region
139            // block whichever variant carries it.
140            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    /// A response with `status`, `body` and, when given, a `Retry-After`.
161    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    /// That response, through Binance's decode.
178    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        // Captured 2026-10-07 from `GET /fapi/v1/ticker/24hr?symbol=NOTASYMBOLUSDT`.
185        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        // A 429 carries a {code, msg} body too; it must still be a rate limit,
196        // not a venue error a retry policy would treat as permanent.
197        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!(&region, 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        // Every text the error keeps: a core response keeps its body too.
245        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        // A status classifies the same whether or not the body had Binance's
281        // shape, so a retry policy does not depend on which layer answered.
282        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        // One reading of `Retry-After` in every arm: Binance's, clamped to 3
297        // days, where core's own reading is unclamped.
298        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        // A week is longer than any documented ban; it is clamped, not trusted.
320        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        // (error, class, is_fault, retry_after())
330        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            // A ban says when it lifts, though its class has no wait of its own.
376            (coded(418, Some("604800")), Class::Restricted, true, week),
377            (coded(451, None), Class::Restricted, false, None),
378            // Built by hand: a 451 always becomes `RegionBlocked`, but a region
379            // block is not a fault whichever variant carries it.
380            (
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}