Skip to main content

rhood_core/
error.rs

1//! Error types for the `rhood-core` crate.
2//!
3//! All fallible operations return [`RhoodError`] through the crate-level
4//! [`Result`](crate::Result) type alias.
5
6use thiserror::Error;
7
8/// Structured diagnostic content extracted from a JSON API error body.
9///
10/// Callers must include a wildcard arm because additional recognized
11/// diagnostics may be added in future releases.
12///
13/// ```compile_fail
14/// use rhood_core::error::ApiErrorDetail;
15///
16/// fn render(detail: ApiErrorDetail) {
17///     match detail {
18///         ApiErrorDetail::MissingInstruments(_) => {}
19///         ApiErrorDetail::Message(_) => {}
20///         ApiErrorDetail::UnusableMissingInstruments => {}
21///     }
22/// }
23/// ```
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum ApiErrorDetail {
27    /// Instrument symbols the API did not recognize.
28    MissingInstruments(Vec<String>),
29    /// A human-readable diagnostic supplied by the API.
30    Message(String),
31    /// The API reported missing instruments without any usable symbol strings.
32    UnusableMissingInstruments,
33}
34
35/// Extracts a recognized diagnostic from a JSON API error body.
36///
37/// Non-empty `missing_instruments` takes precedence, followed by top-level
38/// `message`, top-level `detail`, and nested `error.message`. A present
39/// `missing_instruments` field with no usable symbol strings produces
40/// [`ApiErrorDetail::UnusableMissingInstruments`] only when no human-readable
41/// diagnostic is available. Returns `None` when the body is not JSON or
42/// contains none of those diagnostics.
43pub fn extract_api_error_detail(body: &str) -> Option<ApiErrorDetail> {
44    let parsed = serde_json::from_str::<serde_json::Value>(body).ok()?;
45
46    let missing_instruments = parsed.get("missing_instruments").map(|missing| {
47        missing
48            .as_array()
49            .into_iter()
50            .flatten()
51            .filter_map(serde_json::Value::as_str)
52            .map(str::to_owned)
53            .collect::<Vec<_>>()
54    });
55    let had_missing_instruments = missing_instruments.is_some();
56    if let Some(missing_instruments) = missing_instruments.filter(|missing| !missing.is_empty()) {
57        return Some(ApiErrorDetail::MissingInstruments(missing_instruments));
58    }
59
60    parsed
61        .get("message")
62        .or_else(|| parsed.get("detail"))
63        .and_then(serde_json::Value::as_str)
64        .map(|message| ApiErrorDetail::Message(message.to_owned()))
65        .or_else(|| {
66            parsed
67                .get("error")
68                .and_then(|error| error.get("message"))
69                .and_then(serde_json::Value::as_str)
70                .map(|message| ApiErrorDetail::Message(message.to_owned()))
71        })
72        .or_else(|| had_missing_instruments.then_some(ApiErrorDetail::UnusableMissingInstruments))
73}
74
75/// Formats an API error for display.
76///
77/// If the body is JSON containing an allowlisted diagnostic field, extracts it
78/// for a cleaner user-facing message. Otherwise falls back to the sanitized
79/// diagnostic message.
80fn display_api_error(status: u16, body: &str) -> String {
81    if let Some(detail) = extract_api_error_detail(body) {
82        #[expect(
83            unreachable_patterns,
84            reason = "keeps in-crate rendering forward-compatible with non-exhaustive ApiErrorDetail"
85        )]
86        let message = match detail {
87            ApiErrorDetail::MissingInstruments(symbols) => {
88                format!("unknown symbol(s): {}", symbols.join(", "))
89            }
90            ApiErrorDetail::Message(message) => message,
91            ApiErrorDetail::UnusableMissingInstruments => {
92                "upstream reported unrecognized instruments without usable symbols".to_string()
93            }
94            _ => "upstream returned an unrecognized diagnostic".to_string(),
95        };
96        return format!("API error ({status}): {message}");
97    }
98    format!("API error ({status}): {body}")
99}
100
101/// The type of authentication challenge issued by Robinhood.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum ChallengeType {
104    /// An SMS code was sent to the user's phone.
105    Sms,
106    /// A verification code was sent to the user's email.
107    Email,
108    /// A push notification was sent to the Robinhood mobile app.
109    Prompt,
110}
111
112impl std::fmt::Display for ChallengeType {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Self::Sms => write!(f, "SMS"),
116            Self::Email => write!(f, "email"),
117            Self::Prompt => write!(f, "app prompt"),
118        }
119    }
120}
121
122/// Errors that can occur when interacting with the Robinhood API.
123///
124/// # Example
125///
126/// ```no_run
127/// use rhood_core::{RobinhoodClient, RhoodError};
128///
129/// # async fn run(username: &str, password: &str) -> Result<(), RhoodError> {
130/// let client = RobinhoodClient::new()?;
131/// match client.login(username, password, None).await {
132///     Ok(()) => println!("authenticated"),
133///     Err(RhoodError::ChallengeRequired(challenge_type)) => {
134///         println!("verification needed via {challenge_type}");
135///     }
136///     Err(RhoodError::DeviceVerificationRequired) => {
137///         println!("run `rhood login` interactively first");
138///     }
139///     Err(other) => return Err(other),
140/// }
141/// # Ok(())
142/// # }
143/// ```
144#[derive(Debug, Error)]
145pub enum RhoodError {
146    /// The client is not authenticated and cannot make API calls.
147    #[error("Not authenticated, run `rhood login` first")]
148    NotAuthenticated,
149
150    /// The server issued an authentication challenge that must be answered.
151    #[error("Authentication challenge required: {0}")]
152    ChallengeRequired(ChallengeType),
153
154    /// The access token has expired and automatic refresh failed.
155    #[error("Token expired and refresh failed")]
156    TokenExpired,
157
158    /// The Robinhood API returned a non-success HTTP status.
159    #[error("{}", display_api_error(*.status, message))]
160    Api {
161        /// HTTP status code from the API response.
162        status: u16,
163        /// Sanitized API diagnostic or locally constructed error context.
164        message: String,
165    },
166
167    /// The API returned HTTP 429, indicating the client should back off.
168    #[error("Rate limited, retry after {retry_after_secs}s")]
169    RateLimited {
170        /// Suggested number of seconds to wait before retrying.
171        retry_after_secs: u64,
172    },
173
174    /// The requested ticker symbol was not found.
175    #[error("Symbol not found: {0}")]
176    InvalidSymbol(String),
177
178    /// A parameter provided to an API method was invalid.
179    #[error("Invalid parameter: {0}")]
180    InvalidParameter(String),
181
182    /// A write operation was attempted while the client is in read-only mode.
183    #[error("Operation blocked, client is in read-only mode")]
184    ReadOnlyMode,
185
186    /// An order request contains invalid or contradictory parameters.
187    #[error("Invalid order: {0}")]
188    InvalidOrder(String),
189
190    /// Device verification is required before the client can authenticate.
191    #[error("Device verification required, run `rhood login` interactively first")]
192    DeviceVerificationRequired,
193
194    /// An HTTP transport error from the underlying HTTP client.
195    #[error(transparent)]
196    Http(#[from] reqwest::Error),
197
198    /// A JSON serialization or deserialization error.
199    #[error("JSON error: {0}")]
200    Json(#[from] serde_json::Error),
201
202    /// A filesystem I/O error.
203    #[error("IO error: {0}")]
204    Io(#[from] std::io::Error),
205
206    /// An operation timed out waiting for a response or approval.
207    #[error("Timeout: {0}")]
208    Timeout(String),
209}
210
211#[cfg(test)]
212mod tests {
213    use super::RhoodError;
214
215    #[test]
216    fn rhood_error_display_messages() {
217        let err = RhoodError::NotAuthenticated;
218        assert!(err.to_string().contains("Not authenticated"));
219
220        let err = RhoodError::ReadOnlyMode;
221        assert!(err.to_string().contains("read-only"));
222
223        let err = RhoodError::InvalidSymbol("XYZ".into());
224        assert!(err.to_string().contains("XYZ"));
225
226        let err = RhoodError::RateLimited {
227            retry_after_secs: 30,
228        };
229        assert!(err.to_string().contains("30"));
230
231        let err = RhoodError::Api {
232            status: 404,
233            message: "Not found".into(),
234        };
235        assert!(err.to_string().contains("404"));
236        assert!(err.to_string().contains("Not found"));
237    }
238
239    #[test]
240    fn api_error_display_extracts_json_message() {
241        let err = RhoodError::Api {
242            status: 404,
243            message: r#"{"code":5,"message":"futures contract not found","details":[]}"#.into(),
244        };
245        let display = err.to_string();
246        assert_eq!(display, "API error (404): futures contract not found");
247    }
248
249    #[test]
250    fn api_error_display_extracts_nested_error_message() {
251        let err = RhoodError::Api {
252            status: 400,
253            message: r#"{"status":"FAILURE","error":{"code":3,"message":"invalid argument"}}"#
254                .into(),
255        };
256        let display = err.to_string();
257        assert_eq!(display, "API error (400): invalid argument");
258    }
259
260    #[test]
261    fn api_error_display_extracts_detail_field() {
262        let err = RhoodError::Api {
263            status: 403,
264            message: r#"{"detail":"Permission denied"}"#.into(),
265        };
266        assert_eq!(err.to_string(), "API error (403): Permission denied");
267    }
268
269    #[test]
270    fn api_error_display_extracts_missing_instruments() {
271        let err = RhoodError::Api {
272            status: 404,
273            message: r#"{"missing_instruments":["NOTAREALSYM"]}"#.into(),
274        };
275        assert_eq!(
276            err.to_string(),
277            "API error (404): unknown symbol(s): NOTAREALSYM"
278        );
279    }
280
281    #[test]
282    fn api_error_display_uses_detail_when_missing_instruments_is_empty() {
283        let err = RhoodError::Api {
284            status: 400,
285            message: r#"{"missing_instruments":[],"detail":"insufficient buying power"}"#.into(),
286        };
287        assert_eq!(
288            err.to_string(),
289            "API error (400): insufficient buying power"
290        );
291    }
292
293    #[test]
294    fn api_error_display_sanitizes_unusable_missing_instruments() {
295        for message in [
296            r#"{"missing_instruments":[]}"#,
297            r#"{"missing_instruments":[17,{"symbol":"NOTAREALSYM"}]}"#,
298        ] {
299            let err = RhoodError::Api {
300                status: 400,
301                message: message.into(),
302            };
303            let display = err.to_string();
304            assert_eq!(
305                display,
306                "API error (400): upstream reported unrecognized instruments without usable symbols"
307            );
308            assert!(!display.contains('{'), "raw JSON leaked: {display}");
309        }
310    }
311
312    #[test]
313    fn api_error_display_falls_back_to_raw_body() {
314        let err = RhoodError::Api {
315            status: 500,
316            message: "Internal server error".into(),
317        };
318        assert_eq!(err.to_string(), "API error (500): Internal server error");
319    }
320}