use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApiErrorDetail {
MissingInstruments(Vec<String>),
Message(String),
UnusableMissingInstruments,
}
pub fn extract_api_error_detail(body: &str) -> Option<ApiErrorDetail> {
let parsed = serde_json::from_str::<serde_json::Value>(body).ok()?;
let missing_instruments = parsed.get("missing_instruments").map(|missing| {
missing
.as_array()
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>()
});
let had_missing_instruments = missing_instruments.is_some();
if let Some(missing_instruments) = missing_instruments.filter(|missing| !missing.is_empty()) {
return Some(ApiErrorDetail::MissingInstruments(missing_instruments));
}
parsed
.get("message")
.or_else(|| parsed.get("detail"))
.and_then(serde_json::Value::as_str)
.map(|message| ApiErrorDetail::Message(message.to_owned()))
.or_else(|| {
parsed
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.map(|message| ApiErrorDetail::Message(message.to_owned()))
})
.or_else(|| had_missing_instruments.then_some(ApiErrorDetail::UnusableMissingInstruments))
}
fn display_api_error(status: u16, body: &str) -> String {
if let Some(detail) = extract_api_error_detail(body) {
#[expect(
unreachable_patterns,
reason = "keeps in-crate rendering forward-compatible with non-exhaustive ApiErrorDetail"
)]
let message = match detail {
ApiErrorDetail::MissingInstruments(symbols) => {
format!("unknown symbol(s): {}", symbols.join(", "))
}
ApiErrorDetail::Message(message) => message,
ApiErrorDetail::UnusableMissingInstruments => {
"upstream reported unrecognized instruments without usable symbols".to_string()
}
_ => "upstream returned an unrecognized diagnostic".to_string(),
};
return format!("API error ({status}): {message}");
}
format!("API error ({status}): {body}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChallengeType {
Sms,
Email,
Prompt,
}
impl std::fmt::Display for ChallengeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Sms => write!(f, "SMS"),
Self::Email => write!(f, "email"),
Self::Prompt => write!(f, "app prompt"),
}
}
}
#[derive(Debug, Error)]
pub enum RhoodError {
#[error("Not authenticated, run `rhood login` first")]
NotAuthenticated,
#[error("Authentication challenge required: {0}")]
ChallengeRequired(ChallengeType),
#[error("Token expired and refresh failed")]
TokenExpired,
#[error("{}", display_api_error(*.status, message))]
Api {
status: u16,
message: String,
},
#[error("Rate limited, retry after {retry_after_secs}s")]
RateLimited {
retry_after_secs: u64,
},
#[error("Symbol not found: {0}")]
InvalidSymbol(String),
#[error("Invalid parameter: {0}")]
InvalidParameter(String),
#[error("Operation blocked, client is in read-only mode")]
ReadOnlyMode,
#[error("Invalid order: {0}")]
InvalidOrder(String),
#[error("Device verification required, run `rhood login` interactively first")]
DeviceVerificationRequired,
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Timeout: {0}")]
Timeout(String),
}
#[cfg(test)]
mod tests {
use super::RhoodError;
#[test]
fn rhood_error_display_messages() {
let err = RhoodError::NotAuthenticated;
assert!(err.to_string().contains("Not authenticated"));
let err = RhoodError::ReadOnlyMode;
assert!(err.to_string().contains("read-only"));
let err = RhoodError::InvalidSymbol("XYZ".into());
assert!(err.to_string().contains("XYZ"));
let err = RhoodError::RateLimited {
retry_after_secs: 30,
};
assert!(err.to_string().contains("30"));
let err = RhoodError::Api {
status: 404,
message: "Not found".into(),
};
assert!(err.to_string().contains("404"));
assert!(err.to_string().contains("Not found"));
}
#[test]
fn api_error_display_extracts_json_message() {
let err = RhoodError::Api {
status: 404,
message: r#"{"code":5,"message":"futures contract not found","details":[]}"#.into(),
};
let display = err.to_string();
assert_eq!(display, "API error (404): futures contract not found");
}
#[test]
fn api_error_display_extracts_nested_error_message() {
let err = RhoodError::Api {
status: 400,
message: r#"{"status":"FAILURE","error":{"code":3,"message":"invalid argument"}}"#
.into(),
};
let display = err.to_string();
assert_eq!(display, "API error (400): invalid argument");
}
#[test]
fn api_error_display_extracts_detail_field() {
let err = RhoodError::Api {
status: 403,
message: r#"{"detail":"Permission denied"}"#.into(),
};
assert_eq!(err.to_string(), "API error (403): Permission denied");
}
#[test]
fn api_error_display_extracts_missing_instruments() {
let err = RhoodError::Api {
status: 404,
message: r#"{"missing_instruments":["NOTAREALSYM"]}"#.into(),
};
assert_eq!(
err.to_string(),
"API error (404): unknown symbol(s): NOTAREALSYM"
);
}
#[test]
fn api_error_display_uses_detail_when_missing_instruments_is_empty() {
let err = RhoodError::Api {
status: 400,
message: r#"{"missing_instruments":[],"detail":"insufficient buying power"}"#.into(),
};
assert_eq!(
err.to_string(),
"API error (400): insufficient buying power"
);
}
#[test]
fn api_error_display_sanitizes_unusable_missing_instruments() {
for message in [
r#"{"missing_instruments":[]}"#,
r#"{"missing_instruments":[17,{"symbol":"NOTAREALSYM"}]}"#,
] {
let err = RhoodError::Api {
status: 400,
message: message.into(),
};
let display = err.to_string();
assert_eq!(
display,
"API error (400): upstream reported unrecognized instruments without usable symbols"
);
assert!(!display.contains('{'), "raw JSON leaked: {display}");
}
}
#[test]
fn api_error_display_falls_back_to_raw_body() {
let err = RhoodError::Api {
status: 500,
message: "Internal server error".into(),
};
assert_eq!(err.to_string(), "API error (500): Internal server error");
}
}