nautilus_bybit/
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 Bybit 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, HttpResponse};
25use thiserror::Error;
26use tokio_tungstenite::tungstenite;
27
28/// The main error type for all Bybit adapter operations.
29#[derive(Debug, Error)]
30pub enum BybitError {
31    /// Errors that should be retried with backoff.
32    #[error("Retryable error: {source}")]
33    Retryable {
34        #[source]
35        source: BybitRetryableError,
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: BybitNonRetryableError,
45    },
46
47    /// Fatal errors that require intervention.
48    #[error("Fatal error: {source}")]
49    Fatal {
50        #[source]
51        source: BybitFatalError,
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(String),
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 BybitRetryableError {
78    /// Rate limit exceeded (HTTP 429).
79    ///
80    /// Bybit uses X-Bapi-Limit headers for rate limiting information.
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: u16 },
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 BybitNonRetryableError {
119    /// Bad request (HTTP 400).
120    ///
121    /// Bybit returns retCode/retMsg in the response body for errors.
122    #[error("Bad request: {message} (retCode: {ret_code:?})")]
123    BadRequest {
124        message: String,
125        ret_code: Option<i32>,
126    },
127
128    /// Not found (HTTP 404).
129    #[error("Resource not found: {resource}")]
130    NotFound { resource: String },
131
132    /// Method not allowed (HTTP 405).
133    #[error("Method not allowed: {method}")]
134    MethodNotAllowed { method: String },
135
136    /// Validation error.
137    #[error("Validation error: {field}: {message}")]
138    Validation { field: String, message: String },
139
140    /// Invalid order parameters.
141    #[error("Invalid order: {message} (retCode: {ret_code:?})")]
142    InvalidOrder {
143        message: String,
144        ret_code: Option<i32>,
145    },
146
147    /// Insufficient balance.
148    #[error("Insufficient balance: {message}")]
149    InsufficientBalance { message: String },
150
151    /// Symbol not found or invalid.
152    #[error("Invalid symbol: {symbol}")]
153    InvalidSymbol { symbol: String },
154
155    /// Invalid API request format.
156    #[error("Invalid request format: {message}")]
157    InvalidRequest { message: String },
158
159    /// Missing required parameter.
160    #[error("Missing required parameter: {param}")]
161    MissingParameter { param: String },
162
163    /// Order not found.
164    #[error("Order not found: {order_id}")]
165    OrderNotFound { order_id: String },
166
167    /// Position not found.
168    #[error("Position not found: {symbol}")]
169    PositionNotFound { symbol: String },
170
171    /// Bybit specific error codes.
172    ///
173    /// See <https://bybit-exchange.github.io/docs/v5/error> for error codes.
174    #[error("Bybit error (retCode: {ret_code}): {message}")]
175    BybitApiError { ret_code: i32, message: String },
176}
177
178/// Fatal errors that require manual intervention.
179#[derive(Debug, Error)]
180pub enum BybitFatalError {
181    /// Authentication failed (HTTP 401).
182    #[error("Authentication failed: {message}")]
183    AuthenticationFailed { message: String },
184
185    /// Forbidden (HTTP 403).
186    #[error("Forbidden: {message}")]
187    Forbidden { message: String },
188
189    /// Account suspended.
190    #[error("Account suspended: {reason}")]
191    AccountSuspended { reason: String },
192
193    /// Invalid API credentials.
194    #[error("Invalid API credentials")]
195    InvalidCredentials,
196
197    /// API version no longer supported.
198    #[error("API version no longer supported")]
199    ApiVersionDeprecated,
200
201    /// Critical invariant violation.
202    #[error("Critical invariant violation: {invariant}")]
203    InvariantViolation { invariant: String },
204
205    /// Permission denied for endpoint.
206    #[error("Permission denied: {endpoint}")]
207    PermissionDenied { endpoint: String },
208}
209
210impl BybitError {
211    /// Creates a new rate limit error from HTTP headers.
212    ///
213    /// Bybit uses the following headers:
214    /// - X-Bapi-Limit: Rate limit window
215    /// - X-Bapi-Limit-Status: Current usage
216    /// - X-Bapi-Limit-Reset-Timestamp: Reset time (Unix timestamp in ms)
217    ///
218    /// # Parameters
219    ///
220    /// - `limit_status`: X-Bapi-Limit-Status header value (e.g., "148")
221    /// - `limit`: X-Bapi-Limit header value (e.g., "150")
222    /// - `reset_timestamp`: X-Bapi-Limit-Reset-Timestamp header value (Unix ms)
223    pub fn from_rate_limit_headers(
224        limit_status: Option<&str>,
225        limit: Option<&str>,
226        reset_timestamp: Option<&str>,
227    ) -> Self {
228        let current = limit_status.and_then(|s| s.parse::<u32>().ok());
229        let max_limit = limit.and_then(|s| s.parse::<u32>().ok());
230
231        let remaining = if let (Some(current), Some(max)) = (current, max_limit) {
232            Some(max.saturating_sub(current))
233        } else {
234            None
235        };
236
237        // X-Bapi-Limit-Reset-Timestamp is in milliseconds
238        let reset_at = reset_timestamp.and_then(|s| {
239            s.parse::<u64>().ok().and_then(|timestamp_ms| {
240                let now_ms = std::time::SystemTime::now()
241                    .duration_since(std::time::UNIX_EPOCH)
242                    .ok()?
243                    .as_millis() as u64;
244                if timestamp_ms > now_ms {
245                    Some(Duration::from_millis(timestamp_ms - now_ms))
246                } else {
247                    Some(Duration::from_secs(0))
248                }
249            })
250        });
251
252        Self::Retryable {
253            source: BybitRetryableError::RateLimit {
254                remaining,
255                reset_at,
256            },
257            retry_after: reset_at,
258        }
259    }
260
261    /// Creates an error from an HTTP status code and optional message.
262    pub fn from_http_status(status: u16, message: Option<String>) -> Self {
263        match status {
264            400 => Self::NonRetryable {
265                source: BybitNonRetryableError::BadRequest {
266                    message: message.unwrap_or_else(|| "Bad request".to_string()),
267                    ret_code: None,
268                },
269            },
270            401 => Self::Fatal {
271                source: BybitFatalError::AuthenticationFailed {
272                    message: message.unwrap_or_else(|| "Unauthorized".to_string()),
273                },
274            },
275            403 => Self::Fatal {
276                source: BybitFatalError::Forbidden {
277                    message: message.unwrap_or_else(|| "Forbidden".to_string()),
278                },
279            },
280            404 => Self::NonRetryable {
281                source: BybitNonRetryableError::NotFound {
282                    resource: message.unwrap_or_else(|| "Resource".to_string()),
283                },
284            },
285            405 => Self::NonRetryable {
286                source: BybitNonRetryableError::MethodNotAllowed {
287                    method: message.unwrap_or_else(|| "Method".to_string()),
288                },
289            },
290            429 => Self::from_rate_limit_headers(None, None, None),
291            503 => Self::Retryable {
292                source: BybitRetryableError::ServiceUnavailable,
293                retry_after: None,
294            },
295            504 => Self::Retryable {
296                source: BybitRetryableError::GatewayTimeout,
297                retry_after: None,
298            },
299            s if (500..600).contains(&s) => Self::Retryable {
300                source: BybitRetryableError::ServerError { status: s },
301                retry_after: None,
302            },
303            _ => Self::NonRetryable {
304                source: BybitNonRetryableError::InvalidRequest {
305                    message: format!("Unexpected status: {status}"),
306                },
307            },
308        }
309    }
310
311    /// Creates an error from an HTTP response.
312    pub fn from_http_response(response: &HttpResponse) -> Self {
313        let status = response.status.as_u16();
314        let message = String::from_utf8_lossy(&response.body).to_string();
315        Self::from_http_status(status, Some(message))
316    }
317
318    /// Creates an error from a Bybit API response with retCode.
319    ///
320    /// Bybit returns errors with retCode and retMsg fields.
321    /// See <https://bybit-exchange.github.io/docs/v5/error> for error codes.
322    pub fn from_bybit_ret_code(ret_code: i32, message: String) -> Self {
323        match ret_code {
324            0 => Self::Config("Success code received as error".to_string()),
325            10001 => Self::NonRetryable {
326                source: BybitNonRetryableError::BadRequest {
327                    message,
328                    ret_code: Some(ret_code),
329                },
330            },
331            10002 | 10003 | 10004 | 33004 => Self::Fatal {
332                source: BybitFatalError::AuthenticationFailed { message },
333            },
334            10005 => Self::Fatal {
335                source: BybitFatalError::PermissionDenied { endpoint: message },
336            },
337            10006 => Self::from_rate_limit_headers(None, None, None),
338            110001 | 110003 | 110004 => Self::NonRetryable {
339                source: BybitNonRetryableError::InvalidOrder {
340                    message,
341                    ret_code: Some(ret_code),
342                },
343            },
344            110007 => Self::NonRetryable {
345                source: BybitNonRetryableError::InsufficientBalance { message },
346            },
347            110025 | 110026 => Self::NonRetryable {
348                source: BybitNonRetryableError::OrderNotFound { order_id: message },
349            },
350            110043 => Self::NonRetryable {
351                source: BybitNonRetryableError::InvalidSymbol { symbol: message },
352            },
353            _ if (10000..20000).contains(&ret_code) => Self::NonRetryable {
354                source: BybitNonRetryableError::BybitApiError { ret_code, message },
355            },
356            // Specific retryable errors (system busy/frequency protection)
357            10429 | 131230 | 148019 => Self::Retryable {
358                source: BybitRetryableError::TemporaryNetwork { message },
359                retry_after: None,
360            },
361            // 30000-39999: Institutional Lending, trading restrictions (non-retryable)
362            _ if (30000..40000).contains(&ret_code) => Self::NonRetryable {
363                source: BybitNonRetryableError::BybitApiError { ret_code, message },
364            },
365            // 130000-139999: Withdrawal, KYC, account restrictions (non-retryable)
366            _ if (130000..140000).contains(&ret_code) => Self::NonRetryable {
367                source: BybitNonRetryableError::BybitApiError { ret_code, message },
368            },
369            // 170000-179999: Spot trading business errors (non-retryable)
370            _ if (170000..180000).contains(&ret_code) => Self::NonRetryable {
371                source: BybitNonRetryableError::BybitApiError { ret_code, message },
372            },
373            // 180000-189999: Earn product errors (non-retryable)
374            _ if (180000..190000).contains(&ret_code) => Self::NonRetryable {
375                source: BybitNonRetryableError::BybitApiError { ret_code, message },
376            },
377            // Default: treat unknown errors as non-retryable (fail-safe)
378            _ => Self::NonRetryable {
379                source: BybitNonRetryableError::BybitApiError { ret_code, message },
380            },
381        }
382    }
383
384    /// Checks if this error is retryable.
385    #[must_use]
386    pub fn is_retryable(&self) -> bool {
387        matches!(self, Self::Retryable { .. })
388    }
389
390    /// Checks if this error is fatal.
391    #[must_use]
392    pub fn is_fatal(&self) -> bool {
393        matches!(self, Self::Fatal { .. })
394    }
395
396    /// Gets the suggested retry duration if available.
397    #[must_use]
398    pub fn retry_after(&self) -> Option<Duration> {
399        match self {
400            Self::Retryable { retry_after, .. } => *retry_after,
401            _ => None,
402        }
403    }
404}
405
406// Re-export existing error types for backward compatibility
407pub use crate::{
408    http::error::BybitHttpError,
409    websocket::error::{BybitWsError, BybitWsResult},
410};
411
412impl From<serde_json::Error> for BybitError {
413    fn from(error: serde_json::Error) -> Self {
414        Self::Json {
415            message: error.to_string(),
416            raw: None,
417        }
418    }
419}
420
421impl From<tungstenite::Error> for BybitError {
422    fn from(error: tungstenite::Error) -> Self {
423        Self::WebSocket(error.to_string())
424    }
425}
426
427impl From<BybitHttpError> for BybitError {
428    fn from(error: BybitHttpError) -> Self {
429        match error {
430            BybitHttpError::MissingCredentials => {
431                Self::Config("API credentials not configured".to_string())
432            }
433            BybitHttpError::BybitError {
434                error_code,
435                message,
436            } => Self::Config(format!("Bybit error {error_code}: {message}")),
437            BybitHttpError::JsonError(msg) => Self::Json {
438                message: msg,
439                raw: None,
440            },
441            BybitHttpError::ValidationError(msg) => {
442                Self::Config(format!("Validation error: {msg}"))
443            }
444            BybitHttpError::BuildError(e) => Self::Config(format!("Build error: {e}")),
445            BybitHttpError::NetworkError(msg) => Self::Config(format!("Network error: {msg}")),
446            BybitHttpError::UnexpectedStatus { status, body } => Self::Json {
447                message: format!("HTTP {status}: {body}"),
448                raw: Some(body),
449            },
450        }
451    }
452}
453
454impl From<BybitWsError> for BybitError {
455    fn from(error: BybitWsError) -> Self {
456        Self::WebSocket(error.to_string())
457    }
458}
459
460////////////////////////////////////////////////////////////////////////////////
461// Tests
462////////////////////////////////////////////////////////////////////////////////
463
464#[cfg(test)]
465mod tests {
466    use rstest::rstest;
467
468    use super::*;
469
470    #[rstest]
471    fn test_error_classification() {
472        let err = BybitError::from_http_status(429, None);
473        assert!(err.is_retryable());
474        assert!(!err.is_fatal());
475
476        let err = BybitError::from_http_status(401, None);
477        assert!(!err.is_retryable());
478        assert!(err.is_fatal());
479
480        let err = BybitError::from_http_status(400, None);
481        assert!(!err.is_retryable());
482        assert!(!err.is_fatal());
483    }
484
485    #[rstest]
486    fn test_rate_limit_parsing() {
487        // Simulate future timestamp
488        let future_timestamp_ms = std::time::SystemTime::now()
489            .duration_since(std::time::UNIX_EPOCH)
490            .unwrap()
491            .as_millis() as u64
492            + 60_000; // 60 seconds in future
493
494        let err = BybitError::from_rate_limit_headers(
495            Some("148"),
496            Some("150"),
497            Some(&future_timestamp_ms.to_string()),
498        );
499
500        match err {
501            BybitError::Retryable {
502                source: BybitRetryableError::RateLimit { remaining, .. },
503                retry_after,
504                ..
505            } => {
506                assert_eq!(remaining, Some(2)); // 150 - 148 = 2
507                assert!(retry_after.is_some());
508                let duration = retry_after.unwrap();
509                assert!(duration.as_secs() >= 59 && duration.as_secs() <= 61);
510            }
511            _ => panic!("Expected rate limit error"),
512        }
513    }
514
515    #[rstest]
516    fn test_bybit_ret_codes() {
517        // Authentication error
518        let err = BybitError::from_bybit_ret_code(10003, "Invalid API key".to_string());
519        assert!(err.is_fatal());
520
521        // Bad request
522        let err = BybitError::from_bybit_ret_code(10001, "Invalid parameter".to_string());
523        assert!(!err.is_retryable());
524        assert!(!err.is_fatal());
525
526        // Rate limit
527        let err = BybitError::from_bybit_ret_code(10006, "Rate limit exceeded".to_string());
528        assert!(err.is_retryable());
529
530        // Insufficient balance
531        let err = BybitError::from_bybit_ret_code(110007, "Not enough balance".to_string());
532        assert!(!err.is_retryable());
533    }
534
535    #[rstest]
536    fn test_retry_after() {
537        let err = BybitError::Retryable {
538            source: BybitRetryableError::RateLimit {
539                remaining: Some(0),
540                reset_at: Some(Duration::from_secs(60)),
541            },
542            retry_after: Some(Duration::from_secs(60)),
543        };
544        assert_eq!(err.retry_after(), Some(Duration::from_secs(60)));
545    }
546
547    #[rstest]
548    fn test_invalid_order_errors() {
549        let err = BybitError::from_bybit_ret_code(110001, "Invalid order".to_string());
550        match err {
551            BybitError::NonRetryable {
552                source: BybitNonRetryableError::InvalidOrder { ret_code, .. },
553            } => {
554                assert_eq!(ret_code, Some(110001));
555            }
556            _ => panic!("Expected invalid order error"),
557        }
558    }
559}