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, inbound size limits, transport backend, and optional proxy. Runtime handlers
20//! and rate limiting are 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 maximum number of outstanding ordinary messages, including queued, in-flight, and replay messages.
206 ///
207 /// Ownership-bound sends, keepalives, and control frames share a separate allowance of the same size,
208 /// so authentication can proceed when replay fills the ordinary allowance.
209 ///
210 /// Defaults to 1,024. Sends fail immediately when full. Must be positive and no greater
211 /// than [`tokio::sync::Semaphore::MAX_PERMITS`]. This bounds message count, not payload bytes.
212 #[serde(default)]
213 pub writer_capacity: Option<usize>,
214 /// The transport backend to use for the WebSocket connection.
215 ///
216 /// Defaults to [`TransportBackend::Sockudo`] when the `transport-sockudo`
217 /// Cargo feature is enabled (the default), otherwise [`TransportBackend::Tungstenite`].
218 /// When the feature is disabled, `connect_with_server` returns an error if
219 /// `Sockudo` is selected. Both backends pass `headers` into the HTTP
220 /// upgrade request and both honor [`Self::proxy_url`].
221 #[serde(default)]
222 #[builder(default)]
223 pub backend: TransportBackend,
224 /// Optional forward proxy URL for the WebSocket connection.
225 ///
226 /// Routes the connection through an HTTP `CONNECT` tunnel. Accepts
227 /// `http://` and `https://` schemes; SOCKS schemes are not yet supported.
228 #[serde(default)]
229 pub proxy_url: Option<String>,
230 /// Maximum size in bytes of one inbound WebSocket message.
231 ///
232 /// `None` passes the selected backend's default message cap of 64 MiB. Sockudo applies that
233 /// default only while reassembling fragments; a finished single-frame message is bounded by the
234 /// frame cap unless this field is set. `Some(0)` is rejected. A message that exceeds this cap
235 /// and fits in the frame cap fails the read with `MessageTooLarge`. This cap does not bound
236 /// per-frame memory: both backends apply it after the frame payload is read. Set
237 /// `max_frame_size_bytes` for that bound.
238 #[serde(default)]
239 pub max_message_size_bytes: Option<usize>,
240 /// Maximum size in bytes of one inbound WebSocket frame payload, excluding the frame header.
241 ///
242 /// `None` keeps the selected backend's default of 16 MiB. `Some(0)` is rejected. Sockudo reports
243 /// a frame larger than 125 bytes that exceeds the cap as `FrameTooLarge`. Its small-frame parser
244 /// skips the cap when the whole frame is already buffered, so a shorter frame is not reliably
245 /// rejected. Tungstenite uses one capacity error for both limits, so every frame breach is
246 /// `MessageTooLarge`. On Sockudo, a frame that exceeds this cap fails before the message cap.
247 #[serde(default)]
248 pub max_frame_size_bytes: Option<usize>,
249}
250
251impl Debug for WebSocketConfig {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 f.debug_struct(stringify!(WebSocketConfig))
254 .field("url", &REDACTED)
255 .field(
256 "headers",
257 &format_args!("<{} header(s)>", self.headers.len()),
258 )
259 .field("heartbeat_interval_secs", &self.heartbeat_interval_secs)
260 .field("heartbeat_payload", &self.heartbeat_payload)
261 .field("writer_capacity", &self.writer_capacity)
262 .field("connect_timeout_ms", &self.connect_timeout_ms)
263 .field(
264 "reconnect_delay_initial_ms",
265 &self.reconnect_delay_initial_ms,
266 )
267 .field("reconnect_delay_max_ms", &self.reconnect_delay_max_ms)
268 .field("reconnect_backoff_factor", &self.reconnect_backoff_factor)
269 .field("reconnect_jitter_ms", &self.reconnect_jitter_ms)
270 .field("reconnect_max_attempts", &self.reconnect_max_attempts)
271 .field("heartbeat_timeout_secs", &self.heartbeat_timeout_secs)
272 .field("idle_timeout_ms", &self.idle_timeout_ms)
273 .field("backend", &self.backend)
274 .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
275 .field("max_message_size_bytes", &self.max_message_size_bytes)
276 .field("max_frame_size_bytes", &self.max_frame_size_bytes)
277 .finish()
278 }
279}
280
281impl<S: web_socket_config_builder::IsComplete> WebSocketConfigBuilder<S> {
282 /// Validates and builds the [`WebSocketConfig`].
283 ///
284 /// # Errors
285 ///
286 /// Returns an error if any field fails validation (see [`WebSocketConfig::validate`]).
287 pub fn build(self) -> NetworkConfigResult<WebSocketConfig> {
288 let config = self.build_inner();
289 config.validate()?;
290 Ok(config)
291 }
292}
293
294impl WebSocketConfig {
295 /// Checks whether all WebSocket settings are valid.
296 ///
297 /// # Errors
298 ///
299 /// Returns an error if `url` is empty, the heartbeat interval, a reconnection timing field, or
300 /// an inbound size limit is not positive, `reconnect_backoff_factor` is outside `[1.0, 100.0]`,
301 /// `reconnect_delay_initial_ms` exceeds `reconnect_delay_max_ms`, or `writer_capacity` is
302 /// outside `[1, Semaphore::MAX_PERMITS]`.
303 pub fn validate(&self) -> NetworkConfigResult<()> {
304 let mut errors = Vec::new();
305
306 if let Err(e) = crate::writer::validate_capacity(self.writer_capacity) {
307 errors.push(e);
308 }
309
310 if self.url.trim().is_empty() {
311 errors.push(NetworkConfigError::invalid("url", "must not be empty"));
312 }
313
314 if let Some(interval) = self.heartbeat_interval_secs
315 && interval == 0
316 {
317 errors.push(NetworkConfigError::invalid(
318 "heartbeat_interval_secs",
319 "interval must be positive",
320 ));
321 }
322
323 // A timeout at or below the send cadence tears every connection down before its first
324 // reply is due, so a healthy socket would reconnect forever.
325 if let (Some(interval_secs), Some(timeout_secs)) =
326 (self.heartbeat_interval_secs, self.heartbeat_timeout_secs)
327 && timeout_secs <= interval_secs
328 {
329 errors.push(NetworkConfigError::invalid(
330 "heartbeat_timeout_secs",
331 format!(
332 "must exceed heartbeat_interval_secs ({interval_secs}s), was {timeout_secs}s"
333 ),
334 ));
335 }
336
337 // `reconnect_jitter_ms` is intentionally unchecked: zero disables jitter and
338 // `ExponentialBackoff::new` accepts it.
339 for (field, value) in [
340 ("connect_timeout_ms", self.connect_timeout_ms),
341 (
342 "reconnect_delay_initial_ms",
343 self.reconnect_delay_initial_ms,
344 ),
345 ("reconnect_delay_max_ms", self.reconnect_delay_max_ms),
346 ("heartbeat_timeout_secs", self.heartbeat_timeout_secs),
347 ("idle_timeout_ms", self.idle_timeout_ms),
348 ] {
349 if let Some(value) = value
350 && value == 0
351 {
352 errors.push(NetworkConfigError::invalid(
353 field,
354 format!("must be positive, was {value}"),
355 ));
356 }
357 }
358
359 for (field, value) in [
360 ("max_message_size_bytes", self.max_message_size_bytes),
361 ("max_frame_size_bytes", self.max_frame_size_bytes),
362 ] {
363 if let Some(value) = value
364 && value == 0
365 {
366 errors.push(NetworkConfigError::invalid(
367 field,
368 format!("must be positive, was {value}"),
369 ));
370 }
371 }
372
373 if let Some(factor) = self.reconnect_backoff_factor
374 && !(1.0..=100.0).contains(&factor)
375 {
376 errors.push(NetworkConfigError::invalid(
377 "reconnect_backoff_factor",
378 format!("must be in range [1.0, 100.0], was {factor}"),
379 ));
380 }
381
382 if let (Some(initial), Some(max)) =
383 (self.reconnect_delay_initial_ms, self.reconnect_delay_max_ms)
384 && initial > max
385 {
386 errors.push(NetworkConfigError::invalid(
387 "reconnect_delay_initial_ms",
388 format!("must not exceed reconnect_delay_max_ms ({max}), was {initial}"),
389 ));
390 }
391
392 NetworkConfigError::collect(errors)
393 }
394
395 pub(crate) fn resolved_heartbeat_timeout(&self) -> Option<u64> {
396 crate::heartbeat::resolve_heartbeat_timeout(
397 self.heartbeat_timeout_secs,
398 self.heartbeat_interval_secs,
399 )
400 }
401}
402
403/// Retry policy for establishing the initial handler-mode connection.
404///
405/// Supplied to the client builder rather than held in [`WebSocketConfig`], because it governs a
406/// single invocation of `connect` and has no meaning once a client exists. Without a policy the
407/// builder makes exactly one attempt.
408///
409/// This does not affect automatic reconnection after a connection has been established; that is
410/// configured by the `reconnect_*` fields of [`WebSocketConfig`].
411///
412/// An attempt is retried only after `ConnectionClosed`, `ConnectionReset`, or `ClosedByPeer`; an
413/// I/O error of any kind except `InvalidInput`, `InvalidData`, `Unsupported`, and
414/// `PermissionDenied`; or an HTTP upgrade or proxy `CONNECT` rejection carrying status 408, 425,
415/// 429, or 500 through 599. Every other transport error and rejection status returns immediately
416/// without waiting for a backoff delay, however many attempts remain.
417///
418/// The classification is by error variant, not by cause, and the backends do not map causes to
419/// variants uniformly - a TLS failure is permanent as `Tls` but follows the I/O rule where a
420/// backend reports it as `Io`. A permanent failure on the first attempt is indistinguishable from
421/// an exhausted ladder by the returned error alone.
422#[derive(Clone, Debug)]
423pub struct InitialConnectRetryPolicy {
424 /// Maximum number of connection attempts, including the first attempt.
425 ///
426 /// This is an upper bound rather than a promise: a failure classified as permanent returns
427 /// before the bound is reached.
428 pub max_attempts: NonZeroU32,
429 /// Delay before the second connection attempt.
430 pub delay_initial: Duration,
431 /// Maximum delay between connection attempts.
432 pub delay_max: Duration,
433 /// Multiplier applied to the delay after each failed attempt.
434 pub backoff_factor: f64,
435 /// Maximum random jitter added to each delay, in milliseconds.
436 pub jitter_ms: u64,
437}
438
439#[cfg(test)]
440mod tests {
441 use rstest::rstest;
442 use serde_json::json;
443
444 use super::WebSocketConfig;
445 use crate::error::NetworkConfigError;
446
447 #[rstest]
448 fn test_deserialize_websocket_config_rejects_unknown_field() {
449 let config = json!({
450 "url": "wss://example.com/ws",
451 "unexpected": true,
452 });
453
454 let error = serde_json::from_value::<WebSocketConfig>(config).unwrap_err();
455
456 assert!(error.to_string().contains("unknown field `unexpected`"));
457 }
458
459 fn valid_config() -> WebSocketConfig {
460 WebSocketConfig::builder()
461 .url("wss://example.com/ws".to_string())
462 .build()
463 .expect("baseline websocket config should be valid")
464 }
465
466 #[rstest]
467 fn test_builder_accepts_valid_config() {
468 let result = WebSocketConfig::builder()
469 .url("wss://example.com/ws".to_string())
470 .build();
471
472 assert!(result.is_ok());
473 }
474
475 #[rstest]
476 fn test_deserialize_websocket_config_defaults_inbound_size_limits() {
477 let config = serde_json::from_value::<WebSocketConfig>(json!({
478 "url": "wss://example.com/ws",
479 }))
480 .unwrap();
481
482 assert_eq!(config.max_message_size_bytes, None);
483 assert_eq!(config.max_frame_size_bytes, None);
484 }
485
486 #[rstest]
487 fn test_validate_accepts_positive_inbound_size_limits() {
488 let mut config = valid_config();
489 config.max_message_size_bytes = Some(1);
490 config.max_frame_size_bytes = Some(1);
491
492 assert!(config.validate().is_ok());
493 }
494
495 #[rstest]
496 fn test_validate_accepts_zero_jitter() {
497 let mut config = valid_config();
498 config.reconnect_jitter_ms = Some(0);
499
500 assert!(config.validate().is_ok());
501 }
502
503 #[rstest]
504 #[case::writer_capacity_zero(|c: &mut WebSocketConfig| c.writer_capacity = Some(0), "writer_capacity")]
505 #[case::writer_capacity_excess(|c: &mut WebSocketConfig| c.writer_capacity = Some(usize::MAX), "writer_capacity")]
506 #[case::empty_url(|c: &mut WebSocketConfig| c.url = String::new(), "url")]
507 #[case::heartbeat_interval(|c: &mut WebSocketConfig| c.heartbeat_interval_secs = Some(0), "heartbeat_interval_secs")]
508 #[case::heartbeat_timeout_below_interval(|c: &mut WebSocketConfig| { c.heartbeat_interval_secs = Some(30); c.heartbeat_timeout_secs = Some(30); }, "heartbeat_timeout_secs")]
509 #[case::connect_timeout(|c: &mut WebSocketConfig| c.connect_timeout_ms = Some(0), "connect_timeout_ms")]
510 #[case::reconnect_delay_initial(|c: &mut WebSocketConfig| c.reconnect_delay_initial_ms = Some(0), "reconnect_delay_initial_ms")]
511 #[case::reconnect_delay_max(|c: &mut WebSocketConfig| c.reconnect_delay_max_ms = Some(0), "reconnect_delay_max_ms")]
512 #[case::heartbeat_timeout_zero(|c: &mut WebSocketConfig| c.heartbeat_timeout_secs = Some(0), "heartbeat_timeout_secs")]
513 #[case::idle_timeout(|c: &mut WebSocketConfig| c.idle_timeout_ms = Some(0), "idle_timeout_ms")]
514 #[case::message_size(|c: &mut WebSocketConfig| c.max_message_size_bytes = Some(0), "max_message_size_bytes")]
515 #[case::frame_size(|c: &mut WebSocketConfig| c.max_frame_size_bytes = Some(0), "max_frame_size_bytes")]
516 fn test_validate_rejects_invalid_field(
517 #[case] mutate: fn(&mut WebSocketConfig),
518 #[case] expected_field: &str,
519 ) {
520 let mut config = valid_config();
521 mutate(&mut config);
522
523 let err = config
524 .validate()
525 .expect_err("invalid value should be rejected");
526
527 assert!(
528 matches!(err, NetworkConfigError::Invalid { field, .. } if field == expected_field)
529 );
530 }
531
532 #[rstest]
533 #[case::too_small(0.5)]
534 #[case::too_large(100.1)]
535 #[case::nan(f64::NAN)]
536 #[case::infinite(f64::INFINITY)]
537 fn test_validate_rejects_invalid_backoff_factor(#[case] factor: f64) {
538 let mut config = valid_config();
539 config.reconnect_backoff_factor = Some(factor);
540
541 let err = config
542 .validate()
543 .expect_err("invalid backoff factor should be rejected");
544
545 assert!(
546 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_backoff_factor")
547 );
548 }
549
550 #[rstest]
551 fn test_validate_rejects_delay_initial_exceeding_max() {
552 let mut config = valid_config();
553 config.reconnect_delay_initial_ms = Some(5_000);
554 config.reconnect_delay_max_ms = Some(1_000);
555
556 let err = config
557 .validate()
558 .expect_err("initial delay above max should be rejected");
559
560 assert!(
561 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_delay_initial_ms")
562 );
563 }
564
565 #[rstest]
566 fn test_validate_collects_multiple_errors() {
567 let mut config = valid_config();
568 config.url = String::new();
569 config.connect_timeout_ms = Some(0);
570
571 let err = config.validate().expect_err("multiple invalid fields");
572
573 match err {
574 NetworkConfigError::Multiple { errors } => assert_eq!(errors.len(), 2),
575 other @ NetworkConfigError::Invalid { .. } => {
576 panic!("expected Multiple, was {other:?}")
577 }
578 }
579 }
580
581 #[rstest]
582 #[case::derived(Some(30), None, Some(90))]
583 #[case::explicit_wins(Some(30), Some(45), Some(45))]
584 fn test_resolve_timeout_from_websocket_heartbeat(
585 #[case] interval_secs: Option<u64>,
586 #[case] timeout_secs: Option<u64>,
587 #[case] expected: Option<u64>,
588 ) {
589 let mut config = valid_config();
590 config.heartbeat_interval_secs = interval_secs;
591 config.heartbeat_timeout_secs = timeout_secs;
592
593 assert_eq!(config.resolved_heartbeat_timeout(), expected);
594 }
595
596 #[rstest]
597 fn test_debug_redacts_endpoint_and_proxy_credentials() {
598 const ENDPOINT_PATH_SECRET: &str = "unique-endpoint-path-secret";
599 const ENDPOINT_QUERY_SECRET: &str = "unique-endpoint-query-secret";
600 const PROXY_SECRET: &str = "unique-proxy-secret";
601 let mut config = valid_config();
602 config.url =
603 format!("wss://rpc.example.com/{ENDPOINT_PATH_SECRET}?api_key={ENDPOINT_QUERY_SECRET}");
604 config.proxy_url = Some(format!(
605 "http://proxytest:{PROXY_SECRET}@proxy.example.com:8080"
606 ));
607
608 let debug = format!("{config:?}");
609
610 assert!(debug.contains("url: \"<redacted>\""));
611 assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
612 assert!(!debug.contains(ENDPOINT_PATH_SECRET));
613 assert!(!debug.contains(ENDPOINT_QUERY_SECRET));
614 assert!(!debug.contains(PROXY_SECRET));
615 }
616}