use std::time::Duration;
use serde::Deserialize;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("fyuz api error (HTTP {status}): {message}")]
Api {
status: u16,
message: String,
},
#[error("fyuz api rate limited (HTTP 429): {message}")]
RateLimited {
message: String,
retry_after: Option<Duration>,
},
#[error("http transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("could not decode response body: {message} (body began: {body})")]
Decode {
message: String,
body: String,
},
#[error("invalid base url {url:?}: {message}")]
InvalidBaseUrl {
url: String,
message: String,
},
#[error("{message}")]
InvalidArgument {
message: String,
},
#[error("{message}")]
ContractRevert {
kind: crate::revert::RevertKind,
message: String,
error_name: Option<String>,
selector: Option<String>,
data: String,
retryable: bool,
token_address: Option<String>,
},
#[error("RPC {method} failed: {message}")]
Rpc {
method: String,
message: String,
code: Option<i64>,
data: Option<serde_json::Value>,
},
#[error("RPC {method} could not reach the RPC endpoint: {source}")]
RpcTransport {
method: String,
#[source]
source: reqwest::Error,
},
}
impl Error {
pub fn status(&self) -> Option<u16> {
match self {
Error::Api { status, .. } => Some(*status),
Error::RateLimited { .. } => Some(429),
Error::Transport(e) | Error::RpcTransport { source: e, .. } => {
e.status().map(|s| s.as_u16())
}
_ => None,
}
}
pub fn is_rate_limited(&self) -> bool {
matches!(self, Error::RateLimited { .. })
}
pub fn is_not_found(&self) -> bool {
self.status() == Some(404)
}
pub fn is_graduated(&self) -> bool {
matches!(
self,
Error::ContractRevert {
kind: crate::revert::RevertKind::Graduated,
..
}
)
}
pub fn is_retryable_revert(&self) -> bool {
matches!(
self,
Error::ContractRevert {
retryable: true,
..
}
)
}
pub fn is_timeout(&self) -> bool {
matches!(
self,
Error::Transport(e) | Error::RpcTransport { source: e, .. } if e.is_timeout()
)
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
Error::RateLimited { retry_after, .. } => *retry_after,
_ => None,
}
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorEnvelope {
pub(crate) error: Option<String>,
}
pub(crate) fn message_from_body(body: &[u8], status: reqwest::StatusCode) -> String {
if let Ok(envelope) = serde_json::from_slice::<ErrorEnvelope>(body) {
if let Some(message) = envelope.error {
if !message.is_empty() {
return message;
}
}
}
let raw = String::from_utf8_lossy(body);
let trimmed = raw.trim();
if trimmed.is_empty() {
status
.canonical_reason()
.unwrap_or("unknown error")
.to_string()
} else {
truncate(trimmed, 256)
}
}
pub(crate) fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let mut out: String = text.chars().take(max).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::StatusCode;
#[test]
fn parses_the_error_envelope() {
let body = br#"{"error":"Too many requests"}"#;
assert_eq!(
message_from_body(body, StatusCode::TOO_MANY_REQUESTS),
"Too many requests"
);
}
#[test]
fn falls_back_to_the_raw_body() {
let body = b"upstream exploded";
assert_eq!(
message_from_body(body, StatusCode::BAD_GATEWAY),
"upstream exploded"
);
}
#[test]
fn falls_back_to_the_status_reason_for_empty_bodies() {
assert_eq!(message_from_body(b"", StatusCode::NOT_FOUND), "Not Found");
}
#[test]
fn classifies_statuses() {
let err = Error::Api {
status: 404,
message: "nope".into(),
};
assert!(err.is_not_found());
assert!(!err.is_rate_limited());
let limited = Error::RateLimited {
message: "Too many requests".into(),
retry_after: Some(Duration::from_secs(2)),
};
assert!(limited.is_rate_limited());
assert_eq!(limited.status(), Some(429));
assert_eq!(limited.retry_after(), Some(Duration::from_secs(2)));
}
}