Skip to main content

rithmic_rs/
error.rs

1use std::fmt;
2
3/// A request the server turned down, carrying the numeric code and the
4/// human-readable message separately so callers can branch on the code without
5/// parsing the message text.
6///
7/// This is a request-level outcome, not a connection failure. Receiving one
8/// does not mean the connection is unhealthy, so it is not a reason to
9/// reconnect.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct RithmicRequestError {
13    /// The response code exactly as received, before it is split into
14    /// [`Self::code`] and [`Self::message`].
15    pub rp_code: Vec<String>,
16    /// Numeric code, when present.
17    pub code: Option<String>,
18    /// Human-readable message, when present.
19    ///
20    /// `None` when the response carried a code with no message, or no
21    /// `rp_code` at all. Symmetric with [`Self::code`].
22    pub message: Option<String>,
23}
24
25/// Filter ASCII/Unicode control characters from server-supplied strings before
26/// they reach a log sink or terminal. Protects against log injection (newlines,
27/// `\r`) and ANSI-escape attacks when the Rithmic wire payload is rendered via
28/// `Display`.
29fn 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/// Typed errors returned by all plant handle methods.
54///
55/// There are three outcomes to handle, not two:
56///
57/// - `Ok(resp)` with `resp.error == None` — the request succeeded.
58/// - `Ok(resp)` with `resp.error == Some(..)` — the request reached the server
59///   and the server turned it down.
60/// - `Err(..)` — the request could not be completed: an argument was invalid,
61///   the connection dropped, or no response came back.
62///
63/// The second case is the one that catches people out: a request the server
64/// turned down still returns `Ok`. Code that only checks for `Err` will treat
65/// it as a success. Check [`RithmicResponse::error`] to tell the first two
66/// apart.
67///
68/// `login` is the one call that returns it as
69/// `Err(`[`RequestRejected`](Self::RequestRejected)`)` instead — both cases are
70/// shown below.
71///
72/// For which of these arrive on the subscription channel instead, and which
73/// stop a plant, see the crate-level [Error Handling](crate#error-handling)
74/// section.
75///
76/// [`RithmicResponse::error`]: crate::api::response::RithmicResponse::error
77///
78/// ```ignore
79/// // A `subscribe` the server turns down arrives as `Ok` with `error` set.
80/// match handle.subscribe("ESH6", "CME").await {
81///     Ok(resp) => match &resp.error {
82///         Some(err) => eprintln!("rejected: {err}"),
83///         None => { /* success */ }
84///     },
85///     Err(RithmicError::ConnectionClosed | RithmicError::SendFailed) => {
86///         handle.abort();
87///         // reconnect — see examples/reconnect.rs
88///     }
89///     Err(e) => eprintln!("{e}"),
90/// }
91///
92/// // A `login` the server turns down arrives as `Err`.
93/// if let Err(RithmicError::RequestRejected(err)) = handle.login().await {
94///     eprintln!(
95///         "login rejected code={} msg={}",
96///         err.code.as_deref().unwrap_or("?"),
97///         err.message.as_deref().unwrap_or(""),
98///     );
99/// }
100/// ```
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum RithmicError {
104    /// WebSocket connection could not be established.
105    ConnectionFailed(String),
106    /// The plant's WebSocket connection is gone; pending requests will never complete.
107    ConnectionClosed,
108    /// WebSocket send failed or timed out after the request was registered.
109    ///
110    /// Treat as a connection-health failure. This error alone does not prove the
111    /// actor has shut down; keep-alive failure detection can still emit
112    /// [`crate::rti::messages::RithmicMessage::HeartbeatTimeout`] or
113    /// [`crate::rti::messages::RithmicMessage::ConnectionError`] if the
114    /// connection is actually dead.
115    SendFailed,
116    /// Server returned an empty response where at least one was expected.
117    EmptyResponse,
118    /// No longer produced. The library does not time out requests; a caller
119    /// that wants a deadline wraps the call in [`tokio::time::timeout`], which
120    /// reports expiry through its own `Elapsed` rather than this variant.
121    /// Removed in 4.0.0.
122    #[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    /// The server turned the request down, with the code and message it gave.
128    /// Request-level only — not a reason to reconnect.
129    RequestRejected(RithmicRequestError),
130    /// A response arrived but could not be turned into a result — a decode
131    /// failure, or a failure the server reported without a code. Not a reason
132    /// to reconnect.
133    ///
134    /// An unrecognized `template_id` does not produce this error; it arrives as
135    /// [`RithmicMessage::UnknownTemplate`](crate::rti::messages::RithmicMessage::UnknownTemplate).
136    ProtocolError(String),
137    /// A caller-supplied argument is invalid (the message describes which argument
138    /// and why).
139    InvalidArgument(String),
140    /// No route for the order's exchange and the order named none, so nothing was sent.
141    #[non_exhaustive]
142    NoTradeRoute {
143        /// The exchange the order named.
144        exchange: String,
145        /// The exchanges that do have a route.
146        cached: Vec<String>,
147    },
148    /// Keep-alive detected the connection is dead.
149    HeartbeatTimeout,
150    /// Server terminated the session with a reason string.
151    ForcedLogout(String),
152}
153
154impl RithmicError {
155    /// Returns true when this error reflects a transport/connection-health failure
156    /// rather than a protocol-level rejection.
157    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    /// Maps this error to the synthetic subscription [`RithmicMessage`] that a
169    /// connection-health broadcast should carry. `HeartbeatTimeout` preserves
170    /// the keep-alive signal; every other variant surfaces as `ConnectionError`.
171    ///
172    /// [`RithmicMessage`]: crate::rti::messages::RithmicMessage
173    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        // rp_code = ["5"] produces code=Some("5"), message=None.
270        // Display renders "[5]" rather than "[5] ".
271        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        // A malicious or malformed server message must not leak newlines
283        // (log-injection) or ANSI escapes (terminal-control) into `Display`.
284        // The sanitizer strips control characters — the ESC byte of an ANSI
285        // sequence is removed, which breaks the escape and prevents terminal
286        // interpretation (even though the printable `[31m` text remains).
287        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        // `PartialEq` on `RithmicError` lets consumers write
326        // `assert_eq!(result, Err(RithmicError::ConnectionClosed))` in tests.
327        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        // `anyhow`/`eyre` and stdlib chain walkers rely on `source()`.
337
338        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        // For an rp_code rejection, `response.error` is populated with
360        // `RithmicError::RequestRejected` carrying the full structured payload.
361        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        // Display for the RithmicError wrapper prefixes "request rejected: "
383        // and delegates to `RithmicRequestError::Display`.
384        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        // A Reject carrying no rp_code leaves both fields `None`, so the inner
409        // error renders as an empty string.
410        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        // The exchange and the cached names both come off the wire, so both go
511        // through the sanitizer.
512        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        // Otherwise callers that reconnect on `is_connection_issue()` would tear
526        // down a live session, and its subscriptions, over one lost request.
527        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}