1use std::fmt::Debug;
29
30use tokio_tungstenite::tungstenite::stream::Mode;
31
32use super::types::TcpMessageHandler;
33use crate::error::{NetworkConfigError, NetworkConfigResult};
34
35#[derive(bon::Builder)]
37#[builder(finish_fn(name = build_inner, vis = ""))]
38#[cfg_attr(
39 feature = "python",
40 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network", from_py_object)
41)]
42#[cfg_attr(
43 feature = "python",
44 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.network")
45)]
46pub struct SocketConfig {
47 pub url: String,
49 pub mode: Mode,
51 pub suffix: Vec<u8>,
53 pub message_handler: Option<TcpMessageHandler>,
55 pub heartbeat: Option<(u64, Vec<u8>)>,
57 pub reconnect_timeout_ms: Option<u64>,
59 pub reconnect_delay_initial_ms: Option<u64>,
61 pub reconnect_delay_max_ms: Option<u64>,
63 pub reconnect_backoff_factor: Option<f64>,
65 pub reconnect_jitter_ms: Option<u64>,
67 pub connection_max_retries: Option<u32>,
69 pub reconnect_max_attempts: Option<u32>,
73 pub idle_timeout_ms: Option<u64>,
78 pub certs_dir: Option<String>,
80}
81
82impl<S: socket_config_builder::IsComplete> SocketConfigBuilder<S> {
83 pub fn build(self) -> NetworkConfigResult<SocketConfig> {
90 let config = self.build_inner();
91 config.validate()?;
92 Ok(config)
93 }
94}
95
96impl SocketConfig {
97 pub fn validate(&self) -> NetworkConfigResult<()> {
105 let mut errors = Vec::new();
106
107 if self.url.trim().is_empty() {
108 errors.push(NetworkConfigError::invalid("url", "must not be empty"));
109 }
110
111 if let Some((interval, _)) = &self.heartbeat
112 && *interval == 0
113 {
114 errors.push(NetworkConfigError::invalid(
115 "heartbeat",
116 "interval must be positive",
117 ));
118 }
119
120 for (field, value) in [
123 ("reconnect_timeout_ms", self.reconnect_timeout_ms),
124 (
125 "reconnect_delay_initial_ms",
126 self.reconnect_delay_initial_ms,
127 ),
128 ("reconnect_delay_max_ms", self.reconnect_delay_max_ms),
129 ("idle_timeout_ms", self.idle_timeout_ms),
130 ] {
131 if let Some(value) = value
132 && value == 0
133 {
134 errors.push(NetworkConfigError::invalid(
135 field,
136 format!("must be positive, was {value}"),
137 ));
138 }
139 }
140
141 if let Some(factor) = self.reconnect_backoff_factor
142 && !(factor.is_finite() && factor >= 1.0)
143 {
144 errors.push(NetworkConfigError::invalid(
145 "reconnect_backoff_factor",
146 format!("must be finite and >= 1.0, was {factor}"),
147 ));
148 }
149
150 if let (Some(initial), Some(max)) =
151 (self.reconnect_delay_initial_ms, self.reconnect_delay_max_ms)
152 && initial > max
153 {
154 errors.push(NetworkConfigError::invalid(
155 "reconnect_delay_initial_ms",
156 format!("must not exceed reconnect_delay_max_ms ({max}), was {initial}"),
157 ));
158 }
159
160 NetworkConfigError::collect(errors)
161 }
162}
163
164impl Debug for SocketConfig {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.debug_struct(stringify!(SocketConfig))
167 .field("url", &self.url)
168 .field("mode", &self.mode)
169 .field("suffix", &self.suffix)
170 .field(
171 "message_handler",
172 &self.message_handler.as_ref().map(|_| "<function>"),
173 )
174 .field("heartbeat", &self.heartbeat)
175 .field("reconnect_timeout_ms", &self.reconnect_timeout_ms)
176 .field(
177 "reconnect_delay_initial_ms",
178 &self.reconnect_delay_initial_ms,
179 )
180 .field("reconnect_delay_max_ms", &self.reconnect_delay_max_ms)
181 .field("reconnect_backoff_factor", &self.reconnect_backoff_factor)
182 .field("reconnect_jitter_ms", &self.reconnect_jitter_ms)
183 .field("connection_max_retries", &self.connection_max_retries)
184 .field("reconnect_max_attempts", &self.reconnect_max_attempts)
185 .field("idle_timeout_ms", &self.idle_timeout_ms)
186 .field("certs_dir", &self.certs_dir)
187 .finish()
188 }
189}
190
191impl Clone for SocketConfig {
192 fn clone(&self) -> Self {
193 Self {
194 url: self.url.clone(),
195 mode: self.mode,
196 suffix: self.suffix.clone(),
197 message_handler: self.message_handler.clone(),
198 heartbeat: self.heartbeat.clone(),
199 reconnect_timeout_ms: self.reconnect_timeout_ms,
200 reconnect_delay_initial_ms: self.reconnect_delay_initial_ms,
201 reconnect_delay_max_ms: self.reconnect_delay_max_ms,
202 reconnect_backoff_factor: self.reconnect_backoff_factor,
203 reconnect_jitter_ms: self.reconnect_jitter_ms,
204 connection_max_retries: self.connection_max_retries,
205 reconnect_max_attempts: self.reconnect_max_attempts,
206 idle_timeout_ms: self.idle_timeout_ms,
207 certs_dir: self.certs_dir.clone(),
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use rstest::rstest;
215 use tokio_tungstenite::tungstenite::stream::Mode;
216
217 use super::SocketConfig;
218 use crate::error::NetworkConfigError;
219
220 fn valid_config() -> SocketConfig {
221 SocketConfig::builder()
222 .url("tcp://127.0.0.1:8080".to_string())
223 .mode(Mode::Plain)
224 .suffix(vec![b'\n'])
225 .build()
226 .expect("baseline socket config should be valid")
227 }
228
229 #[rstest]
230 fn test_builder_accepts_valid_config() {
231 let result = SocketConfig::builder()
232 .url("tcp://127.0.0.1:8080".to_string())
233 .mode(Mode::Plain)
234 .suffix(vec![b'\n'])
235 .build();
236
237 assert!(result.is_ok());
238 }
239
240 #[rstest]
241 fn test_validate_accepts_zero_jitter() {
242 let mut config = valid_config();
243 config.reconnect_jitter_ms = Some(0);
244
245 assert!(config.validate().is_ok());
246 }
247
248 #[rstest]
249 #[case::empty_url(|c: &mut SocketConfig| c.url = String::new(), "url")]
250 #[case::heartbeat(|c: &mut SocketConfig| c.heartbeat = Some((0, vec![])), "heartbeat")]
251 #[case::reconnect_timeout(|c: &mut SocketConfig| c.reconnect_timeout_ms = Some(0), "reconnect_timeout_ms")]
252 #[case::reconnect_delay_initial(|c: &mut SocketConfig| c.reconnect_delay_initial_ms = Some(0), "reconnect_delay_initial_ms")]
253 #[case::reconnect_delay_max(|c: &mut SocketConfig| c.reconnect_delay_max_ms = Some(0), "reconnect_delay_max_ms")]
254 #[case::idle_timeout(|c: &mut SocketConfig| c.idle_timeout_ms = Some(0), "idle_timeout_ms")]
255 fn test_validate_rejects_invalid_field(
256 #[case] mutate: fn(&mut SocketConfig),
257 #[case] expected_field: &str,
258 ) {
259 let mut config = valid_config();
260 mutate(&mut config);
261
262 let err = config
263 .validate()
264 .expect_err("invalid value should be rejected");
265
266 assert!(
267 matches!(err, NetworkConfigError::Invalid { field, .. } if field == expected_field)
268 );
269 }
270
271 #[rstest]
272 #[case::too_small(0.5)]
273 #[case::nan(f64::NAN)]
274 #[case::infinite(f64::INFINITY)]
275 fn test_validate_rejects_invalid_backoff_factor(#[case] factor: f64) {
276 let mut config = valid_config();
277 config.reconnect_backoff_factor = Some(factor);
278
279 let err = config
280 .validate()
281 .expect_err("invalid backoff factor should be rejected");
282
283 assert!(
284 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_backoff_factor")
285 );
286 }
287
288 #[rstest]
289 fn test_validate_rejects_delay_initial_exceeding_max() {
290 let mut config = valid_config();
291 config.reconnect_delay_initial_ms = Some(5_000);
292 config.reconnect_delay_max_ms = Some(1_000);
293
294 let err = config
295 .validate()
296 .expect_err("initial delay above max should be rejected");
297
298 assert!(
299 matches!(err, NetworkConfigError::Invalid { field, .. } if field == "reconnect_delay_initial_ms")
300 );
301 }
302
303 #[rstest]
304 fn test_validate_collects_multiple_errors() {
305 let mut config = valid_config();
306 config.url = String::new();
307 config.reconnect_timeout_ms = Some(0);
308
309 let err = config.validate().expect_err("multiple invalid fields");
310
311 match err {
312 NetworkConfigError::Multiple { errors } => assert_eq!(errors.len(), 2),
313 other @ NetworkConfigError::Invalid { .. } => {
314 panic!("expected Multiple, was {other:?}")
315 }
316 }
317 }
318}