use crate::error::DataError;
use std::time::Duration;
const DISPLAY_URL_BYTES: usize = 512;
fn truncate_for_display(s: &str, max_bytes: usize) -> (&str, &'static str) {
let boundary = s.floor_char_boundary(max_bytes);
(&s[..boundary], if boundary < s.len() { "..." } else { "" })
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum MassiveError {
RateLimited { retry_after: Option<Duration> },
Disconnected { reason: String },
Auth { message: String },
Api { status: u16, message: String },
Http { message: String },
Deserialize { message: String, payload: String },
EnvVar { var: &'static str },
InvalidInput { message: String },
PaginationLimitExceeded { pages: usize, limit: usize },
CyclicPagination { url: String },
PaginationUrlTooLong {
len: usize,
limit: usize,
prefix: String,
},
UntrustedNextUrl {
next_url: String,
expected_origin: String,
},
}
impl std::fmt::Display for MassiveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MassiveError::RateLimited { retry_after } => {
write!(f, "Massive rate limited")?;
if let Some(duration) = retry_after {
write!(f, " (retry after {:?})", duration)?;
}
Ok(())
}
MassiveError::Disconnected { reason } => {
write!(f, "Massive disconnected: {}", reason)
}
MassiveError::Auth { message } => {
write!(f, "Massive auth failed: {}", message)
}
MassiveError::Api { status, message } => {
write!(f, "Massive API error ({}): {}", status, message)
}
MassiveError::Http { message } => {
write!(f, "Massive HTTP error: {}", message)
}
MassiveError::Deserialize { message, payload } => {
let (truncated, ellipsis) = truncate_for_display(payload, 100);
write!(
f,
"Massive deserialize error: {} (payload: {truncated}{ellipsis})",
message
)
}
MassiveError::EnvVar { var } => {
write!(f, "Massive environment variable not set: {}", var)
}
MassiveError::InvalidInput { message } => {
write!(f, "Massive invalid input: {}", message)
}
MassiveError::PaginationLimitExceeded { pages, limit } => {
write!(
f,
"Massive pagination exceeded {limit}-page limit (fetched {pages} pages) — result may be incomplete"
)
}
MassiveError::PaginationUrlTooLong { len, limit, prefix } => {
let ellipsis = if prefix.len() < *len { "..." } else { "" };
write!(
f,
"Massive pagination URL exceeds the {limit}-byte limit ({len} bytes): {prefix}{ellipsis}"
)
}
MassiveError::CyclicPagination { url } => {
let (url, ellipsis) = truncate_for_display(url, DISPLAY_URL_BYTES);
write!(
f,
"Massive pagination cycle detected: next_url revisits {url}{ellipsis}"
)
}
MassiveError::UntrustedNextUrl {
next_url,
expected_origin,
} => {
let (next_url, ellipsis) = truncate_for_display(next_url, DISPLAY_URL_BYTES);
write!(
f,
"Massive rejected next_url outside trusted origin {expected_origin}: {next_url}{ellipsis}"
)
}
}
}
}
impl std::error::Error for MassiveError {}
impl From<MassiveError> for DataError {
fn from(err: MassiveError) -> Self {
DataError::Socket(err.to_string())
}
}
impl From<reqwest::Error> for MassiveError {
fn from(err: reqwest::Error) -> Self {
MassiveError::Http {
message: err.to_string(),
}
}
}
impl From<tokio_tungstenite::tungstenite::Error> for MassiveError {
fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
MassiveError::Disconnected {
reason: err.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn oversized_url() -> String {
format!("https://api.massive.com/v3/x?cursor={}", "a".repeat(4096))
}
#[test]
fn cyclic_pagination_display_is_bounded() {
let url = oversized_url();
let rendered = MassiveError::CyclicPagination { url: url.clone() }.to_string();
assert!(
rendered.len() < url.len(),
"an oversized URL must not reach the log line in full"
);
assert!(rendered.contains("cycle detected"));
assert!(rendered.contains("https://api.massive.com/v3/x?cursor=aaa"));
assert!(rendered.ends_with("..."), "an elided URL is marked");
}
#[test]
fn untrusted_next_url_display_is_bounded_but_stores_the_url_in_full() {
let next_url = oversized_url();
let error = MassiveError::UntrustedNextUrl {
next_url: next_url.clone(),
expected_origin: "https://api.massive.com".to_owned(),
};
let rendered = error.to_string();
assert!(rendered.len() < next_url.len(), "Display must be bounded");
assert!(rendered.contains("https://api.massive.com"));
assert!(rendered.ends_with("..."), "an elided URL is marked");
match error {
MassiveError::UntrustedNextUrl {
next_url: stored, ..
} => {
assert_eq!(stored, next_url)
}
other => panic!("expected UntrustedNextUrl, got {other:?}"),
}
}
#[test]
fn display_of_a_short_url_is_not_truncated() {
let url = "https://api.massive.com/v3/reference/tickers?cursor=abc";
let rendered = MassiveError::CyclicPagination {
url: url.to_owned(),
}
.to_string();
assert!(rendered.ends_with(url), "got: {rendered}");
}
#[test]
fn pagination_url_too_long_display_reports_both_sizes() {
let rendered = MassiveError::PaginationUrlTooLong {
len: 9000,
limit: 8192,
prefix: "https://api.massive.com/v3/x".to_owned(),
}
.to_string();
assert!(rendered.contains("9000"), "got: {rendered}");
assert!(rendered.contains("8192"), "got: {rendered}");
assert!(rendered.contains("https://api.massive.com/v3/x"));
assert!(
rendered.ends_with("..."),
"a prefix shorter than `len` is truncated and must say so, got: {rendered}"
);
}
#[test]
fn pagination_url_too_long_display_omits_the_ellipsis_when_nothing_was_cut() {
let url = "https://api.massive.com/v3/x";
let rendered = MassiveError::PaginationUrlTooLong {
len: url.len(),
limit: 8192,
prefix: url.to_owned(),
}
.to_string();
assert!(rendered.contains(url), "got: {rendered}");
assert!(
!rendered.ends_with("..."),
"an untruncated prefix must not be marked as cut, got: {rendered}"
);
}
#[test]
fn deserialize_display_truncates_its_payload_snippet() {
let rendered = MassiveError::Deserialize {
message: "expected `,`".to_owned(),
payload: "x".repeat(500),
}
.to_string();
assert!(rendered.contains("expected `,`"));
assert!(rendered.contains("..."), "an elided payload is marked");
assert!(rendered.len() < 500);
}
#[test]
fn display_covers_every_variant_without_panicking() {
let multibyte = "€".repeat(400);
let variants = [
MassiveError::RateLimited {
retry_after: Some(Duration::from_secs(5)),
},
MassiveError::RateLimited { retry_after: None },
MassiveError::Disconnected {
reason: multibyte.clone(),
},
MassiveError::Auth {
message: multibyte.clone(),
},
MassiveError::Api {
status: 500,
message: multibyte.clone(),
},
MassiveError::Http {
message: multibyte.clone(),
},
MassiveError::Deserialize {
message: "bad".to_owned(),
payload: multibyte.clone(),
},
MassiveError::EnvVar { var: "MASSIVE_KEY" },
MassiveError::InvalidInput {
message: multibyte.clone(),
},
MassiveError::PaginationLimitExceeded {
pages: 10_001,
limit: 10_000,
},
MassiveError::CyclicPagination {
url: multibyte.clone(),
},
MassiveError::PaginationUrlTooLong {
len: 9000,
limit: 8192,
prefix: multibyte.clone(),
},
MassiveError::UntrustedNextUrl {
next_url: multibyte.clone(),
expected_origin: "https://api.massive.com".to_owned(),
},
];
for variant in variants {
assert!(
!variant.to_string().is_empty(),
"every variant renders something: {variant:?}"
);
}
}
}