Skip to main content

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