1use std::fmt;
4use std::time::Duration;
5
6#[derive(Debug)]
8pub enum ApiError {
9 Network {
11 source: Box<dyn std::error::Error + Send + Sync>,
13 remote: String,
15 },
16 HttpStatus {
18 status: u16,
20 body: Option<String>,
22 remote: String,
24 },
25 Deserialization {
27 message: String,
29 endpoint: String,
31 },
32 Timeout {
34 endpoint: String,
36 duration: Duration,
38 },
39 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}