nautilus_bitmex/
error.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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//! Unified error handling for the BitMEX adapter.
17//!
18//! This module provides a comprehensive error taxonomy that distinguishes between
19//! retryable, non-retryable, and fatal errors, with proper context preservation
20//! for debugging and operational monitoring.
21
22use std::time::Duration;
23
24use nautilus_network::http::{HttpClientError, StatusCode};
25use thiserror::Error;
26use tokio_tungstenite::tungstenite;
27
28/// The main error type for all BitMEX adapter operations.
29#[derive(Debug, Error)]
30pub enum BitmexError {
31    /// Errors that should be retried with backoff.
32    #[error("Retryable error: {source}")]
33    Retryable {
34        #[source]
35        source: BitmexRetryableError,
36        /// Suggested retry after duration, if provided by the server.
37        retry_after: Option<Duration>,
38    },
39
40    /// Errors that should not be retried.
41    #[error("Non-retryable error: {source}")]
42    NonRetryable {
43        #[source]
44        source: BitmexNonRetryableError,
45    },
46
47    /// Fatal errors that require intervention.
48    #[error("Fatal error: {source}")]
49    Fatal {
50        #[source]
51        source: BitmexFatalError,
52    },
53
54    /// Network transport errors.
55    #[error("Network error: {0}")]
56    Network(#[from] HttpClientError),
57
58    /// WebSocket specific errors.
59    #[error("WebSocket error: {0}")]
60    WebSocket(#[from] tungstenite::Error),
61
62    /// JSON serialization/deserialization errors.
63    #[error("JSON error: {message}")]
64    Json {
65        message: String,
66        /// The raw JSON that failed to parse, if available.
67        raw: Option<String>,
68    },
69
70    /// Configuration errors.
71    #[error("Configuration error: {0}")]
72    Config(String),
73}
74
75/// Errors that should be retried with appropriate backoff.
76#[derive(Debug, Error)]
77pub enum BitmexRetryableError {
78    /// Rate limit exceeded (HTTP 429).
79    #[error("Rate limit exceeded (remaining: {remaining:?}, reset: {reset_at:?})")]
80    RateLimit {
81        remaining: Option<u32>,
82        reset_at: Option<Duration>,
83    },
84
85    /// Service unavailable (HTTP 503).
86    #[error("Service temporarily unavailable")]
87    ServiceUnavailable,
88
89    /// Gateway timeout (HTTP 504).
90    #[error("Gateway timeout")]
91    GatewayTimeout,
92
93    /// Server error (HTTP 5xx).
94    #[error("Server error (status: {status})")]
95    ServerError { status: StatusCode },
96
97    /// Network timeout.
98    #[error("Request timed out after {duration:?}")]
99    Timeout { duration: Duration },
100
101    /// Temporary network issue.
102    #[error("Temporary network error: {message}")]
103    TemporaryNetwork { message: String },
104
105    /// WebSocket connection lost.
106    #[error("WebSocket connection lost")]
107    ConnectionLost,
108
109    /// Order book resync required.
110    #[error("Order book resync required for {symbol}")]
111    OrderBookResync { symbol: String },
112}
113
114/// Errors that should not be retried.
115#[derive(Debug, Error)]
116pub enum BitmexNonRetryableError {
117    /// Bad request (HTTP 400).
118    #[error("Bad request: {message}")]
119    BadRequest { message: String },
120
121    /// Not found (HTTP 404).
122    #[error("Resource not found: {resource}")]
123    NotFound { resource: String },
124
125    /// Method not allowed (HTTP 405).
126    #[error("Method not allowed: {method}")]
127    MethodNotAllowed { method: String },
128
129    /// Validation error.
130    #[error("Validation error: {field}: {message}")]
131    Validation { field: String, message: String },
132
133    /// Invalid order parameters.
134    #[error("Invalid order: {message}")]
135    InvalidOrder { message: String },
136
137    /// Insufficient balance.
138    #[error("Insufficient balance: {available} < {required}")]
139    InsufficientBalance { available: String, required: String },
140
141    /// Symbol not found or invalid.
142    #[error("Invalid symbol: {symbol}")]
143    InvalidSymbol { symbol: String },
144
145    /// Invalid API request format.
146    #[error("Invalid request format: {message}")]
147    InvalidRequest { message: String },
148
149    /// Missing required parameter.
150    #[error("Missing required parameter: {param}")]
151    MissingParameter { param: String },
152
153    /// Order not found.
154    #[error("Order not found: {order_id}")]
155    OrderNotFound { order_id: String },
156
157    /// Position not found.
158    #[error("Position not found: {symbol}")]
159    PositionNotFound { symbol: String },
160}
161
162/// Fatal errors that require manual intervention.
163#[derive(Debug, Error)]
164pub enum BitmexFatalError {
165    /// Authentication failed (HTTP 401).
166    #[error("Authentication failed: {message}")]
167    AuthenticationFailed { message: String },
168
169    /// Forbidden (HTTP 403).
170    #[error("Forbidden: {message}")]
171    Forbidden { message: String },
172
173    /// Account suspended.
174    #[error("Account suspended: {reason}")]
175    AccountSuspended { reason: String },
176
177    /// Invalid API credentials.
178    #[error("Invalid API credentials")]
179    InvalidCredentials,
180
181    /// API version no longer supported.
182    #[error("API version no longer supported")]
183    ApiVersionDeprecated,
184
185    /// Critical invariant violation.
186    #[error("Critical invariant violation: {invariant}")]
187    InvariantViolation { invariant: String },
188}
189
190impl BitmexError {
191    /// Creates a new rate limit error from HTTP headers.
192    ///
193    /// # Parameters
194    ///
195    /// - `remaining`: X-RateLimit-Remaining header value
196    /// - `reset`: X-RateLimit-Reset header value (UNIX timestamp in seconds)
197    /// - `retry_after`: Retry-After header value (seconds to wait)
198    pub fn from_rate_limit_headers(
199        remaining: Option<&str>,
200        reset: Option<&str>,
201        retry_after: Option<&str>,
202    ) -> Self {
203        let remaining = remaining.and_then(|s| s.parse().ok());
204
205        // X-RateLimit-Reset is a UNIX timestamp, compute duration from now
206        let reset_at = reset.and_then(|s| {
207            s.parse::<u64>().ok().and_then(|timestamp| {
208                let now = std::time::SystemTime::now()
209                    .duration_since(std::time::UNIX_EPOCH)
210                    .ok()?
211                    .as_secs();
212                if timestamp > now {
213                    Some(Duration::from_secs(timestamp - now))
214                } else {
215                    Some(Duration::from_secs(0))
216                }
217            })
218        });
219
220        // Prefer explicit Retry-After header if present
221        let retry_duration = retry_after
222            .and_then(|s| s.parse::<u64>().ok().map(Duration::from_secs))
223            .or(reset_at);
224
225        Self::Retryable {
226            source: BitmexRetryableError::RateLimit {
227                remaining,
228                reset_at,
229            },
230            retry_after: retry_duration,
231        }
232    }
233
234    /// Creates an error from an HTTP status code and optional message.
235    pub fn from_http_status(status: StatusCode, message: Option<String>) -> Self {
236        match status {
237            StatusCode::BAD_REQUEST => Self::NonRetryable {
238                source: BitmexNonRetryableError::BadRequest {
239                    message: message.unwrap_or_else(|| "Bad request".to_string()),
240                },
241            },
242            StatusCode::UNAUTHORIZED => Self::Fatal {
243                source: BitmexFatalError::AuthenticationFailed {
244                    message: message.unwrap_or_else(|| "Unauthorized".to_string()),
245                },
246            },
247            StatusCode::FORBIDDEN => Self::Fatal {
248                source: BitmexFatalError::Forbidden {
249                    message: message.unwrap_or_else(|| "Forbidden".to_string()),
250                },
251            },
252            StatusCode::NOT_FOUND => Self::NonRetryable {
253                source: BitmexNonRetryableError::NotFound {
254                    resource: message.unwrap_or_else(|| "Resource".to_string()),
255                },
256            },
257            StatusCode::METHOD_NOT_ALLOWED => Self::NonRetryable {
258                source: BitmexNonRetryableError::MethodNotAllowed {
259                    method: message.unwrap_or_else(|| "Method".to_string()),
260                },
261            },
262            StatusCode::TOO_MANY_REQUESTS => Self::from_rate_limit_headers(None, None, None),
263            StatusCode::SERVICE_UNAVAILABLE => Self::Retryable {
264                source: BitmexRetryableError::ServiceUnavailable,
265                retry_after: None,
266            },
267            StatusCode::GATEWAY_TIMEOUT => Self::Retryable {
268                source: BitmexRetryableError::GatewayTimeout,
269                retry_after: None,
270            },
271            s if s.is_server_error() => Self::Retryable {
272                source: BitmexRetryableError::ServerError { status },
273                retry_after: None,
274            },
275            _ => Self::NonRetryable {
276                source: BitmexNonRetryableError::InvalidRequest {
277                    message: format!("Unexpected status: {status}"),
278                },
279            },
280        }
281    }
282
283    /// Checks if this error is retryable.
284    pub fn is_retryable(&self) -> bool {
285        matches!(self, Self::Retryable { .. })
286    }
287
288    /// Checks if this error is fatal.
289    pub fn is_fatal(&self) -> bool {
290        matches!(self, Self::Fatal { .. })
291    }
292
293    /// Gets the suggested retry duration if available.
294    pub fn retry_after(&self) -> Option<Duration> {
295        match self {
296            Self::Retryable { retry_after, .. } => *retry_after,
297            _ => None,
298        }
299    }
300}
301
302// Re-export for backward compatibility during migration
303pub use crate::http::error::BitmexBuildError;
304
305impl From<serde_json::Error> for BitmexError {
306    fn from(error: serde_json::Error) -> Self {
307        Self::Json {
308            message: error.to_string(),
309            raw: None,
310        }
311    }
312}
313
314impl From<BitmexBuildError> for BitmexError {
315    fn from(error: BitmexBuildError) -> Self {
316        Self::NonRetryable {
317            source: BitmexNonRetryableError::Validation {
318                field: "parameters".to_string(),
319                message: error.to_string(),
320            },
321        }
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use rstest::rstest;
328
329    use super::*;
330
331    #[rstest]
332    fn test_error_classification() {
333        let err = BitmexError::from_http_status(StatusCode::TOO_MANY_REQUESTS, None);
334        assert!(err.is_retryable());
335        assert!(!err.is_fatal());
336
337        let err = BitmexError::from_http_status(StatusCode::UNAUTHORIZED, None);
338        assert!(!err.is_retryable());
339        assert!(err.is_fatal());
340
341        let err = BitmexError::from_http_status(StatusCode::BAD_REQUEST, None);
342        assert!(!err.is_retryable());
343        assert!(!err.is_fatal());
344    }
345
346    #[rstest]
347    fn test_rate_limit_parsing() {
348        // Use a timestamp far in the future to ensure retry_after is computed
349        let future_timestamp = std::time::SystemTime::now()
350            .duration_since(std::time::UNIX_EPOCH)
351            .unwrap()
352            .as_secs()
353            + 60;
354        let err = BitmexError::from_rate_limit_headers(
355            Some("10"),
356            Some(&future_timestamp.to_string()),
357            None,
358        );
359        match err {
360            BitmexError::Retryable {
361                source: BitmexRetryableError::RateLimit { remaining, .. },
362                retry_after,
363                ..
364            } => {
365                assert_eq!(remaining, Some(10));
366                assert!(retry_after.is_some());
367                let duration = retry_after.unwrap();
368                assert!(duration.as_secs() >= 59 && duration.as_secs() <= 61);
369            }
370            _ => panic!("Expected rate limit error"),
371        }
372    }
373
374    #[rstest]
375    fn test_rate_limit_with_retry_after() {
376        let err = BitmexError::from_rate_limit_headers(Some("0"), None, Some("30"));
377        match err {
378            BitmexError::Retryable {
379                source: BitmexRetryableError::RateLimit { remaining, .. },
380                retry_after,
381                ..
382            } => {
383                assert_eq!(remaining, Some(0));
384                assert_eq!(retry_after, Some(Duration::from_secs(30)));
385            }
386            _ => panic!("Expected rate limit error"),
387        }
388    }
389
390    #[rstest]
391    fn test_retry_after() {
392        let err = BitmexError::Retryable {
393            source: BitmexRetryableError::RateLimit {
394                remaining: Some(0),
395                reset_at: Some(Duration::from_secs(60)),
396            },
397            retry_after: Some(Duration::from_secs(60)),
398        };
399        assert_eq!(err.retry_after(), Some(Duration::from_secs(60)));
400    }
401}