use bytes::Bytes;
use http::StatusCode;
use thiserror::Error;
#[derive(Debug, Error, Clone)]
#[must_use = "errors must be handled or propagated with `?`"]
pub enum Error {
#[error("invalid base URL: {0}")]
InvalidBaseUrl(#[from] url::ParseError),
#[error("transport error: {0}")]
Transport(String),
#[error("HTTP {status} {status_text}: {message}")]
Http {
status: StatusCode,
status_text: String,
message: String,
body: Option<Bytes>,
},
#[cfg(feature = "json")]
#[error("failed to deserialize response body: {message}")]
Deserialize {
status: StatusCode,
message: String,
body: Option<Bytes>,
},
#[cfg(feature = "validate")]
#[error("response validation failed: {message}")]
Validation {
status: StatusCode,
message: String,
body: Option<Bytes>,
},
#[error("request timed out")]
Timeout,
#[error("retries exhausted after {attempts} attempts")]
RetryExhausted { attempts: u32, last: Option<String> },
#[error("hook error: {0}")]
Hook(String),
#[error("{0}")]
Other(String),
}
impl Error {
pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
Self::http_with_status_text(
status,
status.canonical_reason().unwrap_or("").to_string(),
message,
body,
)
}
pub fn http_with_status_text(
status: StatusCode,
status_text: impl Into<String>,
message: impl Into<String>,
body: Option<Bytes>,
) -> Self {
Self::Http {
status,
status_text: status_text.into(),
message: message.into(),
body,
}
}
pub fn status(&self) -> Option<StatusCode> {
match self {
Self::Http { status, .. } => Some(*status),
#[cfg(feature = "json")]
Self::Deserialize { status, .. } => Some(*status),
#[cfg(feature = "validate")]
Self::Validation { status, .. } => Some(*status),
_ => None,
}
}
pub fn status_text(&self) -> Option<&str> {
match self {
Self::Http { status_text, .. } => Some(status_text),
_ => None,
}
}
pub fn body(&self) -> Option<&Bytes> {
match self {
Self::Http { body, .. } => body.as_ref(),
#[cfg(feature = "json")]
Self::Deserialize { body, .. } => body.as_ref(),
#[cfg(feature = "validate")]
Self::Validation { body, .. } => body.as_ref(),
_ => None,
}
}
pub fn is_retry_exhausted(&self) -> bool {
matches!(self, Self::RetryExhausted { .. })
}
pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
Self::RetryExhausted {
attempts,
last: Some(last.to_string()),
}
}
#[cfg(feature = "json")]
pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
let body = self.body()?;
serde_json::from_slice(body).ok()
}
#[cfg(feature = "validate")]
pub fn api_json_validated<T>(&self) -> Option<T>
where
T: serde::de::DeserializeOwned + garde::Validate,
T::Context: Default,
{
let body = self.body()?;
let value: T = serde_json::from_slice(body).ok()?;
value.validate().ok()?;
Some(value)
}
}
pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
if err.is_timeout() {
Error::Timeout
} else {
Error::Transport(err.to_string())
}
}
#[cfg(all(test, feature = "json"))]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct ApiError {
message: String,
}
#[test]
fn api_json_parses_http_body() {
let err = Error::http_with_status_text(
StatusCode::BAD_REQUEST,
"Bad Request",
"bad request",
Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
);
let api: ApiError = err.api_json().unwrap();
assert_eq!(api.message, "invalid");
}
#[test]
fn status_and_status_text_accessors() {
let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
assert_eq!(err.status_text(), Some("Not Found"));
}
#[test]
fn api_json_returns_none_without_body() {
let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
assert!(err.api_json::<ApiError>().is_none());
}
#[test]
fn retry_exhausted_helper_sets_flag() {
let err = Error::retry_exhausted(3, Error::Timeout);
assert!(err.is_retry_exhausted());
assert!(matches!(
err,
Error::RetryExhausted {
attempts: 3,
last: Some(_)
}
));
}
}