use reqwest::{Error as ReqwestError, Url};
use thiserror::Error;
pub type NetResult<T> = Result<T, NetError>;
#[derive(Debug, Error, Clone)]
pub enum NetError {
#[error("HTTP request failed: {0}")]
Http(String),
#[error("Timeout")]
Timeout,
#[error("Request failed after {max_retries} retries: {source}")]
RetryExhausted { max_retries: u32, source: Box<Self> },
#[error("HTTP {status}: {body:?} for URL: {url:?}")]
HttpError {
status: u16,
url: Url,
body: Option<String>,
},
#[error("not implemented")]
Unimplemented,
#[error("Cancelled")]
Cancelled,
#[error("Invalid content-type: {0}")]
InvalidContentType(String),
}
impl NetError {
const HTTP_REQUEST_TIMEOUT: u16 = 408;
const HTTP_SERVER_ERROR_MIN: u16 = 500;
const HTTP_TOO_MANY_REQUESTS: u16 = 429;
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(http_err_str) => {
http_err_str.contains("500")
|| http_err_str.contains("502")
|| http_err_str.contains("503")
|| http_err_str.contains("504")
|| http_err_str.contains("429")
|| http_err_str.contains("408")
|| http_err_str.contains("timeout")
|| http_err_str.contains("connection")
|| http_err_str.contains("network")
|| http_err_str.contains("decoding")
|| http_err_str.contains("body")
}
Self::Timeout => true,
Self::HttpError { status, .. } => {
*status >= Self::HTTP_SERVER_ERROR_MIN
|| *status == Self::HTTP_TOO_MANY_REQUESTS
|| *status == Self::HTTP_REQUEST_TIMEOUT
}
Self::RetryExhausted { .. }
| Self::Unimplemented
| Self::Cancelled
| Self::InvalidContentType(_) => false,
}
}
#[must_use]
pub fn timeout() -> Self {
Self::Timeout
}
}
impl From<ReqwestError> for NetError {
fn from(e: ReqwestError) -> Self {
if e.is_timeout() {
return Self::Timeout;
}
let mut msg = e.to_string();
let mut current: &dyn std::error::Error = &e;
while let Some(source) = current.source() {
msg += &format!(": {source}");
current = source;
}
Self::Http(msg)
}
}
#[cfg(test)]
mod tests {
mod kithara {
pub(crate) use kithara_test_macros::test;
}
use super::*;
fn test_url(raw: &str) -> Url {
Url::parse(raw).expect("BUG: hard-coded test URL is valid")
}
#[kithara::test(tokio)]
#[case::timeout_error(NetError::timeout(), NetError::Timeout)]
async fn test_error_creation_methods(
#[case] created_error: NetError,
#[case] expected_error: NetError,
) {
match (created_error, expected_error) {
(NetError::Timeout, NetError::Timeout) => (),
_ => panic!("Errors don't match"),
}
}
#[kithara::test(tokio)]
#[case::timeout(NetError::Timeout, true)]
#[case::http_500(NetError::HttpError { status: 500, url: test_url("http://example.com"), body: None }, true)]
#[case::http_429(NetError::HttpError { status: 429, url: test_url("http://example.com"), body: None }, true)]
#[case::http_404(NetError::HttpError { status: 404, url: test_url("http://example.com"), body: None }, false)]
#[case::unimplemented(NetError::Unimplemented, false)]
#[case::retry_exhausted(NetError::RetryExhausted { max_retries: 3, source: Box::new(NetError::Timeout) }, false)]
#[case::invalid_content_type(NetError::InvalidContentType("text/html".to_string()), false)]
async fn test_is_retryable(#[case] error: NetError, #[case] expected_retryable: bool) {
assert_eq!(error.is_retryable(), expected_retryable);
}
#[kithara::test(tokio)]
#[case::http_error(
NetError::Http("connection failed".to_string()),
"HTTP request failed: connection failed"
)]
#[case::timeout(NetError::Timeout, "Timeout")]
#[case::unimplemented(NetError::Unimplemented, "not implemented")]
#[case::http_error_with_details(
NetError::HttpError { status: 404, url: test_url("http://example.com/test"), body: Some("Not found".to_string()) },
"HTTP 404: Some(\"Not found\") for URL: Url { scheme: \"http\", cannot_be_a_base: false, username: \"\", password: None, host: Some(Domain(\"example.com\")), port: None, path: \"/test\", query: None, fragment: None }"
)]
async fn test_error_display(#[case] error: NetError, #[case] expected_prefix: &str) {
let display = error.to_string();
assert!(
display.starts_with(expected_prefix),
"Expected display to start with '{}', got '{}'",
expected_prefix,
display
);
}
#[kithara::test(tokio)]
async fn test_retry_exhausted_display() {
let source = Box::new(NetError::Timeout);
let error = NetError::RetryExhausted {
source,
max_retries: 3,
};
let display = error.to_string();
assert!(display.contains("Request failed after 3 retries: Timeout"));
}
#[kithara::test(tokio)]
#[case::timeout(NetError::Timeout)]
#[case::http_error(NetError::HttpError { status: 500, url: test_url("http://example.com"), body: None })]
#[case::unimplemented(NetError::Unimplemented)]
#[case::retry_exhausted(NetError::RetryExhausted { max_retries: 3, source: Box::new(NetError::Timeout) })]
async fn test_error_cloning(#[case] error: NetError) {
let cloned = error.clone();
assert_eq!(error.to_string(), cloned.to_string());
assert_eq!(error.is_retryable(), cloned.is_retryable());
}
#[kithara::test(tokio)]
#[case::timeout(NetError::Timeout)]
#[case::http_error(NetError::HttpError { status: 404, url: test_url("http://example.com"), body: None })]
async fn test_error_debug(#[case] error: NetError) {
let debug_output = format!("{:?}", error);
match error {
NetError::Timeout => assert!(debug_output.contains("Timeout")),
NetError::HttpError { .. } => assert!(debug_output.contains("HttpError")),
_ => (),
}
}
#[kithara::test(tokio)]
async fn test_net_result_type() {
let ok_result: NetResult<i32> = Ok(42);
assert!(ok_result.is_ok());
assert!(matches!(ok_result, Ok(42)));
let err_result: NetResult<i32> = Err(NetError::Timeout);
assert!(err_result.is_err());
match err_result {
Err(NetError::Timeout) => (),
_ => panic!("Expected Timeout error"),
}
}
#[kithara::test(tokio)]
#[case("500 Internal Server Error", true)]
#[case("502 Bad Gateway", true)]
#[case("503 Service Unavailable", true)]
#[case("504 Gateway Timeout", true)]
#[case("429 Too Many Requests", true)]
#[case("408 Request Timeout", true)]
#[case("timeout while connecting", true)]
#[case("network error", true)]
#[case("connection reset", true)]
#[case("404 Not Found", false)]
#[case("400 Bad Request", false)]
#[case("403 Forbidden", false)]
#[case("401 Unauthorized", false)]
async fn test_http_error_string_parsing(
#[case] error_string: &str,
#[case] expected_retryable: bool,
) {
let error = NetError::Http(error_string.to_string());
assert_eq!(error.is_retryable(), expected_retryable);
}
#[kithara::test(tokio)]
async fn test_error_equality() {
let timeout1 = NetError::Timeout;
let timeout2 = NetError::Timeout;
assert_eq!(timeout1.to_string(), timeout2.to_string());
let http1 = NetError::Http("test".to_string());
let http2 = NetError::Http("test".to_string());
assert_eq!(http1.to_string(), http2.to_string());
let http3 = NetError::Http("error1".to_string());
let http4 = NetError::Http("error2".to_string());
assert_ne!(http3.to_string(), http4.to_string());
}
}