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 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 to the client constructors.
80///
81/// # Connection modes
82///
83/// ## Handler mode
84///
85/// - Uses [`WebSocketClient::connect`](crate::websocket::WebSocketClient::connect).
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::connect_stream`](crate::websocket::WebSocketClient::connect_stream).
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#[cfg(test)]
355mod tests {
356 use rstest::rstest;
357 use serde_json::json;
358
359 use super::WebSocketConfig;
360 use crate::error::NetworkConfigError;
361
362 #[rstest]
363 fn test_deserialize_websocket_config_rejects_unknown_field() {
364 let config = json!({
365 "url": "wss://example.com/ws",
366 "unexpected": true,
367 });
368
369 let error = serde_json::from_value::<WebSocketConfig>(config).unwrap_err();
370
371 assert!(error.to_string().contains("unknown field `unexpected`"));
372 }
373
374 fn valid_config() -> WebSocketConfig {
375 WebSocketConfig::builder()
376 .url("wss://example.com/ws".to_string())
377 .build()
378 .expect("baseline websocket config should be valid")
379 }
380
381 #[rstest]
382 fn test_builder_accepts_valid_config() {
383 let result = WebSocketConfig::builder()
384 .url("wss://example.com/ws".to_string())
385 .build();
386
387 assert!(result.is_ok());
388 }
389
390 #[rstest]
391 fn test_validate_accepts_zero_jitter() {
392 let mut config = valid_config();
393 config.reconnect_jitter_ms = Some(0);
394
395 assert!(config.validate().is_ok());
396 }
397
398 #[rstest]
399 #[case::empty_url(|c: &mut WebSocketConfig| c.url = String::new(), "url")]
400 #[case::heartbeat_interval(|c: &mut WebSocketConfig| c.heartbeat_interval_secs = Some(0), "heartbeat_interval_secs")]
401 #[case::heartbeat_timeout_below_interval(|c: &mut WebSocketConfig| { c.heartbeat_interval_secs = Some(30); c.heartbeat_timeout_secs = Some(30); }, "heartbeat_timeout_secs")]
402 #[case::connect_timeout(|c: &mut WebSocketConfig| c.connect_timeout_ms = Some(0), "connect_timeout_ms")]
403 #[case::reconnect_delay_initial(|c: &mut WebSocketConfig| c.reconnect_delay_initial_ms = Some(0), "reconnect_delay_initial_ms")]
404 #[case::reconnect_delay_max(|c: &mut WebSocketConfig| c.reconnect_delay_max_ms = Some(0), "reconnect_delay_max_ms")]
405 #[case::heartbeat_timeout_zero(|c: &mut WebSocketConfig| c.heartbeat_timeout_secs = Some(0), "heartbeat_timeout_secs")]
406 #[case::idle_timeout(|c: &mut WebSocketConfig| c.idle_timeout_ms = Some(0), "idle_timeout_ms")]
407 fn test_validate_rejects_invalid_field(
408 #[case] mutate: fn(&mut WebSocketConfig),
409 #[case] expected_field: &str,
410 ) {
411 let mut config = valid_config();
412 mutate(&mut config);
413
414 let err = config
415 .validate()
416 .expect_err("invalid value should be rejected");
417
418 assert!(
419 matches!(err, NetworkConfigError::Invalid { field, .. } if field == expected_field)
420 );
421 }
422
423 #[rstest]
424 #[case::too_small(0.5)]
425 #[case::too_large(100.1)]
426 #[case::nan(f64::NAN)]
427 #[case::infinite(f64::INFINITY)]
428 fn test_validate_rejects_invalid_backoff_factor(#[case] factor: f64) {
429 let mut config = valid_config();
430 config.reconnect_backoff_factor = Some(factor);
431
432 let err = config
433 .validate()
434 .expect_err("invalid backoff factor should be rejected");
435
436 assert!(
437 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_backoff_factor")
438 );
439 }
440
441 #[rstest]
442 fn test_validate_rejects_delay_initial_exceeding_max() {
443 let mut config = valid_config();
444 config.reconnect_delay_initial_ms = Some(5_000);
445 config.reconnect_delay_max_ms = Some(1_000);
446
447 let err = config
448 .validate()
449 .expect_err("initial delay above max should be rejected");
450
451 assert!(
452 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_delay_initial_ms")
453 );
454 }
455
456 #[rstest]
457 fn test_validate_collects_multiple_errors() {
458 let mut config = valid_config();
459 config.url = String::new();
460 config.connect_timeout_ms = Some(0);
461
462 let err = config.validate().expect_err("multiple invalid fields");
463
464 match err {
465 NetworkConfigError::Multiple { errors } => assert_eq!(errors.len(), 2),
466 other @ NetworkConfigError::Invalid { .. } => {
467 panic!("expected Multiple, was {other:?}")
468 }
469 }
470 }
471
472 #[rstest]
473 #[case::derived(Some(30), None, Some(90))]
474 #[case::explicit_wins(Some(30), Some(45), Some(45))]
475 fn test_resolve_timeout_from_websocket_heartbeat(
476 #[case] interval_secs: Option<u64>,
477 #[case] timeout_secs: Option<u64>,
478 #[case] expected: Option<u64>,
479 ) {
480 let mut config = valid_config();
481 config.heartbeat_interval_secs = interval_secs;
482 config.heartbeat_timeout_secs = timeout_secs;
483
484 assert_eq!(config.resolved_heartbeat_timeout(), expected);
485 }
486
487 #[rstest]
488 fn test_debug_redacts_endpoint_and_proxy_credentials() {
489 const ENDPOINT_PATH_SECRET: &str = "unique-endpoint-path-secret";
490 const ENDPOINT_QUERY_SECRET: &str = "unique-endpoint-query-secret";
491 const PROXY_SECRET: &str = "unique-proxy-secret";
492 let mut config = valid_config();
493 config.url =
494 format!("wss://rpc.example.com/{ENDPOINT_PATH_SECRET}?api_key={ENDPOINT_QUERY_SECRET}");
495 config.proxy_url = Some(format!(
496 "http://proxytest:{PROXY_SECRET}@proxy.example.com:8080"
497 ));
498
499 let debug = format!("{config:?}");
500
501 assert!(debug.contains("url: \"<redacted>\""));
502 assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
503 assert!(!debug.contains(ENDPOINT_PATH_SECRET));
504 assert!(!debug.contains(ENDPOINT_QUERY_SECRET));
505 assert!(!debug.contains(PROXY_SECRET));
506 }
507}