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