Skip to main content

nautilus_network/websocket/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Configuration for WebSocket client connections.
17//!
18//! # Reconnection Strategy
19//!
20//! The default configuration uses unlimited reconnection attempts (`reconnect_max_attempts: None`).
21//! This is intentional for trading systems because:
22//! - Venues may be down for extended periods but eventually recover.
23//! - Exponential backoff already prevents resource waste.
24//! - Automatic recovery can be useful when manual intervention is not desirable.
25//!
26//! Use `Some(n)` primarily for testing, development, or non-critical connections.
27
28use std::fmt::Debug;
29
30use serde::{Deserialize, Serialize};
31
32/// WebSocket transport backend selection.
33///
34/// Selection is runtime so multiple backends can compile side-by-side without
35/// a `compile_error!` collision under `--all-features`.
36///
37/// `Sockudo` is the default backend and is enabled by the `transport-sockudo`
38/// Cargo feature (on by default); it uses a local HTTP/1.1 handshake helper to
39/// pass custom upgrade headers through. When the feature is disabled the
40/// default falls back to `Tungstenite`, which is always compiled and supports
41/// custom HTTP upgrade headers on the WebSocket handshake (see
42/// [`WebSocketConfig::headers`]).
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum TransportBackend {
46    /// `tokio-tungstenite` backed transport (default when `transport-sockudo` is disabled).
47    #[cfg_attr(not(feature = "transport-sockudo"), default)]
48    Tungstenite,
49    /// `sockudo-ws` backed transport (default; gated on `transport-sockudo` feature).
50    #[cfg_attr(feature = "transport-sockudo", default)]
51    Sockudo,
52}
53
54/// Configuration for WebSocket client connections.
55///
56/// This struct contains only static configuration settings. Runtime callbacks
57/// (message handler, ping handler) are passed separately to `connect()`.
58///
59/// # Connection Modes
60///
61/// ## Handler Mode
62///
63/// - Use with [`crate::websocket::WebSocketClient::connect`].
64/// - Pass a message handler to `connect()` to receive messages via callback.
65/// - Client spawns internal task to read messages and call handler.
66/// - Supports automatic reconnection with exponential backoff.
67/// - Reconnection config fields (`reconnect_*`) are active.
68/// - Best for long-lived connections, Python bindings, callback-based APIs.
69///
70/// ## Stream Mode
71///
72/// - Use with [`crate::websocket::WebSocketClient::connect_stream`].
73/// - Returns a [`MessageReader`](super::types::MessageReader) stream for the caller to read from.
74/// - **Does NOT support automatic reconnection** (reader owned by caller).
75/// - Reconnection config fields are ignored.
76/// - On disconnect, client transitions to CLOSED state and caller must manually reconnect.
77#[cfg_attr(
78    feature = "python",
79    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network", from_py_object)
80)]
81#[cfg_attr(
82    feature = "python",
83    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.network")
84)]
85#[allow(
86    clippy::unsafe_derive_deserialize,
87    reason = "PyO3-backed config still needs serde deserialization for strict config decoding"
88)]
89#[derive(Clone, Debug, Serialize, Deserialize, bon::Builder)]
90#[serde(deny_unknown_fields)]
91pub struct WebSocketConfig {
92    /// The URL to connect to.
93    pub url: String,
94    /// The default headers.
95    #[serde(default)]
96    #[builder(default)]
97    pub headers: Vec<(String, String)>,
98    /// The optional heartbeat interval (seconds).
99    #[serde(default)]
100    pub heartbeat: Option<u64>,
101    /// The optional heartbeat message.
102    #[serde(default)]
103    pub heartbeat_msg: Option<String>,
104    /// The timeout (milliseconds) for reconnection attempts.
105    /// **Note**: Only applies to handler mode. Ignored in stream mode.
106    #[serde(default)]
107    pub reconnect_timeout_ms: Option<u64>,
108    /// The initial reconnection delay (milliseconds) for reconnects.
109    /// **Note**: Only applies to handler mode. Ignored in stream mode.
110    #[serde(default)]
111    pub reconnect_delay_initial_ms: Option<u64>,
112    /// The maximum reconnect delay (milliseconds) for exponential backoff.
113    /// **Note**: Only applies to handler mode. Ignored in stream mode.
114    #[serde(default)]
115    pub reconnect_delay_max_ms: Option<u64>,
116    /// The exponential backoff factor for reconnection delays.
117    /// **Note**: Only applies to handler mode. Ignored in stream mode.
118    #[serde(default)]
119    pub reconnect_backoff_factor: Option<f64>,
120    /// The maximum jitter (milliseconds) added to reconnection delays.
121    /// **Note**: Only applies to handler mode. Ignored in stream mode.
122    #[serde(default)]
123    pub reconnect_jitter_ms: Option<u64>,
124    /// The maximum number of reconnection attempts before giving up.
125    /// **Note**: Only applies to handler mode. Ignored in stream mode.
126    /// - `None`: Unlimited reconnection attempts (default, recommended for production).
127    /// - `Some(n)`: After n failed attempts, transition to CLOSED state.
128    #[serde(default)]
129    pub reconnect_max_attempts: Option<u32>,
130    /// The idle timeout (milliseconds) for the read task.
131    /// When set, the read task will break and trigger reconnection if no data
132    /// is received within this duration. Useful for detecting silently dead
133    /// connections where the server stops sending without closing.
134    /// **Note**: Only applies to handler mode. Ignored in stream mode.
135    #[serde(default)]
136    pub idle_timeout_ms: Option<u64>,
137    /// The transport backend to use for the WebSocket connection.
138    ///
139    /// Defaults to [`TransportBackend::Sockudo`] when the `transport-sockudo`
140    /// Cargo feature is enabled (the default), otherwise [`TransportBackend::Tungstenite`].
141    /// When the feature is disabled, `connect_with_server` returns an error if
142    /// `Sockudo` is selected. Both backends pass `headers` into the HTTP
143    /// upgrade request. The Sockudo backend does not yet support proxy tunnels;
144    /// when [`Self::proxy_url`] is set, `connect_with_server` logs a warning
145    /// and routes through Tungstenite regardless of this field.
146    #[serde(default)]
147    #[builder(default)]
148    pub backend: TransportBackend,
149    /// Optional forward proxy URL for the WebSocket connection.
150    ///
151    /// Routes the connection through an HTTP `CONNECT` tunnel. Accepts
152    /// `http://` and `https://` schemes; SOCKS schemes are not yet supported.
153    #[serde(default)]
154    pub proxy_url: Option<String>,
155}
156
157#[cfg(test)]
158mod tests {
159    use rstest::rstest;
160    use serde_json::json;
161
162    use super::WebSocketConfig;
163
164    #[rstest]
165    fn test_deserialize_websocket_config_rejects_unknown_field() {
166        let config = json!({
167            "url": "wss://example.com/ws",
168            "unexpected": true,
169        });
170
171        let error = serde_json::from_value::<WebSocketConfig>(config).unwrap_err();
172
173        assert!(error.to_string().contains("unknown field `unexpected`"));
174    }
175}