nautilus_network/websocket/
config.rs1use std::fmt::Debug;
32
33use nautilus_core::string::secret::REDACTED;
34use serde::{Deserialize, Serialize};
35
36use crate::error::{NetworkConfigError, NetworkConfigResult};
37
38#[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.core.nautilus_pyo3.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 = "PyO3-backed enum still needs serde deserialization for strict config decoding"
67)]
68pub enum TransportBackend {
69 #[cfg_attr(not(feature = "transport-sockudo"), default)]
71 Tungstenite,
72 #[cfg_attr(feature = "transport-sockudo", default)]
74 Sockudo,
75}
76
77#[cfg_attr(
101 feature = "python",
102 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network", from_py_object)
103)]
104#[cfg_attr(
105 feature = "python",
106 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.network")
107)]
108#[allow(
109 clippy::unsafe_derive_deserialize,
110 reason = "PyO3-backed config still needs serde deserialization for strict config decoding"
111)]
112#[derive(Clone, Serialize, Deserialize, bon::Builder)]
113#[builder(finish_fn(name = build_inner, vis = ""))]
114#[serde(deny_unknown_fields)]
115pub struct WebSocketConfig {
116 pub url: String,
118 #[serde(default)]
120 #[builder(default)]
121 pub headers: Vec<(String, String)>,
122 #[serde(default)]
124 pub heartbeat: Option<u64>,
125 #[serde(default)]
127 pub heartbeat_msg: Option<String>,
128 #[serde(default)]
132 pub reconnect_timeout_ms: Option<u64>,
133 #[serde(default)]
136 pub reconnect_delay_initial_ms: Option<u64>,
137 #[serde(default)]
140 pub reconnect_delay_max_ms: Option<u64>,
141 #[serde(default)]
144 pub reconnect_backoff_factor: Option<f64>,
145 #[serde(default)]
148 pub reconnect_jitter_ms: Option<u64>,
149 #[serde(default)]
155 pub reconnect_max_attempts: Option<u32>,
156 #[serde(default)]
162 pub idle_timeout_ms: Option<u64>,
163 #[serde(default)]
173 #[builder(default)]
174 pub backend: TransportBackend,
175 #[serde(default)]
180 pub proxy_url: Option<String>,
181}
182
183impl Debug for WebSocketConfig {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 f.debug_struct(stringify!(WebSocketConfig))
186 .field("url", &self.url)
187 .field(
188 "headers",
189 &format_args!("<{} header(s)>", self.headers.len()),
190 )
191 .field("heartbeat", &self.heartbeat)
192 .field("heartbeat_msg", &self.heartbeat_msg)
193 .field("reconnect_timeout_ms", &self.reconnect_timeout_ms)
194 .field(
195 "reconnect_delay_initial_ms",
196 &self.reconnect_delay_initial_ms,
197 )
198 .field("reconnect_delay_max_ms", &self.reconnect_delay_max_ms)
199 .field("reconnect_backoff_factor", &self.reconnect_backoff_factor)
200 .field("reconnect_jitter_ms", &self.reconnect_jitter_ms)
201 .field("reconnect_max_attempts", &self.reconnect_max_attempts)
202 .field("idle_timeout_ms", &self.idle_timeout_ms)
203 .field("backend", &self.backend)
204 .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
205 .finish()
206 }
207}
208
209impl<S: web_socket_config_builder::IsComplete> WebSocketConfigBuilder<S> {
210 pub fn build(self) -> NetworkConfigResult<WebSocketConfig> {
217 let config = self.build_inner();
218 config.validate()?;
219 Ok(config)
220 }
221}
222
223impl WebSocketConfig {
224 pub fn validate(&self) -> NetworkConfigResult<()> {
232 let mut errors = Vec::new();
233
234 if self.url.trim().is_empty() {
235 errors.push(NetworkConfigError::invalid("url", "must not be empty"));
236 }
237
238 if let Some(interval) = self.heartbeat
239 && interval == 0
240 {
241 errors.push(NetworkConfigError::invalid(
242 "heartbeat",
243 "interval must be positive",
244 ));
245 }
246
247 for (field, value) in [
250 ("reconnect_timeout_ms", self.reconnect_timeout_ms),
251 (
252 "reconnect_delay_initial_ms",
253 self.reconnect_delay_initial_ms,
254 ),
255 ("reconnect_delay_max_ms", self.reconnect_delay_max_ms),
256 ("idle_timeout_ms", self.idle_timeout_ms),
257 ] {
258 if let Some(value) = value
259 && value == 0
260 {
261 errors.push(NetworkConfigError::invalid(
262 field,
263 format!("must be positive, was {value}"),
264 ));
265 }
266 }
267
268 if let Some(factor) = self.reconnect_backoff_factor
269 && !(1.0..=100.0).contains(&factor)
270 {
271 errors.push(NetworkConfigError::invalid(
272 "reconnect_backoff_factor",
273 format!("must be in range [1.0, 100.0], was {factor}"),
274 ));
275 }
276
277 if let (Some(initial), Some(max)) =
278 (self.reconnect_delay_initial_ms, self.reconnect_delay_max_ms)
279 && initial > max
280 {
281 errors.push(NetworkConfigError::invalid(
282 "reconnect_delay_initial_ms",
283 format!("must not exceed reconnect_delay_max_ms ({max}), was {initial}"),
284 ));
285 }
286
287 NetworkConfigError::collect(errors)
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use rstest::rstest;
294 use serde_json::json;
295
296 use super::WebSocketConfig;
297 use crate::error::NetworkConfigError;
298
299 #[rstest]
300 fn test_deserialize_websocket_config_rejects_unknown_field() {
301 let config = json!({
302 "url": "wss://example.com/ws",
303 "unexpected": true,
304 });
305
306 let error = serde_json::from_value::<WebSocketConfig>(config).unwrap_err();
307
308 assert!(error.to_string().contains("unknown field `unexpected`"));
309 }
310
311 fn valid_config() -> WebSocketConfig {
312 WebSocketConfig::builder()
313 .url("wss://example.com/ws".to_string())
314 .build()
315 .expect("baseline websocket config should be valid")
316 }
317
318 #[rstest]
319 fn test_builder_accepts_valid_config() {
320 let result = WebSocketConfig::builder()
321 .url("wss://example.com/ws".to_string())
322 .build();
323
324 assert!(result.is_ok());
325 }
326
327 #[rstest]
328 fn test_validate_accepts_zero_jitter() {
329 let mut config = valid_config();
330 config.reconnect_jitter_ms = Some(0);
331
332 assert!(config.validate().is_ok());
333 }
334
335 #[rstest]
336 #[case::empty_url(|c: &mut WebSocketConfig| c.url = String::new(), "url")]
337 #[case::heartbeat(|c: &mut WebSocketConfig| c.heartbeat = Some(0), "heartbeat")]
338 #[case::reconnect_timeout(|c: &mut WebSocketConfig| c.reconnect_timeout_ms = Some(0), "reconnect_timeout_ms")]
339 #[case::reconnect_delay_initial(|c: &mut WebSocketConfig| c.reconnect_delay_initial_ms = Some(0), "reconnect_delay_initial_ms")]
340 #[case::reconnect_delay_max(|c: &mut WebSocketConfig| c.reconnect_delay_max_ms = Some(0), "reconnect_delay_max_ms")]
341 #[case::idle_timeout(|c: &mut WebSocketConfig| c.idle_timeout_ms = Some(0), "idle_timeout_ms")]
342 fn test_validate_rejects_invalid_field(
343 #[case] mutate: fn(&mut WebSocketConfig),
344 #[case] expected_field: &str,
345 ) {
346 let mut config = valid_config();
347 mutate(&mut config);
348
349 let err = config
350 .validate()
351 .expect_err("invalid value should be rejected");
352
353 assert!(
354 matches!(err, NetworkConfigError::Invalid { field, .. } if field == expected_field)
355 );
356 }
357
358 #[rstest]
359 #[case::too_small(0.5)]
360 #[case::too_large(100.1)]
361 #[case::nan(f64::NAN)]
362 #[case::infinite(f64::INFINITY)]
363 fn test_validate_rejects_invalid_backoff_factor(#[case] factor: f64) {
364 let mut config = valid_config();
365 config.reconnect_backoff_factor = Some(factor);
366
367 let err = config
368 .validate()
369 .expect_err("invalid backoff factor should be rejected");
370
371 assert!(
372 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_backoff_factor")
373 );
374 }
375
376 #[rstest]
377 fn test_validate_rejects_delay_initial_exceeding_max() {
378 let mut config = valid_config();
379 config.reconnect_delay_initial_ms = Some(5_000);
380 config.reconnect_delay_max_ms = Some(1_000);
381
382 let err = config
383 .validate()
384 .expect_err("initial delay above max should be rejected");
385
386 assert!(
387 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_delay_initial_ms")
388 );
389 }
390
391 #[rstest]
392 fn test_validate_collects_multiple_errors() {
393 let mut config = valid_config();
394 config.url = String::new();
395 config.reconnect_timeout_ms = Some(0);
396
397 let err = config.validate().expect_err("multiple invalid fields");
398
399 match err {
400 NetworkConfigError::Multiple { errors } => assert_eq!(errors.len(), 2),
401 other @ NetworkConfigError::Invalid { .. } => {
402 panic!("expected Multiple, was {other:?}")
403 }
404 }
405 }
406
407 #[rstest]
408 fn test_debug_redacts_proxy_credentials() {
409 const SECRET: &str = "unique-proxy-secret";
410 let mut config = valid_config();
411 config.proxy_url = Some(format!("http://proxytest:{SECRET}@proxy.example.com:8080"));
412
413 let debug = format!("{config:?}");
414
415 assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
416 assert!(!debug.contains(SECRET));
417 }
418}