nautilus-network 0.59.0

Network communication machinery for the Nautilus trading engine
// -------------------------------------------------------------------------------------------------
//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
//  https://nautechsystems.io
//
//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
//  You may not use this file except in compliance with the License.
//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
// -------------------------------------------------------------------------------------------------

//! Configuration for WebSocket client connections.
//!
//! # Reconnection Strategy
//!
//! The default configuration uses unlimited reconnection attempts (`reconnect_max_attempts: None`).
//! This is intentional for trading systems because:
//! - Venues may be down for extended periods but eventually recover.
//! - Exponential backoff already prevents resource waste.
//! - Automatic recovery can be useful when manual intervention is not desirable.
//!
//! Use `Some(n)` primarily for testing, development, or non-critical connections.

use std::fmt::Debug;

use serde::{Deserialize, Serialize};

use crate::error::{NetworkConfigError, NetworkConfigResult};

/// WebSocket transport backend selection.
///
/// Selection is runtime so multiple backends can compile side-by-side without
/// a `compile_error!` collision under `--all-features`.
///
/// `Sockudo` is the default backend and is enabled by the `transport-sockudo`
/// Cargo feature (on by default); it uses a local HTTP/1.1 handshake helper to
/// pass custom upgrade headers through. When the feature is disabled the
/// default falls back to `Tungstenite`, which is always compiled and supports
/// custom HTTP upgrade headers on the WebSocket handshake (see
/// [`WebSocketConfig::headers`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransportBackend {
    /// `tokio-tungstenite` backed transport (default when `transport-sockudo` is disabled).
    #[cfg_attr(not(feature = "transport-sockudo"), default)]
    Tungstenite,
    /// `sockudo-ws` backed transport (default; gated on `transport-sockudo` feature).
    #[cfg_attr(feature = "transport-sockudo", default)]
    Sockudo,
}

/// Configuration for WebSocket client connections.
///
/// This struct contains only static configuration settings. Runtime callbacks
/// (message handler, ping handler) are passed separately to `connect()`.
///
/// # Connection Modes
///
/// ## Handler Mode
///
/// - Use with [`crate::websocket::WebSocketClient::connect`].
/// - Pass a message handler to `connect()` to receive messages via callback.
/// - Client spawns internal task to read messages and call handler.
/// - Supports automatic reconnection with exponential backoff.
/// - Reconnection config fields (`reconnect_*`) are active.
/// - Best for long-lived connections, Python bindings, callback-based APIs.
///
/// ## Stream Mode
///
/// - Use with [`crate::websocket::WebSocketClient::connect_stream`].
/// - Returns a [`MessageReader`](super::types::MessageReader) stream for the caller to read from.
/// - **Does NOT support automatic reconnection** (reader owned by caller).
/// - Reconnection config fields are ignored.
/// - On disconnect, client transitions to CLOSED state and caller must manually reconnect.
#[cfg_attr(
    feature = "python",
    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network", from_py_object)
)]
#[cfg_attr(
    feature = "python",
    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.network")
)]
#[allow(
    clippy::unsafe_derive_deserialize,
    reason = "PyO3-backed config still needs serde deserialization for strict config decoding"
)]
#[derive(Clone, Debug, Serialize, Deserialize, bon::Builder)]
#[builder(finish_fn(name = build_inner, vis = ""))]
#[serde(deny_unknown_fields)]
pub struct WebSocketConfig {
    /// The URL to connect to.
    pub url: String,
    /// The default headers.
    #[serde(default)]
    #[builder(default)]
    pub headers: Vec<(String, String)>,
    /// The optional heartbeat interval (seconds).
    #[serde(default)]
    pub heartbeat: Option<u64>,
    /// The optional heartbeat message.
    #[serde(default)]
    pub heartbeat_msg: Option<String>,
    /// The timeout (milliseconds) for reconnection attempts.
    /// **Note**: Only applies to handler mode. Ignored in stream mode.
    /// Must be non-zero when set.
    #[serde(default)]
    pub reconnect_timeout_ms: Option<u64>,
    /// The initial reconnection delay (milliseconds) for reconnects.
    /// **Note**: Only applies to handler mode. Ignored in stream mode.
    #[serde(default)]
    pub reconnect_delay_initial_ms: Option<u64>,
    /// The maximum reconnect delay (milliseconds) for exponential backoff.
    /// **Note**: Only applies to handler mode. Ignored in stream mode.
    #[serde(default)]
    pub reconnect_delay_max_ms: Option<u64>,
    /// The exponential backoff factor for reconnection delays.
    /// **Note**: Only applies to handler mode. Ignored in stream mode.
    #[serde(default)]
    pub reconnect_backoff_factor: Option<f64>,
    /// The maximum jitter (milliseconds) added to reconnection delays.
    /// **Note**: Only applies to handler mode. Ignored in stream mode.
    #[serde(default)]
    pub reconnect_jitter_ms: Option<u64>,
    /// The maximum number of reconnection attempts before giving up.
    /// **Note**: Only applies to handler mode. Ignored in stream mode.
    /// - `None`: Unlimited reconnection attempts (default, recommended for production).
    /// - `Some(n)`: After n failed attempts, transition to CLOSED state.
    #[serde(default)]
    pub reconnect_max_attempts: Option<u32>,
    /// The idle timeout (milliseconds) for the read task.
    /// When set, the read task will break and trigger reconnection if no data
    /// is received within this duration. Useful for detecting silently dead
    /// connections where the server stops sending without closing.
    /// **Note**: Only applies to handler mode. Ignored in stream mode.
    #[serde(default)]
    pub idle_timeout_ms: Option<u64>,
    /// The transport backend to use for the WebSocket connection.
    ///
    /// Defaults to [`TransportBackend::Sockudo`] when the `transport-sockudo`
    /// Cargo feature is enabled (the default), otherwise [`TransportBackend::Tungstenite`].
    /// When the feature is disabled, `connect_with_server` returns an error if
    /// `Sockudo` is selected. Both backends pass `headers` into the HTTP
    /// upgrade request. The Sockudo backend does not yet support proxy tunnels;
    /// when [`Self::proxy_url`] is set, `connect_with_server` logs a warning
    /// and routes through Tungstenite regardless of this field.
    #[serde(default)]
    #[builder(default)]
    pub backend: TransportBackend,
    /// Optional forward proxy URL for the WebSocket connection.
    ///
    /// Routes the connection through an HTTP `CONNECT` tunnel. Accepts
    /// `http://` and `https://` schemes; SOCKS schemes are not yet supported.
    #[serde(default)]
    pub proxy_url: Option<String>,
}

