Skip to main content

remendo/client/
error.rs

1//! API client error types.
2
3use std::fmt;
4use std::time::Duration;
5
6/// Errors from Sashiko API operations.
7#[derive(Debug)]
8pub enum ApiError {
9    /// Network-level failure (DNS, connection, TLS, etc.).
10    Network {
11        /// The underlying error.
12        source: Box<dyn std::error::Error + Send + Sync>,
13        /// Name of the remote that failed.
14        remote: String,
15    },
16    /// Server returned a non-2xx HTTP status.
17    HttpStatus {
18        /// HTTP status code.
19        status: u16,
20        /// Response body, if available.
21        body: Option<String>,
22        /// Name of the remote.
23        remote: String,
24    },
25    /// Failed to parse the JSON response body.
26    Deserialization {
27        /// Description of the parse error.
28        message: String,
29        /// The endpoint that produced the bad response.
30        endpoint: String,
31    },
32    /// The request exceeded the configured timeout.
33    Timeout {
34        /// The endpoint that timed out.
35        endpoint: String,
36        /// The timeout duration.
37        duration: Duration,
38    },
39    /// Invalid client configuration (bad URL, etc.).
40    Configuration(String),
41}
42
43impl fmt::Display for ApiError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::Network { source, remote } => {
47                write!(f, "network error (remote '{remote}'): {source}")
48            }
49            Self::HttpStatus {
50                status,
51                body,
52                remote,
53            } => {
54                write!(f, "HTTP {status} from remote '{remote}'")?;
55                if let Some(b) = body {
56                    let truncated = if b.len() > 200 {
57                        &b[..b.floor_char_boundary(200)]
58                    } else {
59                        b
60                    };
61                    write!(f, ": {truncated}")?;
62                }
63                Ok(())
64            }
65            Self::Deserialization { message, endpoint } => {
66                write!(f, "JSON parse error on {endpoint}: {message}")
67            }
68            Self::Timeout { endpoint, duration } => {
69                write!(f, "request to {endpoint} timed out after {duration:?}")
70            }
71            Self::Configuration(msg) => write!(f, "client configuration error: {msg}"),
72        }
73    }
74}
75
76impl std::error::Error for ApiError {
77    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
78        match self {
79            Self::Network { source, .. } => Some(source.as_ref()),
80            Self::Deserialization { .. }
81            | Self::HttpStatus { .. }
82            | Self::Timeout { .. }
83            | Self::Configuration(_) => None,
84        }
85    }
86}
87
88#[cfg(test)]
89#[allow(clippy::expect_used)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn network_error_display() {
95        let err = ApiError::Network {
96            source: "connection refused".into(),
97            remote: "upstream".to_string(),
98        };
99        let msg = err.to_string();
100        assert!(msg.contains("upstream"));
101        assert!(msg.contains("connection refused"));
102    }
103
104    #[test]
105    fn http_status_display() {
106        let err = ApiError::HttpStatus {
107            status: 500,
108            body: Some("internal server error".to_string()),
109            remote: "staging".to_string(),
110        };
111        let msg = err.to_string();
112        assert!(msg.contains("500"));
113        assert!(msg.contains("staging"));
114    }
115
116    #[test]
117    fn configuration_error_display() {
118        let err = ApiError::Configuration("bad URL".to_string());
119        assert!(err.to_string().contains("bad URL"));
120    }
121
122    #[test]
123    fn timeout_error_display() {
124        let err = ApiError::Timeout {
125            endpoint: "/api/stats".to_string(),
126            duration: Duration::from_secs(5),
127        };
128        let msg = err.to_string();
129        assert!(msg.contains("/api/stats"));
130        assert!(msg.contains("timed out"));
131    }
132}