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//! Static transport and lifecycle configuration for WebSocket connections.
17//!
18//! [`WebSocketConfig`] selects the endpoint, upgrade headers, heartbeat and idle detection,
19//! reconnect policy, transport backend, and optional proxy. Runtime handlers and rate limiting are
20//! supplied to the client constructors instead.
21//!
22//! # Reconnection strategy
23//!
24//! Reconnect settings apply only in handler mode; stream mode ignores them.
25//! `reconnect_max_attempts: None` permits unlimited attempts with exponential backoff, while
26//! `Some(n)` closes the client once `n` consecutive reconnect attempts have either failed or
27//! established connections active for less than 10 seconds. A reconnect active for at least 10
28//! seconds resets its attempt count and backoff delay; shorter-lived connections continue the
29//! current cycle.
30
31use std::fmt::Debug;
32
33use nautilus_core::string::secret::REDACTED;
34use serde::{Deserialize, Serialize};
35
36use crate::error::{NetworkConfigError, NetworkConfigResult};
37
38/// WebSocket transport backend selection.
39///
40/// Selection is runtime so multiple backends can compile side-by-side without
41/// a `compile_error!` collision under `--all-features`.
42///
43/// `Sockudo` is the default backend and is enabled by the `transport-sockudo`
44/// Cargo feature (on by default); it uses a local HTTP/1.1 handshake helper to
45/// pass custom upgrade headers through. When the feature is disabled the
46/// default falls back to `Tungstenite`, which is always compiled and supports
47/// custom HTTP upgrade headers on the WebSocket handshake (see
48/// [`WebSocketConfig::headers`]).
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51#[cfg_attr(
52    feature = "python",
53    pyo3::pyclass(
54        module = "nautilus_trader.core.nautilus_pyo3.network",
55        eq,
56        from_py_object,
57        rename_all = "SCREAMING_SNAKE_CASE"
58    )
59)]
60#[cfg_attr(
61    feature = "python",
62    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.network")
63)]
64#[allow(
65    clippy::unsafe_derive_deserialize,
66    reason = "PyO3-backed enum still needs serde deserialization for strict config decoding"
67)]
68pub enum TransportBackend {
69    /// `tokio-tungstenite` backed transport (default when `transport-sockudo` is disabled).
70    #[cfg_attr(not(feature = "transport-sockudo"), default)]
71    Tungstenite,
72    /// `sockudo-ws` backed transport (default; gated on `transport-sockudo` feature).
73    #[cfg_attr(feature = "transport-sockudo", default)]
74    Sockudo,
75}
76
77/// Configuration for WebSocket client connections.
78///
79/// This struct contains only static configuration settings. Runtime callbacks
80/// (message handler, ping handler) are passed separately to `connect()`.
81///
82/// # Connection Modes
83///
84/// ## Handler Mode
85///
86/// - Use with [`crate::websocket::WebSocketClient::connect`].
87/// - Pass a message handler to `connect()` to receive messages via callback.
88/// - Client spawns internal task to read messages and call handler.
89/// - Supports automatic reconnection with exponential backoff.
90/// - Reconnection config fields (`reconnect_*`) are active.
91/// - Best for long-lived connections, Python bindings, callback-based APIs.
92///
93/// ## Stream Mode
94///
95/// - Use with [`crate::websocket::WebSocketClient::connect_stream`].
96/// - Returns a [`MessageReader`](super::types::MessageReader) stream for the caller to read from.
97/// - **Does NOT support automatic reconnection** (reader owned by caller).
98/// - Reconnection config fields are ignored.
99/// - On disconnect, client transitions to CLOSED state and caller must manually reconnect.
100#[cfg_attr(
101    feature = "python",
102    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network", from_py_object)
103)]
104#[cfg_attr(
105    feature = "python",
106    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.network")
107)]
108#[allow(
109    clippy::unsafe_derive_deserialize,
110    reason = "PyO3-backed config still needs serde deserialization for strict config decoding"
111)]
112#[derive(Clone, Serialize, Deserialize, bon::Builder)]
113#[builder(finish_fn(name = build_inner, vis = ""))]
114#[serde(deny_unknown_fields)]
115pub struct WebSocketConfig {
116    /// The URL to connect to.
117    pub url: String,
118    /// The default headers.
119    #[serde(default)]
120    #[builder(default)]
121    pub headers: Vec<(String, String)>,
122    /// The optional heartbeat interval (seconds).
123    #[serde(default)]
124    pub heartbeat: Option<u64>,
125    /// The optional heartbeat message.
126    #[serde(default)]
127    pub heartbeat_msg: Option<String>,
128    /// The timeout (milliseconds) for reconnection attempts.
129    /// **Note**: Only applies to handler mode. Ignored in stream mode.
130    /// Must be non-zero when set.
131    #[serde(default)]
132    pub reconnect_timeout_ms: Option<u64>,
133    /// The initial reconnection delay (milliseconds) for reconnects.
134    /// **Note**: Only applies to handler mode. Ignored in stream mode.
135    #[serde(default)]
136    pub reconnect_delay_initial_ms: Option<u64>,
137    /// The maximum reconnect delay (milliseconds) for exponential backoff.
138    /// **Note**: Only applies to handler mode. Ignored in stream mode.
139    #[serde(default)]
140    pub reconnect_delay_max_ms: Option<u64>,
141    /// The exponential backoff factor for reconnection delays.
142    /// **Note**: Only applies to handler mode. Ignored in stream mode.
143    #[serde(default)]
144    pub reconnect_backoff_factor: Option<f64>,
145    /// The maximum jitter (milliseconds) added to reconnection delays.
146    /// **Note**: Only applies to handler mode. Ignored in stream mode.
147    #[serde(default)]
148    pub reconnect_jitter_ms: Option<u64>,
149    /// The maximum number of reconnection attempts before giving up.
150    /// **Note**: Only applies to handler mode. Ignored in stream mode.
151    /// - `None`: Unlimited reconnection attempts (default, recommended for production).
152    /// - `Some(n)`: Transitions to CLOSED once `n` consecutive reconnect attempts have either
153    ///   failed or established connections active for less than 10 seconds.
154    #[serde(default)]
155    pub reconnect_max_attempts: Option<u32>,
156    /// The idle timeout (milliseconds) for the read task.
157    /// When set, the read task will break and trigger reconnection if no data
158    /// is received within this duration. Useful for detecting silently dead
159    /// connections where the server stops sending without closing.
160    /// **Note**: Only applies to handler mode. Ignored in stream mode.
161    #[serde(default)]
162    pub idle_timeout_ms: Option<u64>,
163    /// The transport backend to use for the WebSocket connection.
164    ///
165    /// Defaults to [`TransportBackend::Sockudo`] when the `transport-sockudo`
166    /// Cargo feature is enabled (the default), otherwise [`TransportBackend::Tungstenite`].
167    /// When the feature is disabled, `connect_with_server` returns an error if
168    /// `Sockudo` is selected. Both backends pass `headers` into the HTTP
169    /// upgrade request. The Sockudo backend does not yet support proxy tunnels;
170    /// when [`Self::proxy_url`] is set, `connect_with_server` logs a warning
171    /// and routes through Tungstenite regardless of this field.
172    #[serde(default)]
173    #[builder(default)]
174    pub backend: TransportBackend,
175    /// Optional forward proxy URL for the WebSocket connection.
176    ///
177    /// Routes the connection through an HTTP `CONNECT` tunnel. Accepts
178    /// `http://` and `https://` schemes; SOCKS schemes are not yet supported.
179    #[serde(default)]
180    pub proxy_url: Option<String>,
181}
182
183impl Debug for WebSocketConfig {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        f.debug_struct(stringify!(WebSocketConfig))
186            .field("url", &self.url)
187            .field(
188                "headers",
189                &format_args!("<{} header(s)>", self.headers.len()),
190            )
191            .field("heartbeat", &self.heartbeat)
192            .field("heartbeat_msg", &self.heartbeat_msg)
193            .field("reconnect_timeout_ms", &self.reconnect_timeout_ms)
194            .field(
195                "reconnect_delay_initial_ms",
196                &self.reconnect_delay_initial_ms,
197            )
198            .field("reconnect_delay_max_ms", &self.reconnect_delay_max_ms)
199            .field("reconnect_backoff_factor", &self.reconnect_backoff_factor)
200            .field("reconnect_jitter_ms", &self.reconnect_jitter_ms)
201            .field("reconnect_max_attempts", &self.reconnect_max_attempts)
202            .field("idle_timeout_ms", &self.idle_timeout_ms)
203            .field("backend", &self.backend)
204            .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
205            .finish()
206    }
207}
208
209impl<S: web_socket_config_builder::IsComplete> WebSocketConfigBuilder<S> {
210    /// Validates and builds the [`WebSocketConfig`].
211    ///
212    /// # Errors
213    ///
214    /// Returns a [`NetworkConfigError`] if any field fails validation
215    /// (see [`WebSocketConfig::validate`]).
216    pub fn build(self) -> NetworkConfigResult<WebSocketConfig> {
217        let config = self.build_inner();
218        config.validate()?;
219        Ok(config)
220    }
221}
222
223impl WebSocketConfig {
224    /// Checks whether all WebSocket settings are valid.
225    ///
226    /// # Errors
227    ///
228    /// Returns a [`NetworkConfigError`] if `url` is empty, the heartbeat interval or a
229    /// reconnection timing field is not positive, `reconnect_backoff_factor` is outside
230    /// `[1.0, 100.0]`, or `reconnect_delay_initial_ms` exceeds `reconnect_delay_max_ms`.
231    pub fn validate(&self) -> NetworkConfigResult<()> {
232        let mut errors = Vec::new();
233
234        if self.url.trim().is_empty() {
235            errors.push(NetworkConfigError::invalid("url", "must not be empty"));
236        }
237
238        if let Some(interval) = self.heartbeat
239            && interval == 0
240        {
241            errors.push(NetworkConfigError::invalid(
242                "heartbeat",
243                "interval must be positive",
244            ));
245        }
246
247        // `reconnect_jitter_ms` is intentionally unchecked: zero disables jitter and
248        // `ExponentialBackoff::new` accepts it.
249        for (field, value) in [
250            ("reconnect_timeout_ms", self.reconnect_timeout_ms),
251            (
252                "reconnect_delay_initial_ms",
253                self.reconnect_delay_initial_ms,
254            ),
255            ("reconnect_delay_max_ms", self.reconnect_delay_max_ms),
256            ("idle_timeout_ms", self.idle_timeout_ms),
257        ] {
258            if let Some(value) = value
259                && value == 0
260            {
261                errors.push(NetworkConfigError::invalid(
262                    field,
263                    format!("must be positive, was {value}"),
264                ));
265            }
266        }
267
268        if let Some(factor) = self.reconnect_backoff_factor
269            && !(1.0..=100.0).contains(&factor)
270        {
271            errors.push(NetworkConfigError::invalid(
272                "reconnect_backoff_factor",
273                format!("must be in range [1.0, 100.0], was {factor}"),
274            ));
275        }
276
277        if let (Some(initial), Some(max)) =
278            (self.reconnect_delay_initial_ms, self.reconnect_delay_max_ms)
279            && initial > max
280        {
281            errors.push(NetworkConfigError::invalid(
282                "reconnect_delay_initial_ms",
283                format!("must not exceed reconnect_delay_max_ms ({max}), was {initial}"),
284            ));
285        }
286
287        NetworkConfigError::collect(errors)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use rstest::rstest;
294    use serde_json::json;
295
296    use super::WebSocketConfig;
297    use crate::error::NetworkConfigError;
298
299    #[rstest]
300    fn test_deserialize_websocket_config_rejects_unknown_field() {
301        let config = json!({
302            "url": "wss://example.com/ws",
303            "unexpected": true,
304        });
305
306        let error = serde_json::from_value::<WebSocketConfig>(config).unwrap_err();
307
308        assert!(error.to_string().contains("unknown field `unexpected`"));
309    }
310
311    fn valid_config() -> WebSocketConfig {
312        WebSocketConfig::builder()
313            .url("wss://example.com/ws".to_string())
314            .build()
315            .expect("baseline websocket config should be valid")
316    }
317
318    #[rstest]
319    fn test_builder_accepts_valid_config() {
320        let result = WebSocketConfig::builder()
321            .url("wss://example.com/ws".to_string())
322            .build();
323
324        assert!(result.is_ok());
325    }
326
327    #[rstest]
328    fn test_validate_accepts_zero_jitter() {
329        let mut config = valid_config();
330        config.reconnect_jitter_ms = Some(0);
331
332        assert!(config.validate().is_ok());
333    }
334
335    #[rstest]
336    #[case::empty_url(|c: &mut WebSocketConfig| c.url = String::new(), "url")]
337    #[case::heartbeat(|c: &mut WebSocketConfig| c.heartbeat = Some(0), "heartbeat")]
338    #[case::reconnect_timeout(|c: &mut WebSocketConfig| c.reconnect_timeout_ms = Some(0), "reconnect_timeout_ms")]
339    #[case::reconnect_delay_initial(|c: &mut WebSocketConfig| c.reconnect_delay_initial_ms = Some(0), "reconnect_delay_initial_ms")]
340    #[case::reconnect_delay_max(|c: &mut WebSocketConfig| c.reconnect_delay_max_ms = Some(0), "reconnect_delay_max_ms")]
341    #[case::idle_timeout(|c: &mut WebSocketConfig| c.idle_timeout_ms = Some(0), "idle_timeout_ms")]
342    fn test_validate_rejects_invalid_field(
343        #[case] mutate: fn(&mut WebSocketConfig),
344        #[case] expected_field: &str,
345    ) {
346        let mut config = valid_config();
347        mutate(&mut config);
348
349        let err = config
350            .validate()
351            .expect_err("invalid value should be rejected");
352
353        assert!(
354            matches!(err, NetworkConfigError::Invalid { field, .. } if field == expected_field)
355        );
356    }
357
358    #[rstest]
359    #[case::too_small(0.5)]
360    #[case::too_large(100.1)]
361    #[case::nan(f64::NAN)]
362    #[case::infinite(f64::INFINITY)]
363    fn test_validate_rejects_invalid_backoff_factor(#[case] factor: f64) {
364        let mut config = valid_config();
365        config.reconnect_backoff_factor = Some(factor);
366
367        let err = config
368            .validate()
369            .expect_err("invalid backoff factor should be rejected");
370
371        assert!(
372            matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_backoff_factor")
373        );
374    }
375
376    #[rstest]
377    fn test_validate_rejects_delay_initial_exceeding_max() {
378        let mut config = valid_config();
379        config.reconnect_delay_initial_ms = Some(5_000);
380        config.reconnect_delay_max_ms = Some(1_000);
381
382        let err = config
383            .validate()
384            .expect_err("initial delay above max should be rejected");
385
386        assert!(
387            matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_delay_initial_ms")
388        );
389    }
390
391    #[rstest]
392    fn test_validate_collects_multiple_errors() {
393        let mut config = valid_config();
394        config.url = String::new();
395        config.reconnect_timeout_ms = Some(0);
396
397        let err = config.validate().expect_err("multiple invalid fields");
398
399        match err {
400            NetworkConfigError::Multiple { errors } => assert_eq!(errors.len(), 2),
401            other @ NetworkConfigError::Invalid { .. } => {
402                panic!("expected Multiple, was {other:?}")
403            }
404        }
405    }
406
407    #[rstest]
408    fn test_debug_redacts_proxy_credentials() {
409        const SECRET: &str = "unique-proxy-secret";
410        let mut config = valid_config();
411        config.proxy_url = Some(format!("http://proxytest:{SECRET}@proxy.example.com:8080"));
412
413        let debug = format!("{config:?}");
414
415        assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
416        assert!(!debug.contains(SECRET));
417    }
418}