impl<S: web_socket_config_builder::IsComplete> WebSocketConfigBuilder<S> {
    /// Validates and builds the [`WebSocketConfig`].
    ///
    /// # Errors
    ///
    /// Returns a [`NetworkConfigError`] if any field fails validation
    /// (see [`WebSocketConfig::validate`]).
    pub fn build(self) -> NetworkConfigResult<WebSocketConfig> {
        let config = self.build_inner();
        config.validate()?;
        Ok(config)
    }
}

impl WebSocketConfig {
    /// Checks whether all WebSocket settings are valid.
    ///
    /// # Errors
    ///
    /// Returns a [`NetworkConfigError`] if `url` is empty, the heartbeat interval or a
    /// reconnection timing field is not positive, `reconnect_backoff_factor` is not finite and
    /// at least `1.0`, or `reconnect_delay_initial_ms` exceeds `reconnect_delay_max_ms`.
    pub fn validate(&self) -> NetworkConfigResult<()> {
        let mut errors = Vec::new();

        if self.url.trim().is_empty() {
            errors.push(NetworkConfigError::invalid("url", "must not be empty"));
        }

        if let Some(interval) = self.heartbeat
            && interval == 0
        {
            errors.push(NetworkConfigError::invalid(
                "heartbeat",
                "interval must be positive",
            ));
        }

        // `reconnect_jitter_ms` is intentionally unchecked: zero disables jitter and
        // `ExponentialBackoff::new` accepts it.
        for (field, value) in [
            ("reconnect_timeout_ms", self.reconnect_timeout_ms),
            (
                "reconnect_delay_initial_ms",
                self.reconnect_delay_initial_ms,
            ),
            ("reconnect_delay_max_ms", self.reconnect_delay_max_ms),
            ("idle_timeout_ms", self.idle_timeout_ms),
        ] {
            if let Some(value) = value
                && value == 0
            {
                errors.push(NetworkConfigError::invalid(
                    field,
                    format!("must be positive, was {value}"),
                ));
            }
        }

        if let Some(factor) = self.reconnect_backoff_factor
            && !(factor.is_finite() && factor >= 1.0)
        {
            errors.push(NetworkConfigError::invalid(
                "reconnect_backoff_factor",
                format!("must be finite and >= 1.0, was {factor}"),
            ));
        }

        if let (Some(initial), Some(max)) =
            (self.reconnect_delay_initial_ms, self.reconnect_delay_max_ms)
            && initial > max
        {
            errors.push(NetworkConfigError::invalid(
                "reconnect_delay_initial_ms",
                format!("must not exceed reconnect_delay_max_ms ({max}), was {initial}"),
            ));
        }

        NetworkConfigError::collect(errors)
    }
}

