Skip to main content

nautilus_binance/spot/http/
error.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Binance Spot HTTP error types.
17
18use std::{fmt::Display, time::Duration};
19
20use nautilus_network::http::error::HttpClientError;
21
22use crate::common::error::{is_retryable_http_status, is_retryable_venue_code};
23// Re-export unified SBE decode error
24pub use crate::spot::sbe::SbeDecodeError;
25
26/// Binance Spot HTTP client error type.
27#[derive(Debug)]
28pub enum BinanceSpotHttpError {
29    /// Missing API credentials for authenticated request.
30    MissingCredentials,
31    /// Binance API returned an error response.
32    BinanceError {
33        /// Binance error code.
34        code: i64,
35        /// Error message from Binance.
36        message: String,
37        /// HTTP status of the error response.
38        status: u16,
39        /// Venue-advertised minimum retry delay from the `Retry-After` header.
40        retry_after: Option<Duration>,
41    },
42    /// SBE decode error.
43    SbeDecodeError(SbeDecodeError),
44    /// JSON decode error.
45    JsonError(String),
46    /// Response parse error after a venue response was received.
47    ResponseParseError(String),
48    /// Request validation error.
49    ValidationError(String),
50    /// Network or connection error.
51    NetworkError(String),
52    /// Request timed out.
53    Timeout(String),
54    /// Request was canceled.
55    Canceled(String),
56    /// The retry elapsed budget was exhausted.
57    RetryBudgetExceeded(String),
58    /// Unexpected HTTP status code.
59    UnexpectedStatus {
60        /// HTTP status code.
61        status: u16,
62        /// Response body (hex encoded for SBE).
63        body: String,
64        /// Venue-advertised minimum retry delay from the `Retry-After` header.
65        retry_after: Option<Duration>,
66    },
67}
68
69impl BinanceSpotHttpError {
70    /// Returns `true` if the error is transient and the operation can be retried.
71    ///
72    /// Retryability is independent of command-outcome classification: a retryable error on a
73    /// state-changing command still leaves an unknown outcome at the execution boundary.
74    #[must_use]
75    pub fn is_retryable(&self) -> bool {
76        match self {
77            Self::NetworkError(_) | Self::Timeout(_) => true,
78            Self::BinanceError { code, status, .. } => {
79                is_retryable_venue_code(*code) || is_retryable_http_status(*status)
80            }
81            Self::UnexpectedStatus { status, .. } => is_retryable_http_status(*status),
82            _ => false,
83        }
84    }
85
86    /// Returns the venue-advertised minimum retry delay, when present.
87    #[must_use]
88    pub fn retry_after(&self) -> Option<Duration> {
89        match self {
90            Self::BinanceError { retry_after, .. } | Self::UnexpectedStatus { retry_after, .. } => {
91                *retry_after
92            }
93            _ => None,
94        }
95    }
96}
97
98impl Display for BinanceSpotHttpError {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Self::MissingCredentials => write!(f, "Missing API credentials"),
102            Self::BinanceError {
103                code,
104                message,
105                status,
106                ..
107            } => {
108                write!(f, "Binance error {code} (HTTP {status}): {message}")
109            }
110            Self::SbeDecodeError(err) => write!(f, "SBE decode error: {err}"),
111            Self::JsonError(msg) => write!(f, "JSON decode error: {msg}"),
112            Self::ResponseParseError(msg) => write!(f, "Response parse error: {msg}"),
113            Self::ValidationError(msg) => write!(f, "Validation error: {msg}"),
114            Self::NetworkError(msg) => write!(f, "Network error: {msg}"),
115            Self::Timeout(msg) => write!(f, "Timeout: {msg}"),
116            Self::Canceled(msg) => write!(f, "Canceled: {msg}"),
117            Self::RetryBudgetExceeded(msg) => write!(f, "Retry budget exceeded: {msg}"),
118            Self::UnexpectedStatus { status, body, .. } => {
119                write!(f, "Unexpected status {status}: {body}")
120            }
121        }
122    }
123}
124
125impl std::error::Error for BinanceSpotHttpError {}
126
127impl From<SbeDecodeError> for BinanceSpotHttpError {
128    fn from(err: SbeDecodeError) -> Self {
129        Self::SbeDecodeError(err)
130    }
131}
132
133impl From<anyhow::Error> for BinanceSpotHttpError {
134    fn from(err: anyhow::Error) -> Self {
135        Self::NetworkError(err.to_string())
136    }
137}
138
139impl From<HttpClientError> for BinanceSpotHttpError {
140    fn from(err: HttpClientError) -> Self {
141        match err {
142            HttpClientError::TimeoutError(msg) => Self::Timeout(msg),
143            HttpClientError::InvalidProxy(msg) | HttpClientError::ClientBuildError(msg) => {
144                Self::NetworkError(msg)
145            }
146            HttpClientError::Error(msg) | HttpClientError::TransportError(msg) => {
147                Self::NetworkError(msg)
148            }
149        }
150    }
151}
152
153/// Result type for Binance Spot HTTP operations.
154pub type BinanceSpotHttpResult<T> = Result<T, BinanceSpotHttpError>;
155
156#[cfg(test)]
157mod tests {
158    use rstest::rstest;
159
160    use super::*;
161
162    fn venue_error(code: i64, status: u16) -> BinanceSpotHttpError {
163        BinanceSpotHttpError::BinanceError {
164            code,
165            message: "venue message".to_string(),
166            status,
167            retry_after: None,
168        }
169    }
170
171    #[rstest]
172    #[case::network(BinanceSpotHttpError::NetworkError("connection reset".to_string()), true)]
173    #[case::timeout(BinanceSpotHttpError::Timeout("timed out".to_string()), true)]
174    #[case::missing_credentials(BinanceSpotHttpError::MissingCredentials, false)]
175    #[case::validation(BinanceSpotHttpError::ValidationError("bad param".to_string()), false)]
176    #[case::canceled(BinanceSpotHttpError::Canceled("shutdown".to_string()), false)]
177    #[case::budget(
178        BinanceSpotHttpError::RetryBudgetExceeded("exceeded".to_string()),
179        false
180    )]
181    #[case::parse(BinanceSpotHttpError::ResponseParseError("bad body".to_string()), false)]
182    fn test_is_retryable_transport_and_local(
183        #[case] error: BinanceSpotHttpError,
184        #[case] expected: bool,
185    ) {
186        assert_eq!(error.is_retryable(), expected);
187    }
188
189    #[rstest]
190    #[case::rate_limit_code(venue_error(-1003, 429), true)]
191    #[case::order_rate_limit_code(venue_error(-1015, 400), true)]
192    #[case::status_500(venue_error(-1000, 500), true)]
193    #[case::status_418(venue_error(-1003, 418), true)]
194    #[case::permanent_venue_code(venue_error(-1100, 400), false)]
195    #[case::auth_code(venue_error(-2015, 401), false)]
196    #[case::timestamp_drift(venue_error(-1021, 400), false)]
197    fn test_is_retryable_venue_errors(#[case] error: BinanceSpotHttpError, #[case] expected: bool) {
198        assert_eq!(error.is_retryable(), expected);
199    }
200
201    #[rstest]
202    #[case::status_429(BinanceSpotHttpError::UnexpectedStatus {
203        status: 429,
204        body: "rate limited".to_string(),
205        retry_after: None,
206    }, true)]
207    #[case::status_500(BinanceSpotHttpError::UnexpectedStatus {
208        status: 500,
209        body: "internal server error".to_string(),
210        retry_after: None,
211    }, true)]
212    #[case::status_401(BinanceSpotHttpError::UnexpectedStatus {
213        status: 401,
214        body: "unauthorized".to_string(),
215        retry_after: None,
216    }, false)]
217    #[case::status_400(BinanceSpotHttpError::UnexpectedStatus {
218        status: 400,
219        body: "bad request".to_string(),
220        retry_after: None,
221    }, false)]
222    fn test_is_retryable_unexpected_status(
223        #[case] error: BinanceSpotHttpError,
224        #[case] expected: bool,
225    ) {
226        assert_eq!(error.is_retryable(), expected);
227    }
228
229    #[rstest]
230    fn test_retry_after_accessor() {
231        let delay = Duration::from_secs(2);
232        let error = BinanceSpotHttpError::BinanceError {
233            code: -1003,
234            message: "Too many requests".to_string(),
235            status: 429,
236            retry_after: Some(delay),
237        };
238        assert_eq!(error.retry_after(), Some(delay));
239        assert_eq!(
240            BinanceSpotHttpError::NetworkError("x".to_string()).retry_after(),
241            None
242        );
243    }
244
245    #[rstest]
246    fn test_display_includes_status() {
247        let err = venue_error(-1100, 400);
248        let msg = err.to_string();
249        assert!(msg.contains("-1100"));
250        assert!(msg.contains("400"));
251    }
252}