1use nautilus_network::retry::{RetryConfig, RetryError};
25
26use crate::{http::DeriveHttpError, websocket::DeriveWsError};
27
28#[must_use]
37pub fn http_retry_config(
38 max_retries: u32,
39 initial_delay_ms: u64,
40 max_delay_ms: u64,
41) -> RetryConfig {
42 RetryConfig {
43 max_retries,
44 initial_delay_ms,
45 max_delay_ms,
46 backoff_factor: 2.0,
47 jitter_ms: 1_000,
48 operation_timeout_ms: Some(60_000),
49 immediate_first: false,
50 max_elapsed_ms: Some(180_000),
51 }
52}
53
54#[must_use]
69pub fn should_retry_http_error(error: &DeriveHttpError) -> bool {
70 match error {
71 DeriveHttpError::Transport(_) => true,
72 DeriveHttpError::Retry(RetryError::OperationTimeout { .. }) => true,
73 DeriveHttpError::Http { status, .. } => is_retryable_status(*status),
74 DeriveHttpError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
75 DeriveHttpError::MissingResult { .. }
76 | DeriveHttpError::Decode(_)
77 | DeriveHttpError::Serde(_)
78 | DeriveHttpError::Auth(_)
79 | DeriveHttpError::MissingCredentials { .. }
80 | DeriveHttpError::Retry(_) => false,
81 }
82}
83
84#[must_use]
91pub fn is_fatal_http_error(error: &DeriveHttpError) -> bool {
92 match error {
93 DeriveHttpError::Auth(_) | DeriveHttpError::MissingCredentials { .. } => true,
94 DeriveHttpError::Http { status, .. } => matches!(*status, 401 | 403),
95 DeriveHttpError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
96 _ => false,
97 }
98}
99
100#[must_use]
104pub fn should_retry_ws_error(error: &DeriveWsError) -> bool {
105 match error {
106 DeriveWsError::Transport(_)
107 | DeriveWsError::RequestCancelled { .. }
108 | DeriveWsError::Timeout { .. }
109 | DeriveWsError::RateLimited { .. } => true,
110 DeriveWsError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
111 DeriveWsError::NotConnected
112 | DeriveWsError::Serde(_)
113 | DeriveWsError::Auth(_)
114 | DeriveWsError::Authentication { .. }
115 | DeriveWsError::Subscription { .. }
116 | DeriveWsError::MissingCredentials { .. } => false,
117 }
118}
119
120#[must_use]
122pub fn is_fatal_ws_error(error: &DeriveWsError) -> bool {
123 match error {
124 DeriveWsError::Auth(_)
125 | DeriveWsError::Authentication { .. }
126 | DeriveWsError::MissingCredentials { .. } => true,
127 DeriveWsError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
128 _ => false,
129 }
130}
131
132#[must_use]
134fn is_retryable_status(status: u16) -> bool {
135 matches!(status, 408 | 429) || (500..600).contains(&status)
136}
137
138#[must_use]
143pub(crate) fn is_retryable_jsonrpc_code(code: i64) -> bool {
144 matches!(code, -32603 | 9002) || (-32099..=-32000).contains(&code)
145}
146
147#[must_use]
162pub(crate) fn is_write_outcome_ambiguous_jsonrpc(code: i64) -> bool {
163 matches!(code, -32603 | 9000 | 9001)
164}
165
166#[must_use]
176pub(crate) fn is_write_outcome_ambiguous_ws(error: &DeriveWsError) -> bool {
177 match error {
178 DeriveWsError::Transport(_)
179 | DeriveWsError::RequestCancelled { .. }
180 | DeriveWsError::Timeout { .. }
181 | DeriveWsError::Serde(_) => true,
182 DeriveWsError::JsonRpc { code, .. } => is_write_outcome_ambiguous_jsonrpc(*code),
183 DeriveWsError::NotConnected
184 | DeriveWsError::Auth(_)
185 | DeriveWsError::Authentication { .. }
186 | DeriveWsError::Subscription { .. }
187 | DeriveWsError::RateLimited { .. }
188 | DeriveWsError::MissingCredentials { .. } => false,
189 }
190}
191
192#[must_use]
194fn is_fatal_jsonrpc_code(code: i64) -> bool {
195 matches!(
196 code,
197 -32600
198 | -32700
199 | 14000
200 | 14001
201 | 14013
202 | 14014
203 | 14020
204 | 14021
205 | 14023
206 | 14026
207 | 14030
208 | 14031
209 )
210}
211
212#[cfg(test)]
213mod tests {
214 use rstest::rstest;
215 use serde_json::Value;
216
217 use super::*;
218
219 #[rstest]
220 #[case(14000, true, false)]
221 #[case(14001, true, false)]
222 #[case(14013, true, false)]
223 #[case(14014, true, false)]
224 #[case(14020, true, false)]
225 #[case(14021, true, false)]
226 #[case(14023, true, false)]
227 #[case(14026, true, false)]
228 #[case(14030, true, false)]
229 #[case(14031, true, false)]
230 #[case(9002, false, true)]
231 #[case(9000, false, false)]
232 #[case(12001, false, false)]
233 fn test_venue_session_error_classification(
234 #[case] code: i64,
235 #[case] fatal: bool,
236 #[case] retry: bool,
237 ) {
238 let http = DeriveHttpError::JsonRpc {
239 code,
240 message: "opaque".to_string(),
241 data: None,
242 retry_after: None,
243 };
244
245 let ws = DeriveWsError::JsonRpc {
246 code,
247 message: "opaque".to_string(),
248 data: None,
249 };
250
251 assert_eq!(is_fatal_http_error(&http), fatal);
252 assert_eq!(is_fatal_ws_error(&ws), fatal);
253 assert_eq!(should_retry_http_error(&http), retry);
254 assert_eq!(should_retry_ws_error(&ws), retry);
255 }
256
257 #[rstest]
258 fn test_transport_error_retryable() {
259 let err = DeriveHttpError::transport("conn reset");
260 assert!(should_retry_http_error(&err));
261 assert!(!is_fatal_http_error(&err));
262 }
263
264 #[rstest]
265 #[case(500, true)]
266 #[case(502, true)]
267 #[case(503, true)]
268 #[case(504, true)]
269 #[case(429, true)]
270 #[case(408, true)]
271 #[case(400, false)]
272 #[case(404, false)]
273 #[case(409, false)]
274 #[case(422, false)]
275 fn test_http_status_retry_classification(#[case] status: u16, #[case] retryable: bool) {
276 let err = DeriveHttpError::http(status, "body");
277 assert_eq!(should_retry_http_error(&err), retryable);
278 }
279
280 #[rstest]
281 #[case(401)]
282 #[case(403)]
283 fn test_http_auth_status_is_fatal(#[case] status: u16) {
284 let err = DeriveHttpError::http(status, "Unauthorized");
285 assert!(is_fatal_http_error(&err));
286 assert!(!should_retry_http_error(&err));
287 }
288
289 #[rstest]
290 fn test_jsonrpc_invalid_params_not_retryable() {
291 let err = DeriveHttpError::JsonRpc {
295 code: -32602,
296 message: "signed_max_fee_too_low".into(),
297 data: None,
298 retry_after: None,
299 };
300
301 assert!(!should_retry_http_error(&err));
302 assert!(!is_fatal_http_error(&err));
303 }
304
305 #[rstest]
306 fn test_jsonrpc_server_error_range_retryable() {
307 let err = DeriveHttpError::JsonRpc {
308 code: -32050,
309 message: "Server busy".into(),
310 data: None,
311 retry_after: None,
312 };
313
314 assert!(should_retry_http_error(&err));
315 }
316
317 #[rstest]
318 fn test_jsonrpc_internal_error_retryable() {
319 let err = DeriveHttpError::JsonRpc {
320 code: -32603,
321 message: "Internal error".into(),
322 data: None,
323 retry_after: None,
324 };
325
326 assert!(should_retry_http_error(&err));
327 }
328
329 #[rstest]
330 fn test_jsonrpc_invalid_request_is_fatal() {
331 let err = DeriveHttpError::JsonRpc {
332 code: -32600,
333 message: "Invalid request".into(),
334 data: Some(Value::Null),
335 retry_after: None,
336 };
337
338 assert!(is_fatal_http_error(&err));
339 assert!(!should_retry_http_error(&err));
340 }
341
342 #[rstest]
343 fn test_missing_credentials_terminal() {
344 let err = DeriveHttpError::MissingCredentials {
345 method: "private/order".into(),
346 };
347
348 assert!(!should_retry_http_error(&err));
349 assert!(is_fatal_http_error(&err));
350 }
351
352 #[rstest]
353 fn test_ws_transport_retryable() {
354 let err = DeriveWsError::transport("send failed");
355 assert!(should_retry_ws_error(&err));
356 }
357
358 #[rstest]
359 fn test_ws_not_connected_terminal() {
360 let err = DeriveWsError::NotConnected;
361 assert!(!should_retry_ws_error(&err));
362 assert!(!is_fatal_ws_error(&err));
363 }
364
365 #[rstest]
366 fn test_ws_request_cancelled_retryable() {
367 let err = DeriveWsError::RequestCancelled {
370 method: "subscribe".into(),
371 };
372
373 assert!(should_retry_ws_error(&err));
374 }
375
376 #[rstest]
377 fn test_ws_timeout_retryable_not_fatal() {
378 let err = DeriveWsError::Timeout {
379 method: "private/order".into(),
380 };
381
382 assert!(should_retry_ws_error(&err));
383 assert!(!is_fatal_ws_error(&err));
384 }
385
386 #[rstest]
387 fn test_ws_authentication_and_subscription_failures_are_terminal() {
388 let authentication = DeriveWsError::Authentication {
389 operation: "private/order".into(),
390 reason: "session recovery failed".into(),
391 };
392
393 let subscription = DeriveWsError::Subscription {
394 details: "30769.trades: unauthorized".into(),
395 };
396
397 assert!(!should_retry_ws_error(&authentication));
398 assert!(is_fatal_ws_error(&authentication));
399 assert!(!should_retry_ws_error(&subscription));
400 assert!(!is_fatal_ws_error(&subscription));
401 }
402
403 #[rstest]
404 fn test_ws_write_outcome_ambiguous_classification() {
405 let ambiguous = [
408 DeriveWsError::transport("send failed"),
409 DeriveWsError::RequestCancelled {
410 method: "private/order".into(),
411 },
412 DeriveWsError::Timeout {
413 method: "private/order".into(),
414 },
415 DeriveWsError::Serde(serde_json::from_str::<Value>("{").unwrap_err()),
418 DeriveWsError::JsonRpc {
419 code: -32603,
420 message: "Internal error".into(),
421 data: None,
422 },
423 ];
424
425 let definitive = [
426 DeriveWsError::NotConnected,
427 DeriveWsError::JsonRpc {
428 code: -32602,
429 message: "signed_max_fee_too_low".into(),
430 data: None,
431 },
432 DeriveWsError::MissingCredentials {
433 operation: "private/order".into(),
434 },
435 DeriveWsError::Authentication {
436 operation: "private/order".into(),
437 reason: "session recovery failed".into(),
438 },
439 DeriveWsError::Subscription {
440 details: "30769.trades: unauthorized".into(),
441 },
442 ];
443
444 for err in &ambiguous {
445 assert!(
446 is_write_outcome_ambiguous_ws(err),
447 "expected ambiguous: {err}"
448 );
449 }
450
451 for err in &definitive {
452 assert!(
453 !is_write_outcome_ambiguous_ws(err),
454 "expected definitive: {err}",
455 );
456 }
457 }
458}