#[cfg(test)]
mod tests {
    use rstest::rstest;
    use serde_json::json;

    use super::WebSocketConfig;
    use crate::error::NetworkConfigError;

    #[rstest]
    fn test_deserialize_websocket_config_rejects_unknown_field() {
        let config = json!({
            "url": "wss://example.com/ws",
            "unexpected": true,
        });

        let error = serde_json::from_value::<WebSocketConfig>(config).unwrap_err();

        assert!(error.to_string().contains("unknown field `unexpected`"));
    }

    fn valid_config() -> WebSocketConfig {
        WebSocketConfig::builder()
            .url("wss://example.com/ws".to_string())
            .build()
            .expect("baseline websocket config should be valid")
    }

    #[rstest]
    fn test_builder_accepts_valid_config() {
        let result = WebSocketConfig::builder()
            .url("wss://example.com/ws".to_string())
            .build();

        assert!(result.is_ok());
    }

    #[rstest]
    fn test_validate_accepts_zero_jitter() {
        let mut config = valid_config();
        config.reconnect_jitter_ms = Some(0);

        assert!(config.validate().is_ok());
    }

    #[rstest]
    #[case::empty_url(|c: &mut WebSocketConfig| c.url = String::new(), "url")]
    #[case::heartbeat(|c: &mut WebSocketConfig| c.heartbeat = Some(0), "heartbeat")]
    #[case::reconnect_timeout(|c: &mut WebSocketConfig| c.reconnect_timeout_ms = Some(0), "reconnect_timeout_ms")]
    #[case::reconnect_delay_initial(|c: &mut WebSocketConfig| c.reconnect_delay_initial_ms = Some(0), "reconnect_delay_initial_ms")]
    #[case::reconnect_delay_max(|c: &mut WebSocketConfig| c.reconnect_delay_max_ms = Some(0), "reconnect_delay_max_ms")]
    #[case::idle_timeout(|c: &mut WebSocketConfig| c.idle_timeout_ms = Some(0), "idle_timeout_ms")]
    fn test_validate_rejects_invalid_field(
        #[case] mutate: fn(&mut WebSocketConfig),
        #[case] expected_field: &str,
    ) {
        let mut config = valid_config();
        mutate(&mut config);

        let err = config
            .validate()
            .expect_err("invalid value should be rejected");

        assert!(
            matches!(err, NetworkConfigError::Invalid { field, .. } if field == expected_field)
        );
    }

    #[rstest]
    #[case::too_small(0.5)]
    #[case::nan(f64::NAN)]
    #[case::infinite(f64::INFINITY)]
    fn test_validate_rejects_invalid_backoff_factor(#[case] factor: f64) {
        let mut config = valid_config();
        config.reconnect_backoff_factor = Some(factor);

        let err = config
            .validate()
            .expect_err("invalid backoff factor should be rejected");

        assert!(
            matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_backoff_factor")
        );
    }

    #[rstest]
    fn test_validate_rejects_delay_initial_exceeding_max() {
        let mut config = valid_config();
        config.reconnect_delay_initial_ms = Some(5_000);
        config.reconnect_delay_max_ms = Some(1_000);

        let err = config
            .validate()
            .expect_err("initial delay above max should be rejected");

        assert!(
            matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_delay_initial_ms")
        );
    }

    #[rstest]
    fn test_validate_collects_multiple_errors() {
        let mut config = valid_config();
        config.url = String::new();
        config.reconnect_timeout_ms = Some(0);

        let err = config.validate().expect_err("multiple invalid fields");

        match err {
            NetworkConfigError::Multiple { errors } => assert_eq!(errors.len(), 2),
            other @ NetworkConfigError::Invalid { .. } => {
                panic!("expected Multiple, was {other:?}")
            }
        }
    }
}