1use nautilus_network::retry::RetryConfig;
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]
65pub fn should_retry_http_error(error: &DeriveHttpError) -> bool {
66 match error {
67 DeriveHttpError::Transport(_) => true,
68 DeriveHttpError::Http { status, .. } => is_retryable_status(*status),
69 DeriveHttpError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
70 DeriveHttpError::MissingResult { .. }
71 | DeriveHttpError::Decode(_)
72 | DeriveHttpError::Serde(_)
73 | DeriveHttpError::Auth(_)
74 | DeriveHttpError::MissingCredentials { .. } => false,
75 }
76}
77
78#[must_use]
85pub fn is_fatal_http_error(error: &DeriveHttpError) -> bool {
86 match error {
87 DeriveHttpError::Auth(_) | DeriveHttpError::MissingCredentials { .. } => true,
88 DeriveHttpError::Http { status, .. } => matches!(*status, 401 | 403),
89 DeriveHttpError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
90 _ => false,
91 }
92}
93
94#[must_use]
96pub fn should_retry_ws_error(error: &DeriveWsError) -> bool {
97 match error {
98 DeriveWsError::Transport(_)
99 | DeriveWsError::RequestCancelled { .. }
100 | DeriveWsError::Timeout { .. } => true,
101 DeriveWsError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
102 DeriveWsError::NotConnected
103 | DeriveWsError::Serde(_)
104 | DeriveWsError::Auth(_)
105 | DeriveWsError::Authentication { .. }
106 | DeriveWsError::Subscription { .. }
107 | DeriveWsError::MissingCredentials { .. } => false,
108 }
109}
110
111#[must_use]
113pub fn is_fatal_ws_error(error: &DeriveWsError) -> bool {
114 match error {
115 DeriveWsError::Auth(_)
116 | DeriveWsError::Authentication { .. }
117 | DeriveWsError::MissingCredentials { .. } => true,
118 DeriveWsError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
119 _ => false,
120 }
121}
122
123#[must_use]
125fn is_retryable_status(status: u16) -> bool {
126 matches!(status, 408 | 429) || (500..600).contains(&status)
127}
128
129#[must_use]
139pub(crate) fn is_retryable_jsonrpc_code(code: i64) -> bool {
140 code == -32603 || (-32099..=-32000).contains(&code)
141}
142
143#[must_use]
159pub(crate) fn is_write_outcome_ambiguous_jsonrpc(code: i64) -> bool {
160 code == -32603
161}
162
163#[must_use]
174pub fn is_write_outcome_definitive_http_status(status: u16) -> bool {
175 (400..500).contains(&status)
176}
177
178#[must_use]
188pub(crate) fn is_write_outcome_ambiguous_ws(error: &DeriveWsError) -> bool {
189 match error {
190 DeriveWsError::Transport(_)
191 | DeriveWsError::RequestCancelled { .. }
192 | DeriveWsError::Timeout { .. }
193 | DeriveWsError::Serde(_) => true,
194 DeriveWsError::JsonRpc { code, .. } => is_write_outcome_ambiguous_jsonrpc(*code),
195 DeriveWsError::NotConnected
196 | DeriveWsError::Auth(_)
197 | DeriveWsError::Authentication { .. }
198 | DeriveWsError::Subscription { .. }
199 | DeriveWsError::MissingCredentials { .. } => false,
200 }
201}
202
203#[must_use]
207fn is_fatal_jsonrpc_code(code: i64) -> bool {
208 matches!(code, -32600 | -32700)
209}
210
211#[cfg(test)]
212mod tests {
213 use rstest::rstest;
214 use serde_json::Value;
215
216 use super::*;
217
218 #[rstest]
219 fn test_transport_error_retryable() {
220 let err = DeriveHttpError::transport("conn reset");
221 assert!(should_retry_http_error(&err));
222 assert!(!is_fatal_http_error(&err));
223 }
224
225 #[rstest]
226 #[case(500, true)]
227 #[case(502, true)]
228 #[case(503, true)]
229 #[case(504, true)]
230 #[case(429, true)]
231 #[case(408, true)]
232 #[case(400, false)]
233 #[case(404, false)]
234 #[case(409, false)]
235 #[case(422, false)]
236 fn test_http_status_retry_classification(#[case] status: u16, #[case] retryable: bool) {
237 let err = DeriveHttpError::http(status, "body");
238 assert_eq!(should_retry_http_error(&err), retryable);
239 }
240
241 #[rstest]
242 #[case(401)]
243 #[case(403)]
244 fn test_http_auth_status_is_fatal(#[case] status: u16) {
245 let err = DeriveHttpError::http(status, "Unauthorized");
246 assert!(is_fatal_http_error(&err));
247 assert!(!should_retry_http_error(&err));
248 }
249
250 #[rstest]
251 fn test_jsonrpc_invalid_params_not_retryable() {
252 let err = DeriveHttpError::JsonRpc {
256 code: -32602,
257 message: "signed_max_fee_too_low".into(),
258 data: None,
259 };
260 assert!(!should_retry_http_error(&err));
261 assert!(!is_fatal_http_error(&err));
262 }
263
264 #[rstest]
265 fn test_jsonrpc_server_error_range_retryable() {
266 let err = DeriveHttpError::JsonRpc {
267 code: -32050,
268 message: "Server busy".into(),
269 data: None,
270 };
271 assert!(should_retry_http_error(&err));
272 }
273
274 #[rstest]
275 fn test_jsonrpc_internal_error_retryable() {
276 let err = DeriveHttpError::JsonRpc {
277 code: -32603,
278 message: "Internal error".into(),
279 data: None,
280 };
281 assert!(should_retry_http_error(&err));
282 }
283
284 #[rstest]
285 #[case(400, true)]
286 #[case(401, true)]
287 #[case(403, true)]
288 #[case(408, true)]
289 #[case(429, true)]
290 #[case(500, false)]
291 #[case(503, false)]
292 fn test_http_status_write_outcome_classification(
293 #[case] status: u16,
294 #[case] definitive: bool,
295 ) {
296 assert_eq!(is_write_outcome_definitive_http_status(status), definitive);
297 }
298
299 #[rstest]
300 fn test_jsonrpc_invalid_request_is_fatal() {
301 let err = DeriveHttpError::JsonRpc {
302 code: -32600,
303 message: "Invalid request".into(),
304 data: Some(Value::Null),
305 };
306 assert!(is_fatal_http_error(&err));
307 assert!(!should_retry_http_error(&err));
308 }
309
310 #[rstest]
311 fn test_missing_credentials_terminal() {
312 let err = DeriveHttpError::MissingCredentials {
313 method: "private/order".into(),
314 };
315 assert!(!should_retry_http_error(&err));
316 assert!(is_fatal_http_error(&err));
317 }
318
319 #[rstest]
320 fn test_ws_transport_retryable() {
321 let err = DeriveWsError::transport("send failed");
322 assert!(should_retry_ws_error(&err));
323 }
324
325 #[rstest]
326 fn test_ws_not_connected_terminal() {
327 let err = DeriveWsError::NotConnected;
328 assert!(!should_retry_ws_error(&err));
329 assert!(!is_fatal_ws_error(&err));
330 }
331
332 #[rstest]
333 fn test_ws_request_cancelled_retryable() {
334 let err = DeriveWsError::RequestCancelled {
337 method: "subscribe".into(),
338 };
339 assert!(should_retry_ws_error(&err));
340 }
341
342 #[rstest]
343 fn test_ws_timeout_retryable_not_fatal() {
344 let err = DeriveWsError::Timeout {
345 method: "private/order".into(),
346 };
347 assert!(should_retry_ws_error(&err));
348 assert!(!is_fatal_ws_error(&err));
349 }
350
351 #[rstest]
352 fn test_ws_authentication_and_subscription_failures_are_terminal() {
353 let authentication = DeriveWsError::Authentication {
354 operation: "private/order".into(),
355 reason: "session recovery failed".into(),
356 };
357 let subscription = DeriveWsError::Subscription {
358 details: "30769.trades: unauthorized".into(),
359 };
360
361 assert!(!should_retry_ws_error(&authentication));
362 assert!(is_fatal_ws_error(&authentication));
363 assert!(!should_retry_ws_error(&subscription));
364 assert!(!is_fatal_ws_error(&subscription));
365 }
366
367 #[rstest]
368 fn test_ws_write_outcome_ambiguous_classification() {
369 let ambiguous = [
372 DeriveWsError::transport("send failed"),
373 DeriveWsError::RequestCancelled {
374 method: "private/order".into(),
375 },
376 DeriveWsError::Timeout {
377 method: "private/order".into(),
378 },
379 DeriveWsError::Serde(serde_json::from_str::<Value>("{").unwrap_err()),
382 DeriveWsError::JsonRpc {
383 code: -32603,
384 message: "Internal error".into(),
385 data: None,
386 },
387 ];
388 let definitive = [
389 DeriveWsError::NotConnected,
390 DeriveWsError::JsonRpc {
391 code: -32602,
392 message: "signed_max_fee_too_low".into(),
393 data: None,
394 },
395 DeriveWsError::MissingCredentials {
396 operation: "private/order".into(),
397 },
398 DeriveWsError::Authentication {
399 operation: "private/order".into(),
400 reason: "session recovery failed".into(),
401 },
402 DeriveWsError::Subscription {
403 details: "30769.trades: unauthorized".into(),
404 },
405 ];
406
407 for err in &ambiguous {
408 assert!(
409 is_write_outcome_ambiguous_ws(err),
410 "expected ambiguous: {err}"
411 );
412 }
413
414 for err in &definitive {
415 assert!(
416 !is_write_outcome_ambiguous_ws(err),
417 "expected definitive: {err}",
418 );
419 }
420 }
421}