1use std::fmt;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct RithmicRequestError {
13 pub rp_code: Vec<String>,
16 pub code: Option<String>,
18 pub message: Option<String>,
23}
24
25fn sanitize_for_display(s: &str) -> String {
30 s.chars().filter(|c| !c.is_control()).collect()
31}
32
33impl fmt::Display for RithmicRequestError {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 let message = self.message.as_deref().map(sanitize_for_display);
36
37 match self.code.as_deref() {
38 Some(code) if !code.is_empty() => {
39 let code = sanitize_for_display(code);
40
41 match message {
42 Some(m) if !m.is_empty() => write!(f, "[{code}] {m}"),
43 _ => write!(f, "[{code}]"),
44 }
45 }
46 _ => write!(f, "{}", message.unwrap_or_default()),
47 }
48 }
49}
50
51impl std::error::Error for RithmicRequestError {}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum RithmicError {
104 ConnectionFailed(String),
106 ConnectionClosed,
108 SendFailed,
116 EmptyResponse,
118 #[deprecated(
123 since = "3.1.0",
124 note = "the library no longer times out requests; wrap the call in tokio::time::timeout"
125 )]
126 RequestTimeout,
127 RequestRejected(RithmicRequestError),
130 ProtocolError(String),
137 InvalidArgument(String),
140 #[non_exhaustive]
142 NoTradeRoute {
143 exchange: String,
145 cached: Vec<String>,
147 },
148 HeartbeatTimeout,
150 ForcedLogout(String),
152}
153
154impl RithmicError {
155 pub fn is_connection_issue(&self) -> bool {
158 matches!(
159 self,
160 Self::ConnectionFailed(_)
161 | Self::ConnectionClosed
162 | Self::SendFailed
163 | Self::HeartbeatTimeout
164 | Self::ForcedLogout(_)
165 )
166 }
167
168 pub fn as_connection_message(&self) -> crate::rti::messages::RithmicMessage {
174 match self {
175 Self::HeartbeatTimeout => crate::rti::messages::RithmicMessage::HeartbeatTimeout,
176 _ => crate::rti::messages::RithmicMessage::ConnectionError,
177 }
178 }
179}
180
181impl fmt::Display for RithmicError {
182 #[allow(deprecated)]
183 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
184 match self {
185 RithmicError::ConnectionFailed(msg) => write!(f, "connection failed: {msg}"),
186 RithmicError::ConnectionClosed => write!(f, "connection closed"),
187 RithmicError::SendFailed => write!(f, "WebSocket send failed or timed out"),
188 RithmicError::EmptyResponse => write!(f, "empty response"),
189 RithmicError::RequestTimeout => write!(f, "request timed out"),
190 RithmicError::RequestRejected(err) => {
191 let detail = err.to_string();
192
193 if detail.is_empty() {
194 write!(f, "request rejected")
195 } else {
196 write!(f, "request rejected: {detail}")
197 }
198 }
199 RithmicError::ProtocolError(msg) => write!(f, "protocol error: {msg}"),
200 RithmicError::InvalidArgument(msg) => write!(f, "invalid argument: {msg}"),
201 RithmicError::NoTradeRoute { exchange, cached } => {
202 write!(
203 f,
204 "no trade route for exchange {}",
205 sanitize_for_display(exchange),
206 )?;
207
208 match cached.is_empty() {
209 true => write!(f, "; no routes cached"),
210 false => {
211 let cached: Vec<String> =
212 cached.iter().map(|key| sanitize_for_display(key)).collect();
213
214 write!(f, "; cached: {}", cached.join(", "))
215 }
216 }
217 }
218 RithmicError::HeartbeatTimeout => write!(f, "heartbeat timeout"),
219 RithmicError::ForcedLogout(reason) => {
220 write!(f, "forced logout: {}", sanitize_for_display(reason))
221 }
222 }
223 }
224}
225
226impl std::error::Error for RithmicError {
227 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
228 match self {
229 RithmicError::RequestRejected(inner) => Some(inner),
230 _ => None,
231 }
232 }
233}
234
235#[cfg(test)]
236#[allow(deprecated)]
237mod tests {
238 use std::error::Error;
239
240 use super::*;
241
242 #[test]
243 fn request_error_display_formats_code_and_message() {
244 let err = RithmicRequestError {
245 rp_code: vec![
246 "1039".to_string(),
247 "FCM Id field is not received.".to_string(),
248 ],
249 code: Some("1039".to_string()),
250 message: Some("FCM Id field is not received.".to_string()),
251 };
252
253 assert_eq!(err.to_string(), "[1039] FCM Id field is not received.");
254 }
255
256 #[test]
257 fn request_error_display_without_code_uses_message_only() {
258 let err = RithmicRequestError {
259 rp_code: vec![],
260 code: None,
261 message: Some("something happened".to_string()),
262 };
263
264 assert_eq!(err.to_string(), "something happened");
265 }
266
267 #[test]
268 fn request_error_display_single_element_omits_trailing_slash() {
269 let err = RithmicRequestError {
272 rp_code: vec!["5".to_string()],
273 code: Some("5".to_string()),
274 message: None,
275 };
276
277 assert_eq!(err.to_string(), "[5]");
278 }
279
280 #[test]
281 fn request_error_display_sanitizes_control_chars() {
282 let err = RithmicRequestError {
288 rp_code: vec![
289 "3\n".to_string(),
290 "bad\x1b[31mredinjection\r\ndropped".to_string(),
291 ],
292 code: Some("3\n".to_string()),
293 message: Some("bad\x1b[31mredinjection\r\ndropped".to_string()),
294 };
295
296 assert_eq!(err.to_string(), "[3] bad[31mredinjectiondropped");
297 }
298
299 #[test]
300 fn request_error_equality() {
301 let a = RithmicRequestError {
302 rp_code: vec!["3".to_string(), "bad request".to_string()],
303 code: Some("3".to_string()),
304 message: Some("bad request".to_string()),
305 };
306
307 let b = RithmicRequestError {
308 rp_code: vec!["3".to_string(), "bad request".to_string()],
309 code: Some("3".to_string()),
310 message: Some("bad request".to_string()),
311 };
312
313 let c = RithmicRequestError {
314 rp_code: vec!["4".to_string(), "bad request".to_string()],
315 code: Some("4".to_string()),
316 message: Some("bad request".to_string()),
317 };
318
319 assert_eq!(a, b);
320 assert_ne!(a, c);
321 }
322
323 #[test]
324 fn rithmic_error_equality_for_unit_variants() {
325 assert_eq!(
328 RithmicError::ConnectionClosed,
329 RithmicError::ConnectionClosed
330 );
331 assert_ne!(RithmicError::ConnectionClosed, RithmicError::SendFailed);
332 }
333
334 #[test]
335 fn rithmic_error_source_chain_exposes_inner_request_error() {
336 let inner = RithmicRequestError {
339 rp_code: vec!["3".to_string(), "bad".to_string()],
340 code: Some("3".to_string()),
341 message: Some("bad".to_string()),
342 };
343
344 let err = RithmicError::RequestRejected(inner.clone());
345 let src = err
346 .source()
347 .expect("source should be Some for RequestRejected");
348
349 assert_eq!(src.to_string(), inner.to_string());
350
351 assert!(
352 RithmicError::ConnectionClosed.source().is_none(),
353 "unit variants should have no source"
354 );
355 }
356
357 #[test]
358 fn plant_rejection_mapping_produces_request_rejected() {
359 let err = RithmicRequestError {
362 rp_code: vec!["3".to_string(), "bad request".to_string()],
363 code: Some("3".to_string()),
364 message: Some("bad request".to_string()),
365 };
366
367 let mapped = RithmicError::RequestRejected(err.clone());
368
369 match mapped {
370 RithmicError::RequestRejected(inner) => {
371 assert_eq!(inner, err);
372 assert_eq!(inner.code.as_deref(), Some("3"));
373 assert_eq!(inner.message.as_deref(), Some("bad request"));
374 assert_eq!(
375 inner.rp_code,
376 vec!["3".to_string(), "bad request".to_string()]
377 );
378 }
379 other => panic!("expected RequestRejected, got {other:?}"),
380 }
381
382 let display = RithmicError::RequestRejected(err).to_string();
385
386 assert_eq!(display, "request rejected: [3] bad request");
387 }
388
389 #[test]
390 fn rithmic_error_request_rejected_display_delegates() {
391 let err = RithmicError::RequestRejected(RithmicRequestError {
392 rp_code: vec![
393 "7".to_string(),
394 "an error occurred while parsing data.".to_string(),
395 ],
396 code: Some("7".to_string()),
397 message: Some("an error occurred while parsing data.".to_string()),
398 });
399
400 assert_eq!(
401 err.to_string(),
402 "request rejected: [7] an error occurred while parsing data."
403 );
404 }
405
406 #[test]
407 fn rithmic_error_request_rejected_display_omits_the_separator_when_empty() {
408 let err = RithmicError::RequestRejected(RithmicRequestError {
411 rp_code: vec![],
412 code: None,
413 message: None,
414 });
415
416 assert_eq!(err.to_string(), "request rejected");
417 }
418
419 #[test]
420 fn rithmic_error_protocol_error_display() {
421 let err = RithmicError::ProtocolError("decode failed".to_string());
422
423 assert_eq!(err.to_string(), "protocol error: decode failed");
424 }
425
426 #[test]
427 fn request_timeout_display() {
428 assert_eq!(
429 RithmicError::RequestTimeout.to_string(),
430 "request timed out"
431 );
432 }
433
434 #[test]
435 fn heartbeat_timeout_display() {
436 assert_eq!(
437 RithmicError::HeartbeatTimeout.to_string(),
438 "heartbeat timeout"
439 );
440 }
441
442 #[test]
443 fn forced_logout_display() {
444 assert_eq!(
445 RithmicError::ForcedLogout("srv reason".into()).to_string(),
446 "forced logout: srv reason"
447 );
448 }
449
450 #[test]
451 fn forced_logout_sanitizes_control_chars() {
452 let err = RithmicError::ForcedLogout("bad\nreason".into());
453 assert_eq!(err.to_string(), "forced logout: badreason");
454 }
455
456 #[test]
457 fn is_connection_issue_true_for_transport_variants() {
458 assert!(RithmicError::ConnectionFailed("x".into()).is_connection_issue());
459 assert!(RithmicError::ConnectionClosed.is_connection_issue());
460 assert!(RithmicError::SendFailed.is_connection_issue());
461 assert!(RithmicError::HeartbeatTimeout.is_connection_issue());
462 assert!(RithmicError::ForcedLogout("x".into()).is_connection_issue());
463 }
464
465 #[test]
466 fn is_connection_issue_false_for_protocol_variants() {
467 let req = RithmicRequestError {
468 rp_code: vec!["3".into(), "x".into()],
469 code: Some("3".into()),
470 message: Some("x".into()),
471 };
472 assert!(!RithmicError::RequestRejected(req).is_connection_issue());
473 assert!(!RithmicError::ProtocolError("x".into()).is_connection_issue());
474 assert!(!RithmicError::InvalidArgument("x".into()).is_connection_issue());
475 assert!(!RithmicError::EmptyResponse.is_connection_issue());
476 assert!(
477 !RithmicError::NoTradeRoute {
478 exchange: "CBOT".into(),
479 cached: vec![],
480 }
481 .is_connection_issue()
482 );
483 }
484
485 #[test]
486 fn no_trade_route_display_lists_what_is_cached() {
487 let err = RithmicError::NoTradeRoute {
488 exchange: "CBOT".into(),
489 cached: vec!["CME".into(), "NYMEX".into()],
490 };
491
492 assert_eq!(
493 err.to_string(),
494 "no trade route for exchange CBOT; cached: CME, NYMEX"
495 );
496
497 let err = RithmicError::NoTradeRoute {
498 exchange: "CBOT".into(),
499 cached: vec![],
500 };
501
502 assert_eq!(
503 err.to_string(),
504 "no trade route for exchange CBOT; no routes cached"
505 );
506 }
507
508 #[test]
509 fn no_trade_route_display_sanitizes_control_chars() {
510 let err = RithmicError::NoTradeRoute {
513 exchange: "CB\rOT".into(),
514 cached: vec!["C\x1b[31mME".into()],
515 };
516
517 assert_eq!(
518 err.to_string(),
519 "no trade route for exchange CBOT; cached: C[31mME"
520 );
521 }
522
523 #[test]
524 fn request_timeout_is_not_a_connection_issue() {
525 assert!(!RithmicError::RequestTimeout.is_connection_issue());
528 }
529
530 #[test]
531 fn as_connection_message_heartbeat_timeout() {
532 assert!(matches!(
533 RithmicError::HeartbeatTimeout.as_connection_message(),
534 crate::rti::messages::RithmicMessage::HeartbeatTimeout
535 ));
536 }
537
538 #[test]
539 fn as_connection_message_connection_failed() {
540 assert!(matches!(
541 RithmicError::ConnectionFailed("x".into()).as_connection_message(),
542 crate::rti::messages::RithmicMessage::ConnectionError
543 ));
544 }
545}