kithara-net 0.0.1-alpha1

HTTP networking with retry, timeout, and streaming support
Documentation
use reqwest::{Error as ReqwestError, Url};
use thiserror::Error;

pub type NetResult<T> = Result<T, NetError>;

/// Centralized error type for kithara-net
#[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 {
    /// HTTP 408 Request Timeout.
    const HTTP_REQUEST_TIMEOUT: u16 = 408;

    /// Minimum HTTP status code for server errors (5xx).
    const HTTP_SERVER_ERROR_MIN: u16 = 500;

    /// HTTP 429 Too Many Requests.
    const HTTP_TOO_MANY_REQUESTS: u16 = 429;

    /// Checks if this error is considered retryable
    #[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,
        }
    }

    /// Creates a timeout error
    #[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());
    }
}