use nautilus_network::retry::RetryConfig;
use crate::{http::DeriveHttpError, websocket::DeriveWsError};
#[must_use]
pub fn http_retry_config(
max_retries: u32,
initial_delay_ms: u64,
max_delay_ms: u64,
) -> RetryConfig {
RetryConfig {
max_retries,
initial_delay_ms,
max_delay_ms,
backoff_factor: 2.0,
jitter_ms: 1_000,
operation_timeout_ms: Some(60_000),
immediate_first: false,
max_elapsed_ms: Some(180_000),
}
}
#[must_use]
pub fn should_retry_http_error(error: &DeriveHttpError) -> bool {
match error {
DeriveHttpError::Transport(_) => true,
DeriveHttpError::Http { status, .. } => is_retryable_status(*status),
DeriveHttpError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
DeriveHttpError::MissingResult { .. }
| DeriveHttpError::Decode(_)
| DeriveHttpError::Serde(_)
| DeriveHttpError::Auth(_)
| DeriveHttpError::MissingCredentials { .. } => false,
}
}
#[must_use]
pub fn is_fatal_http_error(error: &DeriveHttpError) -> bool {
match error {
DeriveHttpError::Auth(_) | DeriveHttpError::MissingCredentials { .. } => true,
DeriveHttpError::Http { status, .. } => matches!(*status, 401 | 403),
DeriveHttpError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
_ => false,
}
}
#[must_use]
pub fn should_retry_ws_error(error: &DeriveWsError) -> bool {
match error {
DeriveWsError::Transport(_)
| DeriveWsError::RequestCancelled { .. }
| DeriveWsError::Timeout { .. } => true,
DeriveWsError::JsonRpc { code, .. } => is_retryable_jsonrpc_code(*code),
DeriveWsError::NotConnected
| DeriveWsError::Serde(_)
| DeriveWsError::Auth(_)
| DeriveWsError::MissingCredentials { .. } => false,
}
}
#[must_use]
pub fn is_fatal_ws_error(error: &DeriveWsError) -> bool {
match error {
DeriveWsError::Auth(_) | DeriveWsError::MissingCredentials { .. } => true,
DeriveWsError::JsonRpc { code, .. } => is_fatal_jsonrpc_code(*code),
_ => false,
}
}
#[must_use]
fn is_retryable_status(status: u16) -> bool {
matches!(status, 408 | 429) || (500..600).contains(&status)
}
#[must_use]
pub(crate) fn is_retryable_jsonrpc_code(code: i64) -> bool {
code == -32603 || (-32099..=-32000).contains(&code)
}
#[must_use]
pub(crate) fn is_write_outcome_ambiguous_jsonrpc(code: i64) -> bool {
code == -32603
}
#[must_use]
pub fn is_write_outcome_definitive_http_status(status: u16) -> bool {
(400..500).contains(&status)
}
#[must_use]
pub(crate) fn is_write_outcome_ambiguous_ws(error: &DeriveWsError) -> bool {
match error {
DeriveWsError::Transport(_)
| DeriveWsError::RequestCancelled { .. }
| DeriveWsError::Timeout { .. }
| DeriveWsError::Serde(_) => true,
DeriveWsError::JsonRpc { code, .. } => is_write_outcome_ambiguous_jsonrpc(*code),
DeriveWsError::NotConnected
| DeriveWsError::Auth(_)
| DeriveWsError::MissingCredentials { .. } => false,
}
}
#[must_use]
fn is_fatal_jsonrpc_code(code: i64) -> bool {
matches!(code, -32600 | -32700)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use serde_json::Value;
use super::*;
#[rstest]
fn test_transport_error_retryable() {
let err = DeriveHttpError::transport("conn reset");
assert!(should_retry_http_error(&err));
assert!(!is_fatal_http_error(&err));
}
#[rstest]
#[case(500, true)]
#[case(502, true)]
#[case(503, true)]
#[case(504, true)]
#[case(429, true)]
#[case(408, true)]
#[case(400, false)]
#[case(404, false)]
#[case(409, false)]
#[case(422, false)]
fn test_http_status_retry_classification(#[case] status: u16, #[case] retryable: bool) {
let err = DeriveHttpError::http(status, "body");
assert_eq!(should_retry_http_error(&err), retryable);
}
#[rstest]
#[case(401)]
#[case(403)]
fn test_http_auth_status_is_fatal(#[case] status: u16) {
let err = DeriveHttpError::http(status, "Unauthorized");
assert!(is_fatal_http_error(&err));
assert!(!should_retry_http_error(&err));
}
#[rstest]
fn test_jsonrpc_invalid_params_not_retryable() {
let err = DeriveHttpError::JsonRpc {
code: -32602,
message: "signed_max_fee_too_low".into(),
data: None,
};
assert!(!should_retry_http_error(&err));
assert!(!is_fatal_http_error(&err));
}
#[rstest]
fn test_jsonrpc_server_error_range_retryable() {
let err = DeriveHttpError::JsonRpc {
code: -32050,
message: "Server busy".into(),
data: None,
};
assert!(should_retry_http_error(&err));
}
#[rstest]
fn test_jsonrpc_internal_error_retryable() {
let err = DeriveHttpError::JsonRpc {
code: -32603,
message: "Internal error".into(),
data: None,
};
assert!(should_retry_http_error(&err));
}
#[rstest]
#[case(400, true)]
#[case(401, true)]
#[case(403, true)]
#[case(408, true)]
#[case(429, true)]
#[case(500, false)]
#[case(503, false)]
fn test_http_status_write_outcome_classification(
#[case] status: u16,
#[case] definitive: bool,
) {
assert_eq!(is_write_outcome_definitive_http_status(status), definitive);
}
#[rstest]
fn test_jsonrpc_invalid_request_is_fatal() {
let err = DeriveHttpError::JsonRpc {
code: -32600,
message: "Invalid request".into(),
data: Some(Value::Null),
};
assert!(is_fatal_http_error(&err));
assert!(!should_retry_http_error(&err));
}
#[rstest]
fn test_missing_credentials_terminal() {
let err = DeriveHttpError::MissingCredentials {
method: "private/order".into(),
};
assert!(!should_retry_http_error(&err));
assert!(is_fatal_http_error(&err));
}
#[rstest]
fn test_ws_transport_retryable() {
let err = DeriveWsError::transport("send failed");
assert!(should_retry_ws_error(&err));
}
#[rstest]
fn test_ws_not_connected_terminal() {
let err = DeriveWsError::NotConnected;
assert!(!should_retry_ws_error(&err));
assert!(!is_fatal_ws_error(&err));
}
#[rstest]
fn test_ws_request_cancelled_retryable() {
let err = DeriveWsError::RequestCancelled {
method: "subscribe".into(),
};
assert!(should_retry_ws_error(&err));
}
#[rstest]
fn test_ws_timeout_retryable_not_fatal() {
let err = DeriveWsError::Timeout {
method: "private/order".into(),
};
assert!(should_retry_ws_error(&err));
assert!(!is_fatal_ws_error(&err));
}
#[rstest]
fn test_ws_write_outcome_ambiguous_classification() {
let ambiguous = [
DeriveWsError::transport("send failed"),
DeriveWsError::RequestCancelled {
method: "private/order".into(),
},
DeriveWsError::Timeout {
method: "private/order".into(),
},
DeriveWsError::Serde(serde_json::from_str::<Value>("{").unwrap_err()),
DeriveWsError::JsonRpc {
code: -32603,
message: "Internal error".into(),
data: None,
},
];
let definitive = [
DeriveWsError::NotConnected,
DeriveWsError::JsonRpc {
code: -32602,
message: "signed_max_fee_too_low".into(),
data: None,
},
DeriveWsError::MissingCredentials {
operation: "private/order".into(),
},
];
for err in &ambiguous {
assert!(
is_write_outcome_ambiguous_ws(err),
"expected ambiguous: {err}"
);
}
for err in &definitive {
assert!(
!is_write_outcome_ambiguous_ws(err),
"expected definitive: {err}",
);
}
}
}