1use std::time::Duration;
22
23use nautilus_network::http::{HttpClientError, StatusCode};
24use thiserror::Error;
25
26use crate::common::consts::should_retry_error_code;
27
28#[derive(Debug, Error)]
30pub enum BuildError {
31 #[error("Missing required instrument ID")]
33 MissingInstId,
34 #[error("Missing required bar interval")]
36 MissingBar,
37 #[error("Cannot specify both 'after' and 'before' cursors")]
39 BothCursors,
40 #[error(
42 "Invalid time range: after_ms ({after_ms}) must be greater than before_ms ({before_ms})"
43 )]
44 InvalidTimeRange { after_ms: i64, before_ms: i64 },
45 #[error("Cursor timestamp appears to be in nanoseconds (> 13 digits)")]
47 CursorIsNanoseconds,
48 #[error("Limit exceeds maximum of 300")]
50 LimitTooHigh,
51}
52
53#[derive(Debug, Error)]
55pub enum OKXHttpError {
56 #[error("Missing credentials for authenticated request")]
58 MissingCredentials,
59 #[error("OKX error {error_code}: {message}")]
61 OkxError { error_code: String, message: String },
62 #[error("Temporary OKX error {error_code}: {message}")]
64 RetryableOkxError {
65 error_code: String,
66 message: String,
67 retry_after: Option<Duration>,
68 },
69 #[error("Request serialization error: {0}")]
71 RequestSerialization(String),
72 #[error("Malformed response: {0}")]
74 MalformedResponse(String),
75 #[error("Response decoding error: {0}")]
77 ResponseDecoding(String),
78 #[error("Parameter validation error: {0}")]
80 ValidationError(String),
81 #[error("Request canceled: {0}")]
83 Canceled(String),
84 #[error("Network error: {0}")]
86 HttpClientError(#[from] HttpClientError),
87 #[error("Temporary HTTP status code {status}: {body}")]
89 RetryableStatus {
90 status: StatusCode,
91 body: String,
92 retry_after: Option<Duration>,
93 },
94 #[error("Unexpected HTTP status code {status}: {body}")]
96 UnexpectedStatus { status: StatusCode, body: String },
97 #[error("Operation timed out after {timeout_ms}ms")]
99 OperationTimeout { timeout_ms: u64 },
100 #[error("Retry budget exceeded: {0}")]
102 RetryBudgetExceeded(String),
103 #[error("Empty response")]
105 EmptyResponse,
106}
107
108impl From<String> for OKXHttpError {
109 fn from(error: String) -> Self {
110 Self::ValidationError(error)
111 }
112}
113
114impl From<serde_json::Error> for OKXHttpError {
116 fn from(error: serde_json::Error) -> Self {
117 Self::RequestSerialization(error.to_string())
118 }
119}
120
121impl OKXHttpError {
122 pub(crate) fn from_venue_response(
123 error_code: String,
124 message: String,
125 retry_after: Option<Duration>,
126 ) -> Self {
127 if should_retry_error_code(&error_code) {
128 Self::RetryableOkxError {
129 error_code,
130 message,
131 retry_after,
132 }
133 } else {
134 Self::OkxError {
135 error_code,
136 message,
137 }
138 }
139 }
140
141 #[must_use]
143 pub fn is_order_not_found(&self) -> bool {
144 matches!(
145 self,
146 Self::OkxError { error_code, .. } if error_code == "51603"
147 )
148 }
149
150 #[must_use]
152 pub fn is_retryable(&self) -> bool {
153 matches!(
154 self,
155 Self::HttpClientError(
156 HttpClientError::TransportError(_) | HttpClientError::TimeoutError(_)
157 ) | Self::RetryableOkxError { .. }
158 | Self::RetryableStatus { .. }
159 | Self::OperationTimeout { .. }
160 ) || matches!(
161 self,
162 Self::OkxError { error_code, .. } if should_retry_error_code(error_code)
163 )
164 }
165
166 #[must_use]
168 pub const fn retry_after(&self) -> Option<Duration> {
169 match self {
170 Self::RetryableOkxError { retry_after, .. }
171 | Self::RetryableStatus { retry_after, .. } => *retry_after,
172 _ => None,
173 }
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use rstest::rstest;
180
181 use super::*;
182
183 #[rstest]
184 #[case(OKXHttpError::HttpClientError(HttpClientError::TransportError("reset".to_string())), true)]
185 #[case(OKXHttpError::HttpClientError(HttpClientError::Error("invalid header".to_string())), false)]
186 #[case(OKXHttpError::RetryableStatus { status: StatusCode::INTERNAL_SERVER_ERROR, body: String::new(), retry_after: None }, true)]
187 #[case(OKXHttpError::RetryableStatus { status: StatusCode::TOO_MANY_REQUESTS, body: String::new(), retry_after: None }, true)]
188 #[case(OKXHttpError::UnexpectedStatus { status: StatusCode::FORBIDDEN, body: String::new() }, false)]
189 #[case(OKXHttpError::RetryableOkxError { error_code: "50001".to_string(), message: String::new(), retry_after: None }, true)]
190 #[case(OKXHttpError::RetryableOkxError { error_code: "50011".to_string(), message: String::new(), retry_after: None }, true)]
191 #[case(OKXHttpError::OkxError { error_code: "50013".to_string(), message: String::new() }, true)]
192 #[case(OKXHttpError::OkxError { error_code: "51000".to_string(), message: String::new() }, false)]
193 #[case(OKXHttpError::RequestSerialization("bad".to_string()), false)]
194 #[case(OKXHttpError::MalformedResponse("bad".to_string()), false)]
195 #[case(OKXHttpError::ResponseDecoding("bad".to_string()), false)]
196 #[case(OKXHttpError::ValidationError("bad".to_string()), false)]
197 #[case(OKXHttpError::MissingCredentials, false)]
198 #[case(OKXHttpError::Canceled("shutdown".to_string()), false)]
199 #[case(OKXHttpError::HttpClientError(HttpClientError::InvalidProxy("timeout".to_string())), false)]
200 #[case(OKXHttpError::HttpClientError(HttpClientError::ClientBuildError("timeout".to_string())), false)]
201 #[case(OKXHttpError::OperationTimeout { timeout_ms: 1_000 }, true)]
202 #[case(OKXHttpError::RetryBudgetExceeded("budget".to_string()), false)]
203 #[case(OKXHttpError::EmptyResponse, false)]
204 fn test_is_retryable(#[case] error: OKXHttpError, #[case] expected: bool) {
205 assert_eq!(error.is_retryable(), expected);
206 }
207
208 #[rstest]
209 fn test_retryability_uses_error_type_not_message() {
210 let message = "connection reset".to_string();
211 let transport =
212 OKXHttpError::HttpClientError(HttpClientError::TransportError(message.clone()));
213 let permanent = OKXHttpError::HttpClientError(HttpClientError::Error(message));
214
215 assert!(transport.is_retryable());
216 assert!(!permanent.is_retryable());
217 }
218
219 #[rstest]
220 fn test_from_venue_response_classifies_retryable_code() {
221 let delay = Duration::from_secs(2);
222
223 let error = OKXHttpError::from_venue_response(
224 "50013".to_string(),
225 "System busy".to_string(),
226 Some(delay),
227 );
228
229 assert!(matches!(
230 error,
231 OKXHttpError::RetryableOkxError {
232 error_code,
233 message,
234 retry_after: Some(actual_delay),
235 } if error_code == "50013" && message == "System busy" && actual_delay == delay
236 ));
237 }
238
239 #[rstest]
240 fn test_retry_after_is_exposed_only_by_retryable_response_errors() {
241 let delay = Duration::from_secs(5);
242 let rate_limit = OKXHttpError::RetryableOkxError {
243 error_code: "50011".to_string(),
244 message: "Request too frequent".to_string(),
245 retry_after: Some(delay),
246 };
247
248 assert_eq!(rate_limit.retry_after(), Some(delay));
249 assert_eq!(
250 OKXHttpError::MalformedResponse(String::new()).retry_after(),
251 None
252 );
253 }
254
255 #[rstest]
256 #[case(OKXHttpError::OkxError {
257 error_code: "51603".to_string(),
258 message: "Order does not exist".to_string(),
259 }, true)]
260 #[case(OKXHttpError::OkxError {
261 error_code: "51000".to_string(),
262 message: "Parameter error".to_string(),
263 }, false)]
264 #[case(OKXHttpError::ValidationError("bad".to_string()), false)]
265 fn test_is_order_not_found(#[case] error: OKXHttpError, #[case] expected: bool) {
266 assert_eq!(error.is_order_not_found(), expected);
267 }
268}