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