1use std::fmt::Debug;
32
33use nautilus_core::string::secret::REDACTED;
34use tokio_tungstenite::tungstenite::stream::Mode;
35
36use super::types::TcpMessageHandler;
37use crate::error::{NetworkConfigError, NetworkConfigResult};
38
39#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct SocketHeartbeat {
45 pub interval_secs: u64,
49 pub payload: Vec<u8>,
51}
52
53#[derive(Clone, bon::Builder)]
55#[builder(finish_fn(name = build_inner, vis = ""))]
56pub struct SocketConfig {
57 pub url: String,
59 pub mode: Mode,
61 pub suffix: Vec<u8>,
63 pub message_handler: Option<TcpMessageHandler>,
65 pub heartbeat: Option<SocketHeartbeat>,
77 pub connect_timeout_ms: Option<u64>,
83 pub reconnect_delay_initial_ms: Option<u64>,
85 pub reconnect_delay_max_ms: Option<u64>,
87 pub reconnect_backoff_factor: Option<f64>,
89 pub reconnect_jitter_ms: Option<u64>,
91 pub connection_max_retries: Option<u32>,
93 pub reconnect_max_attempts: Option<u32>,
99 pub heartbeat_timeout_secs: Option<u64>,
114 pub writer_capacity: Option<usize>,
119 pub certs_dir: Option<String>,
129}
130
131impl<S: socket_config_builder::IsComplete> SocketConfigBuilder<S> {
132 pub fn build(self) -> NetworkConfigResult<SocketConfig> {
138 let config = self.build_inner();
139 config.validate()?;
140 Ok(config)
141 }
142}
143
144impl SocketConfig {
145 pub fn validate(&self) -> NetworkConfigResult<()> {
154 let mut errors = Vec::new();
155 if let Err(e) = crate::writer::validate_capacity(self.writer_capacity) {
156 errors.push(e);
157 }
158
159 if self.url.trim().is_empty() {
160 errors.push(NetworkConfigError::invalid("url", "must not be empty"));
161 }
162
163 if let Some(heartbeat) = &self.heartbeat
164 && heartbeat.interval_secs == 0
165 {
166 errors.push(NetworkConfigError::invalid(
167 "heartbeat",
168 "interval must be positive",
169 ));
170 }
171
172 if let (Some(heartbeat), Some(timeout_secs)) =
175 (&self.heartbeat, self.heartbeat_timeout_secs)
176 && timeout_secs <= heartbeat.interval_secs
177 {
178 errors.push(NetworkConfigError::invalid(
179 "heartbeat_timeout_secs",
180 format!(
181 "must exceed heartbeat interval ({}s), was {timeout_secs}s",
182 heartbeat.interval_secs
183 ),
184 ));
185 }
186
187 for (field, value) in [
190 ("connect_timeout_ms", self.connect_timeout_ms),
191 (
192 "reconnect_delay_initial_ms",
193 self.reconnect_delay_initial_ms,
194 ),
195 ("reconnect_delay_max_ms", self.reconnect_delay_max_ms),
196 ("heartbeat_timeout_secs", self.heartbeat_timeout_secs),
197 ] {
198 if let Some(value) = value
199 && value == 0
200 {
201 errors.push(NetworkConfigError::invalid(
202 field,
203 format!("must be positive, was {value}"),
204 ));
205 }
206 }
207
208 if let Some(factor) = self.reconnect_backoff_factor
209 && !(1.0..=100.0).contains(&factor)
210 {
211 errors.push(NetworkConfigError::invalid(
212 "reconnect_backoff_factor",
213 format!("must be in range [1.0, 100.0], was {factor}"),
214 ));
215 }
216
217 if let (Some(initial), Some(max)) =
218 (self.reconnect_delay_initial_ms, self.reconnect_delay_max_ms)
219 && initial > max
220 {
221 errors.push(NetworkConfigError::invalid(
222 "reconnect_delay_initial_ms",
223 format!("must not exceed reconnect_delay_max_ms ({max}), was {initial}"),
224 ));
225 }
226
227 NetworkConfigError::collect(errors)
228 }
229
230 pub(crate) fn resolved_heartbeat_timeout(&self) -> Option<u64> {
231 crate::heartbeat::resolve_heartbeat_timeout(
232 self.heartbeat_timeout_secs,
233 self.heartbeat
234 .as_ref()
235 .map(|heartbeat| heartbeat.interval_secs),
236 )
237 }
238}
239
240impl Debug for SocketConfig {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 f.debug_struct(stringify!(SocketConfig))
243 .field("url", &REDACTED)
244 .field("mode", &self.mode)
245 .field("suffix", &self.suffix)
246 .field(
247 "message_handler",
248 &self.message_handler.as_ref().map(|_| "<function>"),
249 )
250 .field("heartbeat", &self.heartbeat)
251 .field("writer_capacity", &self.writer_capacity)
252 .field("connect_timeout_ms", &self.connect_timeout_ms)
253 .field(
254 "reconnect_delay_initial_ms",
255 &self.reconnect_delay_initial_ms,
256 )
257 .field("reconnect_delay_max_ms", &self.reconnect_delay_max_ms)
258 .field("reconnect_backoff_factor", &self.reconnect_backoff_factor)
259 .field("reconnect_jitter_ms", &self.reconnect_jitter_ms)
260 .field("connection_max_retries", &self.connection_max_retries)
261 .field("reconnect_max_attempts", &self.reconnect_max_attempts)
262 .field("heartbeat_timeout_secs", &self.heartbeat_timeout_secs)
263 .field("certs_dir", &self.certs_dir)
264 .finish()
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use rstest::rstest;
271 use tokio_tungstenite::tungstenite::stream::Mode;
272
273 use super::{SocketConfig, SocketHeartbeat};
274 use crate::error::NetworkConfigError;
275
276 fn valid_config() -> SocketConfig {
277 SocketConfig::builder()
278 .url("tcp://127.0.0.1:8080".to_string())
279 .mode(Mode::Plain)
280 .suffix(vec![b'\n'])
281 .build()
282 .expect("baseline socket config should be valid")
283 }
284
285 #[rstest]
286 fn test_builder_accepts_valid_config() {
287 let result = SocketConfig::builder()
288 .url("tcp://127.0.0.1:8080".to_string())
289 .mode(Mode::Plain)
290 .suffix(vec![b'\n'])
291 .build();
292
293 assert!(result.is_ok());
294 }
295
296 #[rstest]
297 fn test_validate_accepts_zero_jitter() {
298 let mut config = valid_config();
299 config.reconnect_jitter_ms = Some(0);
300
301 assert!(config.validate().is_ok());
302 }
303
304 #[rstest]
305 fn test_validate_accepts_heartbeat_with_payload() {
306 let mut config = valid_config();
307 config.heartbeat = Some(SocketHeartbeat {
308 interval_secs: 5,
309 payload: b"ping".to_vec(),
310 });
311
312 assert!(config.validate().is_ok());
313 }
314
315 #[rstest]
316 #[case::derived(None, Some(15))]
317 #[case::explicit_wins(Some(20), Some(20))]
318 fn test_resolve_timeout_from_socket_heartbeat(
319 #[case] timeout_secs: Option<u64>,
320 #[case] expected: Option<u64>,
321 ) {
322 let mut config = valid_config();
323 config.heartbeat = Some(SocketHeartbeat {
324 interval_secs: 5,
325 payload: b"ping".to_vec(),
326 });
327
328 config.heartbeat_timeout_secs = timeout_secs;
329
330 assert_eq!(config.resolved_heartbeat_timeout(), expected);
331 }
332
333 #[rstest]
334 #[case::writer_capacity_zero(|c: &mut SocketConfig| c.writer_capacity = Some(0), "writer_capacity")]
335 #[case::writer_capacity_excess(|c: &mut SocketConfig| c.writer_capacity = Some(usize::MAX), "writer_capacity")]
336 #[case::empty_url(|c: &mut SocketConfig| c.url = String::new(), "url")]
337 #[case::heartbeat_interval(|c: &mut SocketConfig| { c.heartbeat = Some(SocketHeartbeat { interval_secs: 0, payload: vec![] }); }, "heartbeat")]
338 #[case::heartbeat_timeout_below_interval(|c: &mut SocketConfig| { c.heartbeat = Some(SocketHeartbeat { interval_secs: 5, payload: vec![b'p'] }); c.heartbeat_timeout_secs = Some(5); }, "heartbeat_timeout_secs")]
339 #[case::connect_timeout(|c: &mut SocketConfig| c.connect_timeout_ms = Some(0), "connect_timeout_ms")]
340 #[case::reconnect_delay_initial(|c: &mut SocketConfig| c.reconnect_delay_initial_ms = Some(0), "reconnect_delay_initial_ms")]
341 #[case::reconnect_delay_max(|c: &mut SocketConfig| c.reconnect_delay_max_ms = Some(0), "reconnect_delay_max_ms")]
342 #[case::heartbeat_timeout_zero(|c: &mut SocketConfig| c.heartbeat_timeout_secs = Some(0), "heartbeat_timeout_secs")]
343 fn test_validate_rejects_invalid_field(
344 #[case] mutate: fn(&mut SocketConfig),
345 #[case] expected_field: &str,
346 ) {
347 let mut config = valid_config();
348 mutate(&mut config);
349
350 let err = config
351 .validate()
352 .expect_err("invalid value should be rejected");
353
354 assert!(
355 matches!(err, NetworkConfigError::Invalid { field, .. } if field == expected_field)
356 );
357 }
358
359 #[rstest]
360 #[case::too_small(0.5)]
361 #[case::too_large(100.1)]
362 #[case::nan(f64::NAN)]
363 #[case::infinite(f64::INFINITY)]
364 fn test_validate_rejects_invalid_backoff_factor(#[case] factor: f64) {
365 let mut config = valid_config();
366 config.reconnect_backoff_factor = Some(factor);
367
368 let err = config
369 .validate()
370 .expect_err("invalid backoff factor should be rejected");
371
372 assert!(
373 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_backoff_factor")
374 );
375 }
376
377 #[rstest]
378 fn test_validate_rejects_delay_initial_exceeding_max() {
379 let mut config = valid_config();
380 config.reconnect_delay_initial_ms = Some(5_000);
381 config.reconnect_delay_max_ms = Some(1_000);
382
383 let err = config
384 .validate()
385 .expect_err("initial delay above max should be rejected");
386
387 assert!(
388 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_delay_initial_ms")
389 );
390 }
391
392 #[rstest]
393 fn test_validate_collects_multiple_errors() {
394 let mut config = valid_config();
395 config.url = String::new();
396 config.connect_timeout_ms = Some(0);
397
398 let err = config.validate().expect_err("multiple invalid fields");
399
400 match err {
401 NetworkConfigError::Multiple { errors } => assert_eq!(errors.len(), 2),
402 other @ NetworkConfigError::Invalid { .. } => {
403 panic!("expected Multiple, was {other:?}")
404 }
405 }
406 }
407
408 #[rstest]
409 fn test_debug_redacts_endpoint_credentials() {
410 const ENDPOINT_PATH_SECRET: &str = "unique-endpoint-path-secret";
411 const ENDPOINT_QUERY_SECRET: &str = "unique-endpoint-query-secret";
412 let mut config = valid_config();
413 config.url =
414 format!("wss://rpc.example.com/{ENDPOINT_PATH_SECRET}?api_key={ENDPOINT_QUERY_SECRET}");
415
416 let debug = format!("{config:?}");
417
418 assert!(debug.contains("url: \"<redacted>\""));
419 assert!(!debug.contains(ENDPOINT_PATH_SECRET));
420 assert!(!debug.contains(ENDPOINT_QUERY_SECRET));
421 }
422}