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}