Skip to main content

polyoxide_binance/
error.rs

1//! Error types for the Binance API.
2
3use std::time::Duration;
4
5use polyoxide_core::{truncate_for_log, ApiError};
6use serde::Deserialize;
7use thiserror::Error;
8
9use crate::weight::MAX_COOLDOWN;
10
11/// Error type for Binance API operations.
12#[derive(Error, Debug)]
13#[non_exhaustive]
14pub enum BinanceError {
15    /// Transport, decoding, or an error body in a shape Binance does not use.
16    #[error(transparent)]
17    Api(#[from] ApiError),
18
19    /// Binance refused the request with its `{"code", "msg"}` body: an unknown
20    /// symbol is `400` with code `-1121`.
21    #[error("binance answered {status}: {code} {msg}")]
22    Venue {
23        /// HTTP status.
24        status: u16,
25        /// Binance's error code, always negative.
26        code: i64,
27        /// Binance's message, clipped to 512 bytes.
28        msg: String,
29    },
30
31    /// Still answered `429` after the retry schedule ran out.
32    #[error("binance rate limit (429), retry after {retry_after:?}")]
33    RateLimited {
34        /// The response's `Retry-After`.
35        retry_after: Option<Duration>,
36    },
37
38    /// `418`: Binance has banned this IP for continuing after a `429`. Every
39    /// request on the same [`WeightBudget`](crate::WeightBudget) waits until the
40    /// ban lifts.
41    #[error("binance has banned this IP (418), retry after {retry_after:?}")]
42    IpBanned {
43        /// The response's `Retry-After`. The docs give bans of 2 minutes to 3 days.
44        retry_after: Option<Duration>,
45    },
46
47    /// `451`: Binance does not serve the caller's location.
48    #[error("binance does not serve this location (451): {msg}")]
49    RegionBlocked {
50        /// The response body, clipped to 512 bytes.
51        msg: String,
52    },
53
54    /// `403`: Binance's web application firewall refused the request.
55    #[error("binance's firewall refused the request (403): {msg}")]
56    Forbidden {
57        /// The response body, clipped to 512 bytes.
58        msg: String,
59    },
60}
61
62#[derive(Deserialize)]
63struct VenueBody {
64    code: i64,
65    msg: String,
66}
67
68impl BinanceError {
69    /// Classifies an unsuccessful response.
70    ///
71    /// `418`, `429`, `451` and `403` go by status alone: their bodies were not
72    /// observed from a test IP, and the status is what decides what a caller
73    /// should do. Anything else with Binance's `{code, msg}` body is
74    /// [`Venue`](Self::Venue); any other body is left to core.
75    pub(crate) fn from_response_parts(status: u16, retry_after: Option<&str>, body: &str) -> Self {
76        let retry_after = retry_after_secs(retry_after);
77        match status {
78            418 => Self::IpBanned { retry_after },
79            429 => Self::RateLimited { retry_after },
80            451 => Self::RegionBlocked { msg: clip(body) },
81            403 => Self::Forbidden { msg: clip(body) },
82            _ => match serde_json::from_str::<VenueBody>(body) {
83                Ok(venue) => Self::Venue {
84                    status,
85                    code: venue.code,
86                    msg: clip(&venue.msg),
87                },
88                Err(_) => Self::Api(core_error(status, body)),
89            },
90        }
91    }
92
93    /// Whether re-sending the same request could plausibly succeed.
94    ///
95    /// A `429`, and a `408`, `425` or 5xx however its body is shaped, are, as
96    /// core's `ApiError::is_retriable` says for those statuses. A ban, a region
97    /// block and a firewall refusal are not: sending again does not change
98    /// them, and sending after a `418` lengthens the ban.
99    pub fn is_retriable(&self) -> bool {
100        match self {
101            Self::Api(err) => err.is_retriable(),
102            Self::Venue { status, .. } => matches!(*status, 408 | 425) || *status >= 500,
103            Self::RateLimited { .. } => true,
104            Self::IpBanned { .. } | Self::RegionBlocked { .. } | Self::Forbidden { .. } => false,
105        }
106    }
107
108    /// Binance's error code, for a [`Venue`](Self::Venue) error.
109    pub fn code(&self) -> Option<i64> {
110        match self {
111            Self::Venue { code, .. } => Some(*code),
112            _ => None,
113        }
114    }
115
116    /// The `Retry-After` delay, for a `429` or a `418` that carried one.
117    pub fn retry_after(&self) -> Option<Duration> {
118        match self {
119            Self::RateLimited { retry_after } | Self::IpBanned { retry_after } => *retry_after,
120            _ => None,
121        }
122    }
123}
124
125/// Parses `Retry-After` as seconds, whole or fractional. Zero, negative,
126/// non-finite and unparsable values, and any that round to zero, are `None`;
127/// anything past [`MAX_COOLDOWN`] is clamped to it.
128pub(crate) fn retry_after_secs(value: Option<&str>) -> Option<Duration> {
129    let secs = value?.trim().parse::<f64>().ok()?;
130    if !secs.is_finite() || secs <= 0.0 {
131        return None;
132    }
133    Some(Duration::from_secs_f64(
134        secs.min(MAX_COOLDOWN.as_secs_f64()),
135    ))
136    .filter(|wait| !wait.is_zero())
137}
138
139/// Core's classification of a body in some other shape, read whole so its
140/// `error` or `message` field is found, then clipped.
141fn core_error(status: u16, body: &str) -> ApiError {
142    match ApiError::from_status_and_body(status, body) {
143        ApiError::Api { status, message } => ApiError::Api {
144            status,
145            message: clip(&message),
146        },
147        ApiError::Authentication(message) => ApiError::Authentication(clip(&message)),
148        ApiError::Validation(message) => ApiError::Validation(clip(&message)),
149        ApiError::RateLimit(message) => ApiError::RateLimit(clip(&message)),
150        other => other,
151    }
152}
153
154fn clip(text: &str) -> String {
155    truncate_for_log(text).into_owned()
156}
157
158polyoxide_core::impl_api_error_conversions!(BinanceError);
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn an_unknown_symbol_is_a_venue_error_with_its_code() {
166        // Captured 2026-10-07 from `GET /fapi/v1/ticker/24hr?symbol=NOTASYMBOLUSDT`.
167        let err = BinanceError::from_response_parts(
168            400,
169            None,
170            r#"{"code":-1121,"msg":"Invalid symbol."}"#,
171        );
172        assert!(
173            matches!(&err, BinanceError::Venue { status: 400, code: -1121, msg } if msg == "Invalid symbol.")
174        );
175        assert_eq!(err.code(), Some(-1121));
176        assert!(!err.is_retriable());
177    }
178
179    #[test]
180    fn statuses_that_decide_the_caller_s_next_step_win_over_the_body() {
181        // A 429 carries a {code, msg} body too; it must still be a rate limit,
182        // not a venue error a retry policy would treat as permanent.
183        let body = r#"{"code":-1003,"msg":"Too many requests."}"#;
184        let limited = BinanceError::from_response_parts(429, Some("7"), body);
185        assert!(matches!(limited, BinanceError::RateLimited { .. }));
186        assert_eq!(limited.retry_after(), Some(Duration::from_secs(7)));
187        assert!(limited.is_retriable());
188
189        let banned = BinanceError::from_response_parts(418, Some("120"), body);
190        assert!(matches!(banned, BinanceError::IpBanned { .. }));
191        assert_eq!(banned.retry_after(), Some(Duration::from_secs(120)));
192        assert!(!banned.is_retriable());
193
194        let region = BinanceError::from_response_parts(451, None, body);
195        assert!(matches!(&region, BinanceError::RegionBlocked { msg } if msg.contains("-1003")));
196        assert!(!region.is_retriable());
197
198        let firewall = BinanceError::from_response_parts(403, None, "<html>denied</html>");
199        assert!(
200            matches!(&firewall, BinanceError::Forbidden { msg } if msg == "<html>denied</html>")
201        );
202        assert!(!firewall.is_retriable());
203
204        let firewall_json = BinanceError::from_response_parts(403, None, body);
205        assert!(matches!(firewall_json, BinanceError::Forbidden { .. }));
206    }
207
208    #[test]
209    fn a_5xx_venue_error_is_retriable_and_a_4xx_is_not() {
210        let body = r#"{"code":-1001,"msg":"Internal error; unable to process your request."}"#;
211        assert!(BinanceError::from_response_parts(503, None, body).is_retriable());
212        assert!(BinanceError::from_response_parts(500, None, body).is_retriable());
213        assert!(!BinanceError::from_response_parts(499, None, body).is_retriable());
214        assert!(!BinanceError::from_response_parts(400, None, body).is_retriable());
215    }
216
217    #[test]
218    fn a_body_in_another_shape_is_left_to_core() {
219        let err = BinanceError::from_response_parts(502, None, "<html>bad gateway</html>");
220        assert!(matches!(
221            err,
222            BinanceError::Api(ApiError::Api { status: 502, .. })
223        ));
224        assert!(err.is_retriable());
225        assert_eq!(err.code(), None);
226    }
227
228    #[test]
229    fn every_kept_body_is_clipped() {
230        let long = "x".repeat(10_000);
231        let venue = format!(r#"{{"code":-1121,"msg":"{long}"}}"#);
232        let kept = |err: BinanceError| match err {
233            BinanceError::Venue { msg, .. }
234            | BinanceError::RegionBlocked { msg }
235            | BinanceError::Forbidden { msg } => msg,
236            BinanceError::Api(ApiError::Api { message, .. }) => message,
237            other => panic!("unexpected {other:?}"),
238        };
239        for err in [
240            BinanceError::from_response_parts(400, None, &venue),
241            BinanceError::from_response_parts(451, None, &long),
242            BinanceError::from_response_parts(403, None, &long),
243            BinanceError::from_response_parts(502, None, &long),
244        ] {
245            let msg = kept(err);
246            assert!(
247                msg.len() <= 512 + "... [truncated]".len(),
248                "kept {} bytes",
249                msg.len()
250            );
251        }
252    }
253
254    #[test]
255    fn core_reads_a_long_json_body_before_it_is_clipped() {
256        let body = format!(r#"{{"message":"short","pad":"{}"}}"#, "p".repeat(600));
257        let err = BinanceError::from_response_parts(502, None, &body);
258        assert!(
259            matches!(&err, BinanceError::Api(ApiError::Api { status: 502, message }) if message == "short"),
260            "{err:?}"
261        );
262    }
263
264    #[test]
265    fn venue_and_api_errors_agree_on_retriability() {
266        // A status classifies the same whether or not the body had Binance's
267        // shape, so a retry policy does not depend on which layer answered.
268        let body = r#"{"code":-1,"msg":"x"}"#;
269        for status in [400u16, 401, 404, 408, 409, 425, 500, 502, 503, 504] {
270            let venue = BinanceError::from_response_parts(status, None, body);
271            let api = ApiError::from_status_and_body(status, "x");
272            assert_eq!(venue.is_retriable(), api.is_retriable(), "status {status}");
273        }
274    }
275
276    #[test]
277    fn retry_after_reads_seconds_and_rejects_what_is_not_a_wait() {
278        assert_eq!(retry_after_secs(Some("2")), Some(Duration::from_secs(2)));
279        assert_eq!(
280            retry_after_secs(Some(" 1.5 ")),
281            Some(Duration::from_millis(1500))
282        );
283        for junk in ["0", "-3", "soon", "NaN", "inf", "0.0000000004", "1e400"] {
284            assert_eq!(retry_after_secs(Some(junk)), None, "{junk:?}");
285        }
286        assert_eq!(retry_after_secs(None), None);
287        // A week is longer than any documented ban; it is clamped, not trusted.
288        assert_eq!(retry_after_secs(Some("604800")), Some(MAX_COOLDOWN));
289    }
290
291    #[test]
292    fn binance_error_is_send_sync() {
293        fn assert_send_sync<T: Send + Sync>() {}
294        assert_send_sync::<BinanceError>();
295    }
296}