use thiserror::Error;
pub mod openapi_to_rust_problem {
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
pub code: String,
#[serde(default)]
pub errors: Vec<InvalidParameter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct InvalidParameter {
pub code: String,
pub location: String,
pub message: String,
}
}
#[derive(Error, Debug)]
pub enum HttpError {
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Failed to serialize request: {0}")]
Serialization(String),
#[error("Failed to deserialize response: {0}")]
Deserialization(String),
#[error("HTTP error {status}: {message}")]
Http {
status: u16,
message: String,
body: Option<String>,
},
#[error("Authentication error: {0}")]
Auth(String),
#[error("Request timeout")]
Timeout,
#[error("Configuration error: {0}")]
Config(String),
#[error("{0}")]
Other(String),
}
impl HttpError {
pub fn from_status(status: u16, message: impl Into<String>, body: Option<String>) -> Self {
Self::Http {
status,
message: message.into(),
body,
}
}
pub fn serialization_error(error: impl std::fmt::Display) -> Self {
Self::Serialization(error.to_string())
}
pub fn deserialization_error(error: impl std::fmt::Display) -> Self {
Self::Deserialization(error.to_string())
}
pub fn is_client_error(&self) -> bool {
matches!(self, Self::Http { status, .. } if *status >= 400 && *status < 500)
}
pub fn is_server_error(&self) -> bool {
matches!(self, Self::Http { status, .. } if *status >= 500 && *status < 600)
}
pub fn is_retryable(&self) -> bool {
match self {
Self::Network(_) => true,
Self::Timeout => true,
Self::Http { status, .. } => {
matches!(status, 429 | 500 | 502 | 503 | 504)
}
_ => false,
}
}
}
pub type HttpResult<T> = Result<T, HttpError>;
#[derive(Debug, Clone)]
pub struct ApiError<E> {
pub status: u16,
pub headers: reqwest::header::HeaderMap,
pub body: String,
pub typed: Option<E>,
pub parse_error: Option<String>,
}
const API_ERROR_BODY_DISPLAY_LIMIT: usize = 500;
const API_ERROR_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
fn display_api_error_body(body: &str) -> std::borrow::Cow<'_, str> {
let Some((end, _)) = body.char_indices().nth(API_ERROR_BODY_DISPLAY_LIMIT) else {
return std::borrow::Cow::Borrowed(body);
};
let mut displayed = String::with_capacity(end + API_ERROR_BODY_TRUNCATION_MARKER.len());
displayed.push_str(&body[..end]);
displayed.push_str(API_ERROR_BODY_TRUNCATION_MARKER);
std::borrow::Cow::Owned(displayed)
}
impl<E> ApiError<E> {
pub fn is_client_error(&self) -> bool {
(400..500).contains(&self.status)
}
pub fn is_server_error(&self) -> bool {
(500..600).contains(&self.status)
}
pub fn problem_details(&self) -> Option<openapi_to_rust_problem::ProblemDetails> {
let content_type = self
.headers
.get(reqwest::header::CONTENT_TYPE)?
.to_str()
.ok()?;
let media_type = content_type.split(';').next()?.trim();
if !media_type.eq_ignore_ascii_case("application/problem+json") {
return None;
}
serde_json::from_str(&self.body).ok()
}
}
impl<E: std::fmt::Debug> std::fmt::Display for ApiError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"API error {}: {}",
self.status,
display_api_error_body(&self.body)
)?;
if let Some(typed) = &self.typed {
write!(f, "; typed: {typed:?}")?;
}
if let Some(parse_error) = &self.parse_error {
write!(f, "; parse error: {parse_error}")?;
}
Ok(())
}
}
impl<E: std::fmt::Debug> std::error::Error for ApiError<E> {}
#[derive(Debug, thiserror::Error)]
pub enum ApiOpError<E: std::fmt::Debug> {
#[error(transparent)]
Transport(#[from] HttpError),
#[error(transparent)]
Api(ApiError<E>),
}
impl<E: std::fmt::Debug> ApiOpError<E> {
pub fn api(&self) -> Option<&ApiError<E>> {
match self {
Self::Api(e) => Some(e),
Self::Transport(_) => None,
}
}
}