use std::fmt;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(transparent)]
Api(#[from] ApiError),
#[error("http transport error")]
Transport(#[source] TransportError),
#[error("failed to decode response from {path}: {source}")]
Decode {
path: String,
body: String,
#[source]
source: serde_json::Error,
},
#[error("invalid credentials: {0}")]
Credentials(String),
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("stream error: {0}")]
Stream(String),
#[error("invalid url: {0}")]
InvalidUrl(String),
#[error("giving up after {attempts} attempts")]
RetriesExhausted {
attempts: u32,
#[source]
last: ApiError,
},
}
impl Error {
#[cfg(feature = "data")]
pub(crate) fn decode_shape(path: &str, body: &str, reason: impl fmt::Display) -> Self {
use serde::de::Error as _;
Self::Decode {
path: path.to_owned(),
body: crate::rest::truncate(body),
source: serde_json::Error::custom(reason),
}
}
#[must_use]
pub fn status(&self) -> Option<u16> {
match self {
Self::Api(e) => Some(e.status),
Self::RetriesExhausted { last, .. } => Some(last.status),
_ => None,
}
}
#[must_use]
pub fn is_transient(&self) -> bool {
match self {
Self::Api(e) => e.is_retried_by_default(),
Self::Transport(e) => e.is_timeout() || e.is_connect(),
_ => false,
}
}
}
impl Error {
pub(crate) fn transport(error: reqwest::Error) -> Self {
Self::Transport(TransportError(error))
}
}
#[derive(Debug)]
pub struct TransportError(reqwest::Error);
impl TransportError {
#[must_use]
pub fn is_timeout(&self) -> bool {
self.0.is_timeout()
}
#[must_use]
pub fn is_connect(&self) -> bool {
self.0.is_connect()
}
#[must_use]
pub fn is_body(&self) -> bool {
self.0.is_body()
}
#[must_use]
pub fn is_decode(&self) -> bool {
self.0.is_decode()
}
#[must_use]
pub fn status(&self) -> Option<u16> {
self.0.status().map(|status| status.as_u16())
}
#[must_use]
pub fn url(&self) -> Option<&str> {
self.0.url().map(url::Url::as_str)
}
}
impl fmt::Display for TransportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for TransportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
std::error::Error::source(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ApiError {
pub status: u16,
pub code: Option<i64>,
pub message: String,
pub body: String,
pub path: String,
}
impl ApiError {
#[must_use]
pub fn from_body(status: u16, path: impl Into<String>, body: impl Into<String>) -> Self {
#[derive(serde::Deserialize)]
struct Payload {
code: Option<i64>,
message: Option<String>,
}
let body = body.into();
let parsed = serde_json::from_str::<Payload>(&body).ok();
let (code, message) = match parsed {
Some(p) => (p.code, p.message),
None => (None, None),
};
Self {
status,
code,
message: message.unwrap_or_else(|| body.clone()),
body,
path: path.into(),
}
}
#[must_use]
pub fn is_retried_by_default(&self) -> bool {
crate::config::DEFAULT_RETRY_STATUS_CODES.contains(&self.status)
}
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "alpaca api error {} on {}", self.status, self.path)?;
if let Some(code) = self.code {
write!(f, " (code {code})")?;
}
write!(f, ": {}", self.message)
}
}
impl std::error::Error for ApiError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_code_and_message_from_json_body() {
let err = ApiError::from_body(
403,
"/v2/orders",
r#"{"code":40310000,"message":"insufficient buying power"}"#.to_owned(),
);
assert_eq!(err.status, 403);
assert_eq!(err.code, Some(40_310_000));
assert_eq!(err.message, "insufficient buying power");
}
#[test]
fn non_json_body_degrades_instead_of_panicking() {
let err = ApiError::from_body(502, "/v2/account", "<html>bad gateway</html>".to_owned());
assert_eq!(err.code, None);
assert_eq!(err.message, "<html>bad gateway</html>");
assert_eq!(err.body, "<html>bad gateway</html>");
}
#[test]
fn only_429_and_504_are_retryable() {
for status in [429, 504] {
assert!(
ApiError::from_body(status, "/v2/account", String::new()).is_retried_by_default()
);
}
for status in [400, 401, 403, 404, 500] {
assert!(
!ApiError::from_body(status, "/v2/account", String::new()).is_retried_by_default()
);
}
}
}