use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
type DynError = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
#[error("invalid base URL: {0}")]
InvalidBaseUrl(String),
#[error("URL is not a base: {url}")]
UrlNotABase {
url: String,
},
#[error("http transport error: {0}")]
Http(#[from] reqwest::Error),
#[error("upstream {endpoint} returned {status}")]
UpstreamStatus {
endpoint: &'static str,
status: u16,
body_preview: Option<String>,
},
#[error("unknown response variant from {endpoint}")]
Unknown {
endpoint: &'static str,
},
#[error("decode error from {endpoint} (status {status}): {message}")]
Decode {
endpoint: String,
status: u16,
message: String,
},
#[error("bad request: {0}")]
BadRequest(String),
#[error("missing query parameter: {name}")]
MissingQuery {
name: &'static str,
},
#[error("invalid {name} header value: {source}")]
Header {
name: &'static str,
#[source]
source: reqwest::header::InvalidHeaderValue,
},
#[error("api error: {0}")]
Api(#[source] DynError),
#[error("no contracts for symbol '{symbol}'")]
SymbolNotFound {
symbol: String,
},
#[error("invalid conid '{raw}' for symbol '{symbol}': {source}")]
BadConid {
symbol: String,
raw: String,
#[source]
source: std::num::ParseIntError,
},
#[error("ws handshake to {url}: {source}")]
WsHandshake {
url: String,
#[source]
source: tokio_tungstenite::tungstenite::Error,
},
#[error("ws transport: {source}")]
WsTransport {
#[source]
source: tokio_tungstenite::tungstenite::Error,
},
#[error("ws protocol: {0}")]
WsProtocol(String),
#[error("response build: {0}")]
ResponseBuild(String),
#[error("gateway is not authenticated — log in at the Gateway URL first")]
NotAuthenticated,
#[error("gateway has no active session")]
NoSession,
#[error("{0}")]
Other(String),
}
impl Error {
pub fn other(msg: impl Into<String>) -> Self {
Self::Other(msg.into())
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
Self::UpstreamStatus { status, .. } => *status >= 500 || *status == 429,
Self::NoSession => true,
Self::WsTransport { .. } | Self::WsHandshake { .. } => true,
_ => false,
}
}
}
impl From<anyhow::Error> for Error {
fn from(e: anyhow::Error) -> Self {
Error::Api(e.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_is_send_sync() {
const fn assert<T: Send + Sync>() {}
assert::<Error>();
}
#[test]
fn is_retryable_flags_transient_errors() {
assert!(Error::NoSession.is_retryable());
assert!(Error::UpstreamStatus {
endpoint: "x",
status: 500,
body_preview: None
}
.is_retryable());
assert!(Error::UpstreamStatus {
endpoint: "x",
status: 503,
body_preview: None
}
.is_retryable());
assert!(Error::UpstreamStatus {
endpoint: "x",
status: 429,
body_preview: None
}
.is_retryable());
}
#[test]
fn is_retryable_rejects_caller_errors() {
assert!(!Error::NotAuthenticated.is_retryable());
assert!(!Error::BadRequest("bad".into()).is_retryable());
assert!(!Error::MissingQuery { name: "x" }.is_retryable());
assert!(!Error::InvalidBaseUrl("nope".into()).is_retryable());
assert!(!Error::UrlNotABase {
url: "data:".into()
}
.is_retryable());
assert!(!Error::SymbolNotFound {
symbol: "ZZZ".into()
}
.is_retryable());
assert!(!Error::UpstreamStatus {
endpoint: "x",
status: 401,
body_preview: None
}
.is_retryable());
assert!(!Error::UpstreamStatus {
endpoint: "x",
status: 404,
body_preview: None
}
.is_retryable());
assert!(!Error::Other("mystery".into()).is_retryable());
}
#[test]
fn display_includes_endpoint_and_status() {
let e = Error::UpstreamStatus {
endpoint: "iserver/auth/status",
status: 503,
body_preview: None,
};
let s = e.to_string();
assert!(s.contains("iserver/auth/status"), "got: {s}");
assert!(s.contains("503"), "got: {s}");
}
#[test]
fn display_symbol_not_found_carries_symbol() {
let e = Error::SymbolNotFound {
symbol: "PLTRX".into(),
};
let s = e.to_string();
assert!(s.contains("PLTRX"), "got: {s}");
}
}