Skip to main content

nautilus_okx/http/
error.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//! Error structures and enumerations for the OKX integration.
17//!
18//! The JSON error schema is described in the OKX documentation under
19//! *REST API > Error Codes* - <https://www.okx.com/docs-v5/en/#error-codes>.
20//! The types below mirror that structure and are reused across the entire
21//! crate.
22
23use std::time::Duration;
24
25use nautilus_network::http::{HttpClientError, StatusCode};
26use serde::Deserialize;
27use thiserror::Error;
28
29use crate::common::consts::should_retry_error_code;
30
31/// Represents a build error for query parameter validation.
32#[derive(Debug, Error)]
33pub enum BuildError {
34    /// Missing required instrument ID.
35    #[error("Missing required instrument ID")]
36    MissingInstId,
37    /// Missing required bar interval.
38    #[error("Missing required bar interval")]
39    MissingBar,
40    /// Both after and before cursors specified.
41    #[error("Cannot specify both 'after' and 'before' cursors")]
42    BothCursors,
43    /// Invalid time range: `after_ms` should be greater than `before_ms`.
44    #[error(
45        "Invalid time range: after_ms ({after_ms}) must be greater than before_ms ({before_ms})"
46    )]
47    InvalidTimeRange { after_ms: i64, before_ms: i64 },
48    /// Cursor timestamp is in nanoseconds (> 13 digits).
49    #[error("Cursor timestamp appears to be in nanoseconds (> 13 digits)")]
50    CursorIsNanoseconds,
51    /// Limit exceeds maximum allowed value.
52    #[error("Limit exceeds maximum of 300")]
53    LimitTooHigh,
54}
55
56/// Represents the JSON structure of an error response returned by the OKX API.
57#[derive(Clone, Debug, Deserialize)]
58pub struct OKXErrorResponse {
59    /// The top-level error object included in the OKX error response.
60    pub error: OKXErrorMessage,
61}
62
63/// Contains the specific error details provided by the OKX API.
64#[derive(Clone, Debug, Deserialize)]
65pub struct OKXErrorMessage {
66    /// A human-readable explanation of the error condition.
67    pub message: String,
68    /// A short identifier or category for the error, as returned by OKX.
69    pub name: String,
70}
71
72/// A typed error enumeration for the OKX HTTP client.
73#[derive(Debug, Error)]
74pub enum OKXHttpError {
75    /// Error variant when credentials are missing but the request is authenticated.
76    #[error("Missing credentials for authenticated request")]
77    MissingCredentials,
78    /// Errors returned directly by OKX (non-zero code).
79    #[error("OKX error {error_code}: {message}")]
80    OkxError { error_code: String, message: String },
81    /// Temporary errors returned by OKX.
82    #[error("Temporary OKX error {error_code}: {message}")]
83    RetryableOkxError {
84        error_code: String,
85        message: String,
86        retry_after: Option<Duration>,
87    },
88    /// Failure while serializing an outbound request.
89    #[error("Request serialization error: {0}")]
90    RequestSerialization(String),
91    /// The response body is not valid JSON.
92    #[error("Malformed response: {0}")]
93    MalformedResponse(String),
94    /// The response JSON does not match the expected schema.
95    #[error("Response decoding error: {0}")]
96    ResponseDecoding(String),
97    /// Parameter validation error.
98    #[error("Parameter validation error: {0}")]
99    ValidationError(String),
100    /// Request was canceled, typically due to shutdown or disconnect.
101    #[error("Request canceled: {0}")]
102    Canceled(String),
103    /// Wrapping the underlying `HttpClientError` from the network crate.
104    #[error("Network error: {0}")]
105    HttpClientError(#[from] HttpClientError),
106    /// A temporary HTTP status without a decodable OKX error envelope.
107    #[error("Temporary HTTP status code {status}: {body}")]
108    RetryableStatus {
109        status: StatusCode,
110        body: String,
111        retry_after: Option<Duration>,
112    },
113    /// Any permanent unknown HTTP status or unexpected response from OKX.
114    #[error("Unexpected HTTP status code {status}: {body}")]
115    UnexpectedStatus { status: StatusCode, body: String },
116    /// A single retry attempt exceeded its configured timeout.
117    #[error("Operation timed out after {timeout_ms}ms")]
118    OperationTimeout { timeout_ms: u64 },
119    /// The retry elapsed-time budget was exhausted.
120    #[error("Retry budget exceeded: {0}")]
121    RetryBudgetExceeded(String),
122    /// The venue returned a successful envelope with no result items.
123    #[error("Empty response")]
124    EmptyResponse,
125}
126
127impl From<String> for OKXHttpError {
128    fn from(error: String) -> Self {
129        Self::ValidationError(error)
130    }
131}
132
133// Response decoding is classified explicitly; this conversion handles outbound serialization
134impl From<serde_json::Error> for OKXHttpError {
135    fn from(error: serde_json::Error) -> Self {
136        Self::RequestSerialization(error.to_string())
137    }
138}
139
140impl OKXHttpError {
141    pub(crate) fn from_venue_response(
142        error_code: String,
143        message: String,
144        retry_after: Option<Duration>,
145    ) -> Self {
146        if should_retry_error_code(&error_code) {
147            Self::RetryableOkxError {
148                error_code,
149                message,
150                retry_after,
151            }
152        } else {
153            Self::OkxError {
154                error_code,
155                message,
156            }
157        }
158    }
159
160    /// Returns whether OKX reported that the requested order does not exist.
161    #[must_use]
162    pub fn is_order_not_found(&self) -> bool {
163        matches!(
164            self,
165            Self::OkxError { error_code, .. } if error_code == "51603"
166        )
167    }
168
169    /// Returns whether this error is retryable.
170    #[must_use]
171    pub fn is_retryable(&self) -> bool {
172        matches!(
173            self,
174            Self::HttpClientError(
175                HttpClientError::TransportError(_) | HttpClientError::TimeoutError(_)
176            ) | Self::RetryableOkxError { .. }
177                | Self::RetryableStatus { .. }
178                | Self::OperationTimeout { .. }
179        ) || matches!(
180            self,
181            Self::OkxError { error_code, .. } if should_retry_error_code(error_code)
182        )
183    }
184
185    /// Returns the venue or transport-provided minimum retry delay.
186    #[must_use]
187    pub const fn retry_after(&self) -> Option<Duration> {
188        match self {
189            Self::RetryableOkxError { retry_after, .. }
190            | Self::RetryableStatus { retry_after, .. } => *retry_after,
191            _ => None,
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use rstest::rstest;
199
200    use super::*;
201
202    #[rstest]
203    #[case(OKXHttpError::HttpClientError(HttpClientError::TransportError("reset".to_string())), true)]
204    #[case(OKXHttpError::HttpClientError(HttpClientError::Error("invalid header".to_string())), false)]
205    #[case(OKXHttpError::RetryableStatus { status: StatusCode::INTERNAL_SERVER_ERROR, body: String::new(), retry_after: None }, true)]
206    #[case(OKXHttpError::RetryableStatus { status: StatusCode::TOO_MANY_REQUESTS, body: String::new(), retry_after: None }, true)]
207    #[case(OKXHttpError::UnexpectedStatus { status: StatusCode::FORBIDDEN, body: String::new() }, false)]
208    #[case(OKXHttpError::RetryableOkxError { error_code: "50001".to_string(), message: String::new(), retry_after: None }, true)]
209    #[case(OKXHttpError::RetryableOkxError { error_code: "50011".to_string(), message: String::new(), retry_after: None }, true)]
210    #[case(OKXHttpError::OkxError { error_code: "50013".to_string(), message: String::new() }, true)]
211    #[case(OKXHttpError::OkxError { error_code: "51000".to_string(), message: String::new() }, false)]
212    #[case(OKXHttpError::RequestSerialization("bad".to_string()), false)]
213    #[case(OKXHttpError::MalformedResponse("bad".to_string()), false)]
214    #[case(OKXHttpError::ResponseDecoding("bad".to_string()), false)]
215    #[case(OKXHttpError::ValidationError("bad".to_string()), false)]
216    #[case(OKXHttpError::MissingCredentials, false)]
217    #[case(OKXHttpError::Canceled("shutdown".to_string()), false)]
218    #[case(OKXHttpError::HttpClientError(HttpClientError::InvalidProxy("timeout".to_string())), false)]
219    #[case(OKXHttpError::HttpClientError(HttpClientError::ClientBuildError("timeout".to_string())), false)]
220    #[case(OKXHttpError::OperationTimeout { timeout_ms: 1_000 }, true)]
221    #[case(OKXHttpError::RetryBudgetExceeded("budget".to_string()), false)]
222    #[case(OKXHttpError::EmptyResponse, false)]
223    fn test_is_retryable(#[case] error: OKXHttpError, #[case] expected: bool) {
224        assert_eq!(error.is_retryable(), expected);
225    }
226
227    #[rstest]
228    fn test_retryability_uses_error_type_not_message() {
229        let message = "connection reset".to_string();
230        let transport =
231            OKXHttpError::HttpClientError(HttpClientError::TransportError(message.clone()));
232        let permanent = OKXHttpError::HttpClientError(HttpClientError::Error(message));
233
234        assert!(transport.is_retryable());
235        assert!(!permanent.is_retryable());
236    }
237
238    #[rstest]
239    fn test_from_venue_response_classifies_retryable_code() {
240        let delay = Duration::from_secs(2);
241
242        let error = OKXHttpError::from_venue_response(
243            "50013".to_string(),
244            "System busy".to_string(),
245            Some(delay),
246        );
247
248        assert!(matches!(
249            error,
250            OKXHttpError::RetryableOkxError {
251                error_code,
252                message,
253                retry_after: Some(actual_delay),
254            } if error_code == "50013" && message == "System busy" && actual_delay == delay
255        ));
256    }
257
258    #[rstest]
259    fn test_retry_after_is_exposed_only_by_retryable_response_errors() {
260        let delay = Duration::from_secs(5);
261        let rate_limit = OKXHttpError::RetryableOkxError {
262            error_code: "50011".to_string(),
263            message: "Request too frequent".to_string(),
264            retry_after: Some(delay),
265        };
266
267        assert_eq!(rate_limit.retry_after(), Some(delay));
268        assert_eq!(
269            OKXHttpError::MalformedResponse(String::new()).retry_after(),
270            None
271        );
272    }
273
274    #[rstest]
275    #[case(OKXHttpError::OkxError {
276        error_code: "51603".to_string(),
277        message: "Order does not exist".to_string(),
278    }, true)]
279    #[case(OKXHttpError::OkxError {
280        error_code: "51000".to_string(),
281        message: "Parameter error".to_string(),
282    }, false)]
283    #[case(OKXHttpError::ValidationError("bad".to_string()), false)]
284    fn test_is_order_not_found(#[case] error: OKXHttpError, #[case] expected: bool) {
285        assert_eq!(error.is_order_not_found(), expected);
286    }
287}