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, 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}