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
215                if timestamp > now {
216                    Some(Duration::from_secs(timestamp - now))
217                } else {
218                    Some(Duration::from_secs(0))
219                }
220            })
221        });
222
223        // Prefer explicit Retry-After header if present
224        let retry_duration = retry_after
225            .and_then(|s| s.parse::<u64>().ok().map(Duration::from_secs))
226            .or(reset_at);
227
228        Self::Retryable {
229            source: BitmexRetryableError::RateLimit {
230                remaining,
231                reset_at,
232            },
233            retry_after: retry_duration,
234        }
235    }
236
237    /// Creates an error from an HTTP status code and optional message.
238    pub fn from_http_status(status: StatusCode, message: Option<String>) -> Self {
239        match status {
240            StatusCode::BAD_REQUEST => Self::NonRetryable {
241                source: BitmexNonRetryableError::BadRequest {
242                    message: message.unwrap_or_else(|| "Bad request".to_string()),
243                },
244            },
245            StatusCode::UNAUTHORIZED => Self::Fatal {
246                source: BitmexFatalError::AuthenticationFailed {
247                    message: message.unwrap_or_else(|| "Unauthorized".to_string()),
248                },
249            },
250            StatusCode::FORBIDDEN => Self::Fatal {
251                source: BitmexFatalError::Forbidden {
252                    message: message.unwrap_or_else(|| "Forbidden".to_string()),
253                },
254            },
255            StatusCode::NOT_FOUND => Self::NonRetryable {
256                source: BitmexNonRetryableError::NotFound {
257                    resource: message.unwrap_or_else(|| "Resource".to_string()),
258                },
259            },
260            StatusCode::METHOD_NOT_ALLOWED => Self::NonRetryable {
261                source: BitmexNonRetryableError::MethodNotAllowed {
262                    method: message.unwrap_or_else(|| "Method".to_string()),
263                },
264            },
265            StatusCode::TOO_MANY_REQUESTS => Self::from_rate_limit_headers(None, None, None),
266            StatusCode::SERVICE_UNAVAILABLE => Self::Retryable {
267                source: BitmexRetryableError::ServiceUnavailable,
268                retry_after: None,
269            },
270            StatusCode::GATEWAY_TIMEOUT => Self::Retryable {
271                source: BitmexRetryableError::GatewayTimeout,
272                retry_after: None,
273            },
274            s if s.is_server_error() => Self::Retryable {
275                source: BitmexRetryableError::ServerError { status },
276                retry_after: None,
277            },
278            _ => Self::NonRetryable {
279                source: BitmexNonRetryableError::InvalidRequest {
280                    message: format!("Unexpected status: {status}"),
281                },
282            },
283        }
284    }
285
286    /// Checks if this error is retryable.
287    #[must_use]
288    pub fn is_retryable(&self) -> bool {
289        matches!(self, Self::Retryable { .. })
290    }
291
292    /// Checks if this error is fatal.
293    #[must_use]
294    pub fn is_fatal(&self) -> bool {
295        matches!(self, Self::Fatal { .. })
296    }
297
298    /// Gets the suggested retry duration if available.
299    #[must_use]
300    pub fn retry_after(&self) -> Option<Duration> {
301        match self {
302            Self::Retryable { retry_after, .. } => *retry_after,
303            _ => None,
304        }
305    }
306}
307
308impl From<serde_json::Error> for BitmexError {
309    fn from(error: serde_json::Error) -> Self {
310        Self::Json {
311            message: error.to_string(),
312            raw: None,
313        }
314    }
315}
316
317impl From<BitmexBuildError> for BitmexError {
318    fn from(error: BitmexBuildError) -> Self {
319        Self::NonRetryable {
320            source: BitmexNonRetryableError::Validation {
321                field: "parameters".to_string(),
322                message: error.to_string(),
323            },
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use rstest::rstest;
331
332    use super::*;
333
334    #[rstest]
335    fn test_error_classification() {
336        let err = BitmexError::from_http_status(StatusCode::TOO_MANY_REQUESTS, None);
337        assert!(err.is_retryable());
338        assert!(!err.is_fatal());
339
340        let err = BitmexError::from_http_status(StatusCode::UNAUTHORIZED, None);
341        assert!(!err.is_retryable());
342        assert!(err.is_fatal());
343
344        let err = BitmexError::from_http_status(StatusCode::BAD_REQUEST, None);
345        assert!(!err.is_retryable());
346        assert!(!err.is_fatal());
347    }
348
349    #[rstest]
350    fn test_rate_limit_parsing() {
351        // Use a timestamp far in the future to ensure retry_after is computed
352        let future_timestamp = std::time::SystemTime::now()
353            .duration_since(std::time::UNIX_EPOCH)
354            .unwrap()
355            .as_secs()
356            + 60;
357        let err = BitmexError::from_rate_limit_headers(
358            Some("10"),
359            Some(&future_timestamp.to_string()),
360            None,
361        );
362        match err {
363            BitmexError::Retryable {
364                source: BitmexRetryableError::RateLimit { remaining, .. },
365                retry_after,
366                ..
367            } => {
368                assert_eq!(remaining, Some(10));
369                assert!(retry_after.is_some());
370                let duration = retry_after.unwrap();
371                assert!(duration.as_secs() >= 59 && duration.as_secs() <= 61);
372            }
373            _ => panic!("Expected rate limit error"),
374        }
375    }
376
377    #[rstest]
378    fn test_rate_limit_with_retry_after() {
379        let err = BitmexError::from_rate_limit_headers(Some("0"), None, Some("30"));
380        match err {
381            BitmexError::Retryable {
382                source: BitmexRetryableError::RateLimit { remaining, .. },
383                retry_after,
384                ..
385            } => {
386                assert_eq!(remaining, Some(0));
387                assert_eq!(retry_after, Some(Duration::from_secs(30)));
388            }
389            _ => panic!("Expected rate limit error"),
390        }
391    }
392
393    #[rstest]
394    fn test_retry_after() {
395        let err = BitmexError::Retryable {
396            source: BitmexRetryableError::RateLimit {
397                remaining: Some(0),
398                reset_at: Some(Duration::from_secs(60)),
399            },
400            retry_after: Some(Duration::from_secs(60)),
401        };
402        assert_eq!(err.retry_after(), Some(Duration::from_secs(60)));
403    }
404}