Skip to main content

polyoxide_binance/spot/
error.rs

1//! Error types for Binance spot.
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/// Error type for Binance spot API operations.
12#[derive(Error, Debug)]
13#[non_exhaustive]
14pub enum SpotError {
15    /// Transport, decoding, or an error body in a shape Binance does not use.
16    #[error(transparent)]
17    Api(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::spot::WeightBudget) waits
40    /// until the 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
62impl SpotError {
63    /// Binance's error code, for a [`Venue`](Self::Venue) error.
64    pub fn code(&self) -> Option<i64> {
65        match self {
66            Self::Venue { code, .. } => Some(*code),
67            _ => None,
68        }
69    }
70}
71
72/// Binance's one decode for spot: an unsuccessful response, by status first,
73/// through the crate's shared decode.
74///
75/// `418`, `429`, `451` and `403` go by status alone; anything else with
76/// Binance's `{code, msg}` body is [`Venue`](SpotError::Venue); any other body
77/// stays core's [`ApiError::Response`], its message and body clipped and its
78/// `Retry-After` clamped to 3 days, as every arm reads it. Every other
79/// [`ApiError`] stays core's.
80///
81/// A `418`'s body is Binance's `-1003` text, which names when the ban ends.
82/// The error's fields have no room for it, so it is logged at WARN under
83/// `polyoxide_binance::spot::error`; the send loop warns of the hold itself,
84/// naming the path.
85impl 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
101/// `Api` delegates, and `Venue` follows the status rule with Binance's code
102/// in decimal. A `429` is `RateLimited`, and a ban, a region block and a
103/// firewall refusal are all `Restricted`: Binance documents its `403` as the
104/// firewall's, not as a credential failure (D14). So a `429`, and a `408`,
105/// `425` or 5xx however its body is shaped, are retriable, and a ban, a region
106/// block and a firewall refusal are not: sending again does not change them,
107/// and sending after a `418` lengthens the ban.
108///
109/// A region block is the venue answering as designed, so it is not a fault. A
110/// `429`'s or a ban's [`Classify::retry_after`] is its `Retry-After`, clamped
111/// to 3 days: for a ban, the time it lifts.
112impl 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            // Unreachable today, since a 451 is `RegionBlocked`, but a region
131            // block whichever variant carries it.
132            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    /// A response with `status`, `body` and, when given, a `Retry-After`.
157    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    /// That response, through the spot module's decode.
174    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        // Captured from production: `GET /api/v3/exchangeInfo?symbol=NOPEUSDT`
181        // answers 400 (`tests/fixtures/spot/PROVENANCE.md`).
182        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        // A 429 carries a {code, msg} body too; it must still be a rate limit,
195        // not a venue error a retry policy would treat as permanent.
196        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!(&region, 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        // (error, class, is_fault, retry_after())
286        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            // A ban says when it lifts, though its class has no wait of its own.
326            (coded(418, Some("604800")), Class::Restricted, true, week),
327            (coded(451, None), Class::Restricted, false, None),
328            // Built by hand: a 451 always becomes `RegionBlocked`, but a region
329            // block is not a fault whichever variant carries it.
330            (
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}