1use thiserror::Error;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum ApiErrorDetail {
27 MissingInstruments(Vec<String>),
29 Message(String),
31 UnusableMissingInstruments,
33}
34
35pub 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
75fn 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#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum ChallengeType {
104 Sms,
106 Email,
108 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#[derive(Debug, Error)]
145pub enum RhoodError {
146 #[error("Not authenticated, run `rhood login` first")]
148 NotAuthenticated,
149
150 #[error("Authentication challenge required: {0}")]
152 ChallengeRequired(ChallengeType),
153
154 #[error("Token expired and refresh failed")]
156 TokenExpired,
157
158 #[error("{}", display_api_error(*.status, message))]
160 Api {
161 status: u16,
163 message: String,
165 },
166
167 #[error("Rate limited, retry after {retry_after_secs}s")]
169 RateLimited {
170 retry_after_secs: u64,
172 },
173
174 #[error("Symbol not found: {0}")]
176 InvalidSymbol(String),
177
178 #[error("Invalid parameter: {0}")]
180 InvalidParameter(String),
181
182 #[error("Operation blocked, client is in read-only mode")]
184 ReadOnlyMode,
185
186 #[error("Invalid order: {0}")]
188 InvalidOrder(String),
189
190 #[error("Device verification required, run `rhood login` interactively first")]
192 DeviceVerificationRequired,
193
194 #[error(transparent)]
196 Http(#[from] reqwest::Error),
197
198 #[error("JSON error: {0}")]
200 Json(#[from] serde_json::Error),
201
202 #[error("IO error: {0}")]
204 Io(#[from] std::io::Error),
205
206 #[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}