Skip to main content

rithmic_rs/
error.rs

1use std::fmt;
2
3/// Structured server-side rejection preserving both the Rithmic `rp_code`
4/// numeric code and the human-readable message.
5///
6/// Rithmic returns request-level errors as a tuple `rp_code = [code, message]`;
7/// this struct keeps both pieces accessible so callers can branch on the
8/// numeric code (e.g. `"1039"` for "FCM Id field is not received") without
9/// parsing the string. The raw payload is preserved on [`Self::rp_code`] so
10/// consumers see exactly what the wire carried.
11///
12/// A populated `RithmicRequestError` is a **protocol-level** outcome — not a
13/// transport/connection failure. Receiving one must NOT trigger reconnection.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub struct RithmicRequestError {
17    /// Raw rp_code payload exactly as received from Rithmic.
18    pub rp_code: Vec<String>,
19    /// First rp_code element when present.
20    pub code: Option<String>,
21    /// Second rp_code element when present.
22    ///
23    /// `None` when the server emitted a single-element rp_code (e.g. `["5"]`).
24    /// Symmetric with [`Self::code`].
25    pub message: Option<String>,
26}
27
28/// Filter ASCII/Unicode control characters from server-supplied strings before
29/// they reach a log sink or terminal. Protects against log injection (newlines,
30/// `\r`) and ANSI-escape attacks when the Rithmic wire payload is rendered via
31/// `Display`.
32fn sanitize_for_display(s: &str) -> String {
33    s.chars().filter(|c| !c.is_control()).collect()
34}
35
36impl fmt::Display for RithmicRequestError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        let message = self.message.as_deref().map(sanitize_for_display);
39
40        match self.code.as_deref() {
41            Some(code) if !code.is_empty() => {
42                let code = sanitize_for_display(code);
43
44                match message {
45                    Some(m) if !m.is_empty() => write!(f, "[{code}] {m}"),
46                    _ => write!(f, "[{code}]"),
47                }
48            }
49            _ => write!(f, "{}", message.unwrap_or_default()),
50        }
51    }
52}
53
54impl std::error::Error for RithmicRequestError {}
55
56/// Typed errors returned by all plant handle methods.
57///
58/// ```ignore
59/// match handle.subscribe("ESH6", "CME").await {
60///     Ok(resp) => { /* success */ }
61///     Err(RithmicError::ConnectionClosed | RithmicError::SendFailed) => {
62///         handle.abort();
63///         // reconnect — see examples/reconnect.rs
64///     }
65///     Err(RithmicError::InvalidArgument(msg)) => eprintln!("bad input: {msg}"),
66///     Err(RithmicError::RequestRejected(err)) => {
67///         eprintln!(
68///             "rejected code={} msg={}",
69///             err.code.as_deref().unwrap_or("?"),
70///             err.message.as_deref().unwrap_or(""),
71///         );
72///     }
73///     Err(e) => eprintln!("{e}"),
74/// }
75/// ```
76#[derive(Debug, Clone, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum RithmicError {
79    /// WebSocket connection could not be established.
80    ConnectionFailed(String),
81    /// The plant's WebSocket connection is gone; pending requests will never complete.
82    ConnectionClosed,
83    /// WebSocket send failed or timed out after the request was registered.
84    ///
85    /// Treat as a connection-health failure. This error alone does not prove the
86    /// actor has shut down; keep-alive failure detection can still emit
87    /// [`crate::rti::messages::RithmicMessage::HeartbeatTimeout`] or
88    /// [`crate::rti::messages::RithmicMessage::ConnectionError`] if the
89    /// connection is actually dead.
90    SendFailed,
91    /// Server returned an empty response where at least one was expected.
92    EmptyResponse,
93    /// Structured protocol-level rejection preserving the Rithmic `rp_code`
94    /// tuple. Not a reconnect signal — request-level only.
95    RequestRejected(RithmicRequestError),
96    /// Non-transport, non-rp_code response failure (e.g. decode failures or
97    /// other protocol-level outcomes that don't carry `rp_code`). Not a
98    /// reconnect signal.
99    ProtocolError(String),
100    /// A caller-supplied argument is invalid (the message describes which argument
101    /// and why).
102    InvalidArgument(String),
103    /// Keep-alive detected the connection is dead.
104    HeartbeatTimeout,
105    /// Server terminated the session with a reason string.
106    ForcedLogout(String),
107}
108
109impl RithmicError {
110    /// Returns true when this error reflects a transport/connection-health failure
111    /// rather than a protocol-level rejection.
112    pub fn is_connection_issue(&self) -> bool {
113        matches!(
114            self,
115            Self::ConnectionFailed(_)
116                | Self::ConnectionClosed
117                | Self::SendFailed
118                | Self::HeartbeatTimeout
119                | Self::ForcedLogout(_)
120        )
121    }
122
123    /// Maps this error to the synthetic subscription [`RithmicMessage`] that a
124    /// connection-health broadcast should carry. `HeartbeatTimeout` preserves
125    /// the keep-alive signal; every other variant surfaces as `ConnectionError`.
126    ///
127    /// [`RithmicMessage`]: crate::rti::messages::RithmicMessage
128    pub fn as_connection_message(&self) -> crate::rti::messages::RithmicMessage {
129        match self {
130            Self::HeartbeatTimeout => crate::rti::messages::RithmicMessage::HeartbeatTimeout,
131            _ => crate::rti::messages::RithmicMessage::ConnectionError,
132        }
133    }
134}
135
136impl fmt::Display for RithmicError {
137    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
138        match self {
139            RithmicError::ConnectionFailed(msg) => write!(f, "connection failed: {msg}"),
140            RithmicError::ConnectionClosed => write!(f, "connection closed"),
141            RithmicError::SendFailed => write!(f, "WebSocket send failed or timed out"),
142            RithmicError::EmptyResponse => write!(f, "empty response"),
143            RithmicError::RequestRejected(err) => write!(f, "request rejected: {err}"),
144            RithmicError::ProtocolError(msg) => write!(f, "protocol error: {msg}"),
145            RithmicError::InvalidArgument(msg) => write!(f, "invalid argument: {msg}"),
146            RithmicError::HeartbeatTimeout => write!(f, "heartbeat timeout"),
147            RithmicError::ForcedLogout(reason) => {
148                write!(f, "forced logout: {}", sanitize_for_display(reason))
149            }
150        }
151    }
152}
153
154impl std::error::Error for RithmicError {
155    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
156        match self {
157            RithmicError::RequestRejected(inner) => Some(inner),
158            _ => None,
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use std::error::Error;
166
167    use super::*;
168
169    #[test]
170    fn request_error_display_formats_code_and_message() {
171        let err = RithmicRequestError {
172            rp_code: vec![
173                "1039".to_string(),
174                "FCM Id field is not received.".to_string(),
175            ],
176            code: Some("1039".to_string()),
177            message: Some("FCM Id field is not received.".to_string()),
178        };
179
180        assert_eq!(err.to_string(), "[1039] FCM Id field is not received.");
181    }
182
183    #[test]
184    fn request_error_display_without_code_uses_message_only() {
185        let err = RithmicRequestError {
186            rp_code: vec![],
187            code: None,
188            message: Some("something happened".to_string()),
189        };
190
191        assert_eq!(err.to_string(), "something happened");
192    }
193
194    #[test]
195    fn request_error_display_single_element_omits_trailing_slash() {
196        // rp_code = ["5"] produces code=Some("5"), message=None.
197        // Display renders "[5]" rather than "[5] ".
198        let err = RithmicRequestError {
199            rp_code: vec!["5".to_string()],
200            code: Some("5".to_string()),
201            message: None,
202        };
203
204        assert_eq!(err.to_string(), "[5]");
205    }
206
207    #[test]
208    fn request_error_display_sanitizes_control_chars() {
209        // A malicious or malformed server message must not leak newlines
210        // (log-injection) or ANSI escapes (terminal-control) into `Display`.
211        // The sanitizer strips control characters — the ESC byte of an ANSI
212        // sequence is removed, which breaks the escape and prevents terminal
213        // interpretation (even though the printable `[31m` text remains).
214        let err = RithmicRequestError {
215            rp_code: vec![
216                "3\n".to_string(),
217                "bad\x1b[31mredinjection\r\ndropped".to_string(),
218            ],
219            code: Some("3\n".to_string()),
220            message: Some("bad\x1b[31mredinjection\r\ndropped".to_string()),
221        };
222
223        assert_eq!(err.to_string(), "[3] bad[31mredinjectiondropped");
224    }
225
226    #[test]
227    fn request_error_equality() {
228        let a = RithmicRequestError {
229            rp_code: vec!["3".to_string(), "bad request".to_string()],
230            code: Some("3".to_string()),
231            message: Some("bad request".to_string()),
232        };
233
234        let b = RithmicRequestError {
235            rp_code: vec!["3".to_string(), "bad request".to_string()],
236            code: Some("3".to_string()),
237            message: Some("bad request".to_string()),
238        };
239
240        let c = RithmicRequestError {
241            rp_code: vec!["4".to_string(), "bad request".to_string()],
242            code: Some("4".to_string()),
243            message: Some("bad request".to_string()),
244        };
245
246        assert_eq!(a, b);
247        assert_ne!(a, c);
248    }
249
250    #[test]
251    fn rithmic_error_equality_for_unit_variants() {
252        // `PartialEq` on `RithmicError` lets consumers write
253        // `assert_eq!(result, Err(RithmicError::ConnectionClosed))` in tests.
254        assert_eq!(
255            RithmicError::ConnectionClosed,
256            RithmicError::ConnectionClosed
257        );
258        assert_ne!(RithmicError::ConnectionClosed, RithmicError::SendFailed);
259    }
260
261    #[test]
262    fn rithmic_error_source_chain_exposes_inner_request_error() {
263        // `anyhow`/`eyre` and stdlib chain walkers rely on `source()`.
264
265        let inner = RithmicRequestError {
266            rp_code: vec!["3".to_string(), "bad".to_string()],
267            code: Some("3".to_string()),
268            message: Some("bad".to_string()),
269        };
270
271        let err = RithmicError::RequestRejected(inner.clone());
272        let src = err
273            .source()
274            .expect("source should be Some for RequestRejected");
275
276        assert_eq!(src.to_string(), inner.to_string());
277
278        assert!(
279            RithmicError::ConnectionClosed.source().is_none(),
280            "unit variants should have no source"
281        );
282    }
283
284    #[test]
285    fn plant_rejection_mapping_produces_request_rejected() {
286        // For an rp_code rejection, `response.error` is populated with
287        // `RithmicError::RequestRejected` carrying the full structured payload.
288        let err = RithmicRequestError {
289            rp_code: vec!["3".to_string(), "bad request".to_string()],
290            code: Some("3".to_string()),
291            message: Some("bad request".to_string()),
292        };
293
294        let mapped = RithmicError::RequestRejected(err.clone());
295
296        match mapped {
297            RithmicError::RequestRejected(inner) => {
298                assert_eq!(inner, err);
299                assert_eq!(inner.code.as_deref(), Some("3"));
300                assert_eq!(inner.message.as_deref(), Some("bad request"));
301                assert_eq!(
302                    inner.rp_code,
303                    vec!["3".to_string(), "bad request".to_string()]
304                );
305            }
306            other => panic!("expected RequestRejected, got {other:?}"),
307        }
308
309        // Display for the RithmicError wrapper prefixes "request rejected: "
310        // and delegates to `RithmicRequestError::Display`.
311        let display = RithmicError::RequestRejected(err).to_string();
312
313        assert_eq!(display, "request rejected: [3] bad request");
314    }
315
316    #[test]
317    fn rithmic_error_request_rejected_display_delegates() {
318        let err = RithmicError::RequestRejected(RithmicRequestError {
319            rp_code: vec![
320                "7".to_string(),
321                "an error occurred while parsing data.".to_string(),
322            ],
323            code: Some("7".to_string()),
324            message: Some("an error occurred while parsing data.".to_string()),
325        });
326
327        assert_eq!(
328            err.to_string(),
329            "request rejected: [7] an error occurred while parsing data."
330        );
331    }
332
333    #[test]
334    fn rithmic_error_protocol_error_display() {
335        let err = RithmicError::ProtocolError("decode failed".to_string());
336
337        assert_eq!(err.to_string(), "protocol error: decode failed");
338    }
339
340    #[test]
341    fn heartbeat_timeout_display() {
342        assert_eq!(
343            RithmicError::HeartbeatTimeout.to_string(),
344            "heartbeat timeout"
345        );
346    }
347
348    #[test]
349    fn forced_logout_display() {
350        assert_eq!(
351            RithmicError::ForcedLogout("srv reason".into()).to_string(),
352            "forced logout: srv reason"
353        );
354    }
355
356    #[test]
357    fn forced_logout_sanitizes_control_chars() {
358        let err = RithmicError::ForcedLogout("bad\nreason".into());
359        assert_eq!(err.to_string(), "forced logout: badreason");
360    }
361
362    #[test]
363    fn is_connection_issue_true_for_transport_variants() {
364        assert!(RithmicError::ConnectionFailed("x".into()).is_connection_issue());
365        assert!(RithmicError::ConnectionClosed.is_connection_issue());
366        assert!(RithmicError::SendFailed.is_connection_issue());
367        assert!(RithmicError::HeartbeatTimeout.is_connection_issue());
368        assert!(RithmicError::ForcedLogout("x".into()).is_connection_issue());
369    }
370
371    #[test]
372    fn is_connection_issue_false_for_protocol_variants() {
373        let req = RithmicRequestError {
374            rp_code: vec!["3".into(), "x".into()],
375            code: Some("3".into()),
376            message: Some("x".into()),
377        };
378        assert!(!RithmicError::RequestRejected(req).is_connection_issue());
379        assert!(!RithmicError::ProtocolError("x".into()).is_connection_issue());
380        assert!(!RithmicError::InvalidArgument("x".into()).is_connection_issue());
381        assert!(!RithmicError::EmptyResponse.is_connection_issue());
382    }
383
384    #[test]
385    fn as_connection_message_heartbeat_timeout() {
386        assert!(matches!(
387            RithmicError::HeartbeatTimeout.as_connection_message(),
388            crate::rti::messages::RithmicMessage::HeartbeatTimeout
389        ));
390    }
391
392    #[test]
393    fn as_connection_message_connection_failed() {
394        assert!(matches!(
395            RithmicError::ConnectionFailed("x".into()).as_connection_message(),
396            crate::rti::messages::RithmicMessage::ConnectionError
397        ));
398    }
399}