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 through the client builders 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, num::NonZeroU32, time::Duration};
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 path 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.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 = "network configuration requires strict serde 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/// Static configuration for WebSocket client connections.
78///
79/// Runtime handlers and rate limiters are passed separately through the client builders.
80///
81/// # Connection modes
82///
83/// ## Handler mode
84///
85/// - Uses [`WebSocketClient::builder`](crate::websocket::WebSocketClient::builder).
86/// - Delivers messages through the supplied callback.
87/// - Runs the reader in an internal task.
88/// - Supports automatic reconnection with exponential backoff.
89/// - Applies `reconnect_*`, `heartbeat_timeout_secs`, and `idle_timeout_ms` settings.
90/// - Suits long-lived connections and callback-based APIs.
91///
92/// ## Stream mode
93///
94/// - Uses [`WebSocketClient::stream_builder`](crate::websocket::WebSocketClient::stream_builder).
95/// - Returns a [`MessageReader`](super::types::MessageReader) owned by the caller.
96/// - Does not support automatic reconnection because the client cannot replace the caller's reader.
97/// - Ignores `reconnect_*`, `heartbeat_timeout_secs`, and `idle_timeout_ms` settings.
98/// - Enters the closed state after disconnection, requiring the caller to create a new connection.
99#[allow(
100    clippy::unsafe_derive_deserialize,
101    reason = "network configuration requires strict serde decoding"
102)]
103#[derive(Clone, Serialize, Deserialize, bon::Builder)]
104#[builder(finish_fn(name = build_inner, vis = ""))]
105#[serde(deny_unknown_fields)]
106pub struct WebSocketConfig {
107    /// The URL to connect to.
108    pub url: String,
109    /// The default headers.
110    #[serde(default)]
111    #[builder(default)]
112    pub headers: Vec<(String, String)>,
113    /// The optional heartbeat interval (seconds).
114    ///
115    /// Each timing field carries the coarsest unit that expresses every legitimate value, and
116    /// quantities compared against each other share a unit: this and [`Self::heartbeat_timeout_secs`]
117    /// are bounded below by whole-second cadences, while reconnect delays and jitter have real
118    /// sub-second values and stay in milliseconds.
119    #[serde(default)]
120    pub heartbeat_interval_secs: Option<u64>,
121    /// The optional heartbeat payload sent as a text frame.
122    ///
123    /// When `None`, the heartbeat is an empty Ping control frame instead. A venue that counts only
124    /// an application-level keepalive needs the text form; the two are not interchangeable.
125    #[serde(default)]
126    pub heartbeat_payload: Option<String>,
127    /// The timeout (milliseconds) for establishing a usable connection. Defaults to 10 seconds.
128    ///
129    /// Bounds three things: the initial connection attempt, each reconnect attempt, and how long a
130    /// send waits for the client to become active again. A short value therefore makes sends give
131    /// up early during a reconnect as well as failing a connection attempt faster; keep it above
132    /// the reconnect backoff.
133    ///
134    /// Only applies to handler mode and must be non-zero when set. Stream mode ignores this field
135    /// and bounds its connection attempt at 10 seconds.
136    #[serde(default)]
137    pub connect_timeout_ms: Option<u64>,
138    /// The initial reconnection delay (milliseconds) for reconnects.
139    ///
140    /// Only applies to handler mode. Stream mode ignores this field.
141    #[serde(default)]
142    pub reconnect_delay_initial_ms: Option<u64>,
143    /// The maximum reconnect delay (milliseconds) for exponential backoff.
144    ///
145    /// Only applies to handler mode. Stream mode ignores this field.
146    #[serde(default)]
147    pub reconnect_delay_max_ms: Option<u64>,
148    /// The exponential backoff factor for reconnection delays.
149    ///
150    /// Only applies to handler mode. Stream mode ignores this field.
151    #[serde(default)]
152    pub reconnect_backoff_factor: Option<f64>,
153    /// The maximum jitter (milliseconds) added to reconnection delays.
154    ///
155    /// Only applies to handler mode. Stream mode ignores this field.
156    #[serde(default)]
157    pub reconnect_jitter_ms: Option<u64>,
158    /// The maximum number of reconnection attempts before giving up.
159    ///
160    /// Only applies to handler mode. Stream mode ignores this field.
161    ///
162    /// - `None`: Unlimited reconnection attempts (default, recommended for production).
163    /// - `Some(n)`: Transitions to CLOSED once `n` consecutive reconnect attempts have either
164    ///   failed or established connections active for less than 10 seconds.
165    #[serde(default)]
166    pub reconnect_max_attempts: Option<u32>,
167    /// The dead-peer timeout (seconds) for the read task.
168    ///
169    /// Seconds rather than milliseconds because this is a multiple of
170    /// [`Self::heartbeat_interval_secs`]: it can never sensibly sit below one heartbeat cycle.
171    ///
172    /// When set, the read task stops and triggers reconnection if no inbound frame of any kind
173    /// arrives within this duration. Ping and Pong both refresh it, so this detects a peer that has
174    /// gone silent rather than one whose feed is merely quiet. Set it above
175    /// [`Self::heartbeat_interval_secs`] so a healthy connection cannot trip it; three intervals is
176    /// the usual choice, tolerating two lost replies.
177    ///
178    /// `None` derives three heartbeat intervals when a heartbeat is configured, and disables
179    /// detection otherwise. `Some(0)` is rejected.
180    ///
181    /// Only applies to handler mode; stream mode ignores this field.
182    #[serde(default)]
183    pub heartbeat_timeout_secs: Option<u64>,
184    /// The idle timeout (milliseconds) for the read task.
185    ///
186    /// When set, the read task stops and triggers reconnection if no Text or Binary frame arrives
187    /// within this duration. Ping and Pong deliberately do not refresh it, so this detects a feed
188    /// that has stopped flowing even while the transport is provably alive. Contrast
189    /// [`Self::heartbeat_timeout_secs`], which any inbound frame refreshes.
190    ///
191    /// `None` disables this timeout. `Some(0)` is rejected. Adapters that expose a required integer
192    /// map `0` to `None` rather than passing it through.
193    ///
194    /// The raw-socket client has no equivalent: TCP carries no control frames, so there is no
195    /// transport-level way to tell keepalive traffic from data.
196    ///
197    /// A venue answering the keepalive with a text payload refreshes this timer exactly like real
198    /// data does, so on those venues the window must sit below
199    /// [`Self::heartbeat_interval_secs`] to mean anything. Prefer
200    /// [`Self::heartbeat_timeout_secs`] unless the venue guarantees periodic inbound data.
201    ///
202    /// Only applies to handler mode; stream mode ignores this field.
203    #[serde(default)]
204    pub idle_timeout_ms: Option<u64>,
205    /// The transport backend to use for the WebSocket connection.
206    ///
207    /// Defaults to [`TransportBackend::Sockudo`] when the `transport-sockudo`
208    /// Cargo feature is enabled (the default), otherwise [`TransportBackend::Tungstenite`].
209    /// When the feature is disabled, `connect_with_server` returns an error if
210    /// `Sockudo` is selected. Both backends pass `headers` into the HTTP
211    /// upgrade request and both honour [`Self::proxy_url`].
212    #[serde(default)]
213    #[builder(default)]
214    pub backend: TransportBackend,
215    /// Optional forward proxy URL for the WebSocket connection.
216    ///
217    /// Routes the connection through an HTTP `CONNECT` tunnel. Accepts
218    /// `http://` and `https://` schemes; SOCKS schemes are not yet supported.
219    #[serde(default)]
220    pub proxy_url: Option<String>,
221}
222
223impl Debug for WebSocketConfig {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct(stringify!(WebSocketConfig))
226            .field("url", &REDACTED)
227            .field(
228                "headers",
229                &format_args!("<{} header(s)>", self.headers.len()),
230            )
231            .field("heartbeat_interval_secs", &self.heartbeat_interval_secs)
232            .field("heartbeat_payload", &self.heartbeat_payload)
233            .field("connect_timeout_ms", &self.connect_timeout_ms)
234            .field(
235                "reconnect_delay_initial_ms",
236                &self.reconnect_delay_initial_ms,
237            )
238            .field("reconnect_delay_max_ms", &self.reconnect_delay_max_ms)
239            .field("reconnect_backoff_factor", &self.reconnect_backoff_factor)
240            .field("reconnect_jitter_ms", &self.reconnect_jitter_ms)
241            .field("reconnect_max_attempts", &self.reconnect_max_attempts)
242            .field("heartbeat_timeout_secs", &self.heartbeat_timeout_secs)
243            .field("idle_timeout_ms", &self.idle_timeout_ms)
244            .field("backend", &self.backend)
245            .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
246            .finish()
247    }
248}
249
250impl<S: web_socket_config_builder::IsComplete> WebSocketConfigBuilder<S> {
251    /// Validates and builds the [`WebSocketConfig`].
252    ///
253    /// # Errors
254    ///
255    /// Returns a [`NetworkConfigError`] if any field fails validation
256    /// (see [`WebSocketConfig::validate`]).
257    pub fn build(self) -> NetworkConfigResult<WebSocketConfig> {
258        let config = self.build_inner();
259        config.validate()?;
260        Ok(config)
261    }
262}
263
264impl WebSocketConfig {
265    /// Checks whether all WebSocket settings are valid.
266    ///
267    /// # Errors
268    ///
269    /// Returns a [`NetworkConfigError`] if `url` is empty, the heartbeat interval or a
270    /// reconnection timing field is not positive, `reconnect_backoff_factor` is outside
271    /// `[1.0, 100.0]`, or `reconnect_delay_initial_ms` exceeds `reconnect_delay_max_ms`.
272    pub fn validate(&self) -> NetworkConfigResult<()> {
273        let mut errors = Vec::new();
274
275        if self.url.trim().is_empty() {
276            errors.push(NetworkConfigError::invalid("url", "must not be empty"));
277        }
278
279        if let Some(interval) = self.heartbeat_interval_secs
280            && interval == 0
281        {
282            errors.push(NetworkConfigError::invalid(
283                "heartbeat_interval_secs",
284                "interval must be positive",
285            ));
286        }
287
288        // A timeout at or below the send cadence tears every connection down before its first
289        // reply is due, so a healthy socket would reconnect forever.
290        if let (Some(interval_secs), Some(timeout_secs)) =
291            (self.heartbeat_interval_secs, self.heartbeat_timeout_secs)
292            && timeout_secs <= interval_secs
293        {
294            errors.push(NetworkConfigError::invalid(
295                "heartbeat_timeout_secs",
296                format!(
297                    "must exceed heartbeat_interval_secs ({interval_secs}s), was {timeout_secs}s"
298                ),
299            ));
300        }
301
302        // `reconnect_jitter_ms` is intentionally unchecked: zero disables jitter and
303        // `ExponentialBackoff::new` accepts it.
304        for (field, value) in [
305            ("connect_timeout_ms", self.connect_timeout_ms),
306            (
307                "reconnect_delay_initial_ms",
308                self.reconnect_delay_initial_ms,
309            ),
310            ("reconnect_delay_max_ms", self.reconnect_delay_max_ms),
311            ("heartbeat_timeout_secs", self.heartbeat_timeout_secs),
312            ("idle_timeout_ms", self.idle_timeout_ms),
313        ] {
314            if let Some(value) = value
315                && value == 0
316            {
317                errors.push(NetworkConfigError::invalid(
318                    field,
319                    format!("must be positive, was {value}"),
320                ));
321            }
322        }
323
324        if let Some(factor) = self.reconnect_backoff_factor
325            && !(1.0..=100.0).contains(&factor)
326        {
327            errors.push(NetworkConfigError::invalid(
328                "reconnect_backoff_factor",
329                format!("must be in range [1.0, 100.0], was {factor}"),
330            ));
331        }
332
333        if let (Some(initial), Some(max)) =
334            (self.reconnect_delay_initial_ms, self.reconnect_delay_max_ms)
335            && initial > max
336        {
337            errors.push(NetworkConfigError::invalid(
338                "reconnect_delay_initial_ms",
339                format!("must not exceed reconnect_delay_max_ms ({max}), was {initial}"),
340            ));
341        }
342
343        NetworkConfigError::collect(errors)
344    }
345
346    pub(crate) fn resolved_heartbeat_timeout(&self) -> Option<u64> {
347        crate::heartbeat::resolve_heartbeat_timeout(
348            self.heartbeat_timeout_secs,
349            self.heartbeat_interval_secs,
350        )
351    }
352}
353
354/// Retry policy for establishing the initial handler-mode connection.
355///
356/// Supplied to the client builder rather than held in [`WebSocketConfig`], because it governs a
357/// single invocation of `connect` and has no meaning once a client exists. Without a policy the
358/// builder makes exactly one attempt.
359///
360/// This does not affect automatic reconnection after a connection has been established; that is
361/// configured by the `reconnect_*` fields of [`WebSocketConfig`].
362///
363/// An attempt is retried only after `ConnectionClosed`, `ConnectionReset`, or `ClosedByPeer`; an
364/// I/O error of any kind except `InvalidInput`, `InvalidData`, `Unsupported`, and
365/// `PermissionDenied`; or an HTTP upgrade or proxy `CONNECT` rejection carrying status 408, 425,
366/// 429, or 500 through 599. Every other transport error and rejection status returns immediately
367/// without waiting for a backoff delay, however many attempts remain.
368///
369/// The classification is by error variant, not by cause, and the backends do not map causes to
370/// variants uniformly - a TLS failure is permanent as `Tls` but follows the I/O rule where a
371/// backend reports it as `Io`. A permanent failure on the first attempt is indistinguishable from
372/// an exhausted ladder by the returned error alone.
373#[derive(Clone, Debug)]
374pub struct InitialConnectRetryPolicy {
375    /// Maximum number of connection attempts, including the first attempt.
376    ///
377    /// This is an upper bound rather than a promise: a failure classified as permanent returns
378    /// before the bound is reached.
379    pub max_attempts: NonZeroU32,
380    /// Delay before the second connection attempt.
381    pub delay_initial: Duration,
382    /// Maximum delay between connection attempts.
383    pub delay_max: Duration,
384    /// Multiplier applied to the delay after each failed attempt.
385    pub backoff_factor: f64,
386    /// Maximum random jitter added to each delay, in milliseconds.
387    pub jitter_ms: u64,
388}
389
390#[cfg(test)]
391mod tests {
392    use rstest::rstest;
393    use serde_json::json;
394
395    use super::WebSocketConfig;
396    use crate::error::NetworkConfigError;
397
398    #[rstest]
399    fn test_deserialize_websocket_config_rejects_unknown_field() {
400        let config = json!({
401            "url": "wss://example.com/ws",
402            "unexpected": true,
403        });
404
405        let error = serde_json::from_value::<WebSocketConfig>(config).unwrap_err();
406
407        assert!(error.to_string().contains("unknown field `unexpected`"));
408    }
409
410    fn valid_config() -> WebSocketConfig {
411        WebSocketConfig::builder()
412            .url("wss://example.com/ws".to_string())
413            .build()
414            .expect("baseline websocket config should be valid")
415    }
416
417    #[rstest]
418    fn test_builder_accepts_valid_config() {
419        let result = WebSocketConfig::builder()
420            .url("wss://example.com/ws".to_string())
421            .build();
422
423        assert!(result.is_ok());
424    }
425
426    #[rstest]
427    fn test_validate_accepts_zero_jitter() {
428        let mut config = valid_config();
429        config.reconnect_jitter_ms = Some(0);
430
431        assert!(config.validate().is_ok());
432    }
433
434    #[rstest]
435    #[case::empty_url(|c: &mut WebSocketConfig| c.url = String::new(), "url")]
436    #[case::heartbeat_interval(|c: &mut WebSocketConfig| c.heartbeat_interval_secs = Some(0), "heartbeat_interval_secs")]
437    #[case::heartbeat_timeout_below_interval(|c: &mut WebSocketConfig| { c.heartbeat_interval_secs = Some(30); c.heartbeat_timeout_secs = Some(30); }, "heartbeat_timeout_secs")]
438    #[case::connect_timeout(|c: &mut WebSocketConfig| c.connect_timeout_ms = Some(0), "connect_timeout_ms")]
439    #[case::reconnect_delay_initial(|c: &mut WebSocketConfig| c.reconnect_delay_initial_ms = Some(0), "reconnect_delay_initial_ms")]
440    #[case::reconnect_delay_max(|c: &mut WebSocketConfig| c.reconnect_delay_max_ms = Some(0), "reconnect_delay_max_ms")]
441    #[case::heartbeat_timeout_zero(|c: &mut WebSocketConfig| c.heartbeat_timeout_secs = Some(0), "heartbeat_timeout_secs")]
442    #[case::idle_timeout(|c: &mut WebSocketConfig| c.idle_timeout_ms = Some(0), "idle_timeout_ms")]
443    fn test_validate_rejects_invalid_field(
444        #[case] mutate: fn(&mut WebSocketConfig),
445        #[case] expected_field: &str,
446    ) {
447        let mut config = valid_config();
448        mutate(&mut config);
449
450        let err = config
451            .validate()
452            .expect_err("invalid value should be rejected");
453
454        assert!(
455            matches!(err, NetworkConfigError::Invalid { field, .. } if field == expected_field)
456        );
457    }
458
459    #[rstest]
460    #[case::too_small(0.5)]
461    #[case::too_large(100.1)]
462    #[case::nan(f64::NAN)]
463    #[case::infinite(f64::INFINITY)]
464    fn test_validate_rejects_invalid_backoff_factor(#[case] factor: f64) {
465        let mut config = valid_config();
466        config.reconnect_backoff_factor = Some(factor);
467
468        let err = config
469            .validate()
470            .expect_err("invalid backoff factor should be rejected");
471
472        assert!(
473            matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_backoff_factor")
474        );
475    }
476
477    #[rstest]
478    fn test_validate_rejects_delay_initial_exceeding_max() {
479        let mut config = valid_config();
480        config.reconnect_delay_initial_ms = Some(5_000);
481        config.reconnect_delay_max_ms = Some(1_000);
482
483        let err = config
484            .validate()
485            .expect_err("initial delay above max should be rejected");
486
487        assert!(
488            matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_delay_initial_ms")
489        );
490    }
491
492    #[rstest]
493    fn test_validate_collects_multiple_errors() {
494        let mut config = valid_config();
495        config.url = String::new();
496        config.connect_timeout_ms = Some(0);
497
498        let err = config.validate().expect_err("multiple invalid fields");
499
500        match err {
501            NetworkConfigError::Multiple { errors } => assert_eq!(errors.len(), 2),
502            other @ NetworkConfigError::Invalid { .. } => {
503                panic!("expected Multiple, was {other:?}")
504            }
505        }
506    }
507
508    #[rstest]
509    #[case::derived(Some(30), None, Some(90))]
510    #[case::explicit_wins(Some(30), Some(45), Some(45))]
511    fn test_resolve_timeout_from_websocket_heartbeat(
512        #[case] interval_secs: Option<u64>,
513        #[case] timeout_secs: Option<u64>,
514        #[case] expected: Option<u64>,
515    ) {
516        let mut config = valid_config();
517        config.heartbeat_interval_secs = interval_secs;
518        config.heartbeat_timeout_secs = timeout_secs;
519
520        assert_eq!(config.resolved_heartbeat_timeout(), expected);
521    }
522
523    #[rstest]
524    fn test_debug_redacts_endpoint_and_proxy_credentials() {
525        const ENDPOINT_PATH_SECRET: &str = "unique-endpoint-path-secret";
526        const ENDPOINT_QUERY_SECRET: &str = "unique-endpoint-query-secret";
527        const PROXY_SECRET: &str = "unique-proxy-secret";
528        let mut config = valid_config();
529        config.url =
530            format!("wss://rpc.example.com/{ENDPOINT_PATH_SECRET}?api_key={ENDPOINT_QUERY_SECRET}");
531        config.proxy_url = Some(format!(
532            "http://proxytest:{PROXY_SECRET}@proxy.example.com:8080"
533        ));
534
535        let debug = format!("{config:?}");
536
537        assert!(debug.contains("url: \"<redacted>\""));
538        assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
539        assert!(!debug.contains(ENDPOINT_PATH_SECRET));
540        assert!(!debug.contains(ENDPOINT_QUERY_SECRET));
541        assert!(!debug.contains(PROXY_SECRET));
542    }
543}