1use thiserror::Error;
2
3#[derive(Error, Debug)]
5pub enum ApiError {
6 #[error("API error: {status} - {message}")]
8 Api { status: u16, message: String },
9
10 #[error("Authentication failed: {0}")]
12 Authentication(String),
13
14 #[error("Validation error: {0}")]
16 Validation(String),
17
18 #[error("Rate limit exceeded: {0}")]
20 RateLimit(String),
21
22 #[error("Request timeout")]
24 Timeout,
25
26 #[error("Network error: {0}")]
28 Network(#[from] reqwest::Error),
29
30 #[error("Serialization error: {0}")]
32 Serialization(#[from] serde_json::Error),
33
34 #[error("URL error: {0}")]
36 Url(#[from] url::ParseError),
37}
38
39impl ApiError {
40 pub async fn from_response(response: reqwest::Response) -> Self {
42 let status = response.status().as_u16();
43
44 let body_text = response.text().await.unwrap_or_default();
46 tracing::debug!("API error response body: {}", body_text);
47
48 Self::from_status_and_body(status, &body_text)
49 }
50
51 pub fn from_status_and_body(status: u16, body: &str) -> Self {
57 let message = serde_json::from_str::<serde_json::Value>(body)
58 .ok()
59 .and_then(|v| {
60 v.get("error")
61 .or(v.get("message"))
62 .and_then(|m| m.as_str())
63 .map(String::from)
64 })
65 .unwrap_or_else(|| body.to_owned());
66
67 match status {
68 401 | 403 => Self::Authentication(message),
69 400 => Self::Validation(message),
70 429 => Self::RateLimit(message),
71 408 => Self::Timeout,
72 _ => Self::Api { status, message },
73 }
74 }
75
76 pub fn is_retriable(&self) -> bool {
90 match self {
91 Self::Api { status, .. } => *status == 425 || *status >= 500,
94 Self::RateLimit(_) | Self::Timeout => true,
95 Self::Network(e) => e.is_timeout() || e.is_connect(),
96 Self::Authentication(_) | Self::Validation(_) => false,
97 Self::Serialization(_) | Self::Url(_) => false,
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn test_rate_limit_error_carries_message() {
108 let err = ApiError::RateLimit("too many requests, retry after 5s".to_string());
109 let display = format!("{}", err);
110 assert!(
111 display.contains("too many requests"),
112 "RateLimit display should contain the message: {}",
113 display
114 );
115 }
116
117 #[test]
118 fn test_rate_limit_error_display_format() {
119 let err = ApiError::RateLimit("slow down".to_string());
120 assert_eq!(format!("{}", err), "Rate limit exceeded: slow down");
121 }
122
123 #[test]
126 fn test_from_status_and_body_reads_error_then_message() {
127 assert!(matches!(
128 ApiError::from_status_and_body(400, r#"{"error":"bad limit"}"#),
129 ApiError::Validation(m) if m == "bad limit"
130 ));
131 assert!(matches!(
132 ApiError::from_status_and_body(503, r#"{"message":"down"}"#),
133 ApiError::Api { status: 503, message } if message == "down"
134 ));
135 }
136
137 #[test]
138 fn test_from_status_and_body_keeps_a_non_json_body_verbatim() {
139 assert!(matches!(
141 ApiError::from_status_and_body(429, "error code: 1015"),
142 ApiError::RateLimit(m) if m == "error code: 1015"
143 ));
144 }
145
146 #[test]
149 fn test_retriable_transient_failures() {
150 assert!(ApiError::RateLimit("slow down".into()).is_retriable());
151 assert!(ApiError::Timeout.is_retriable());
152 for status in [500u16, 502, 503, 504] {
153 assert!(
154 ApiError::Api {
155 status,
156 message: String::new()
157 }
158 .is_retriable(),
159 "{status} should be retriable"
160 );
161 }
162 }
163
164 #[test]
165 fn test_retriable_425_too_early() {
166 assert!(ApiError::Api {
169 status: 425,
170 message: String::new()
171 }
172 .is_retriable());
173 }
174
175 #[test]
176 fn test_not_retriable_deterministic_failures() {
177 assert!(!ApiError::Validation("bad payload".into()).is_retriable());
178 assert!(!ApiError::Authentication("Invalid API key".into()).is_retriable());
179 for status in [404u16, 409, 418] {
180 assert!(
181 !ApiError::Api {
182 status,
183 message: String::new()
184 }
185 .is_retriable(),
186 "{status} should not be retriable"
187 );
188 }
189 }
190
191 #[test]
192 fn test_not_retriable_local_encode_decode_failures() {
193 let json_err = serde_json::from_str::<String>("not json").unwrap_err();
194 assert!(!ApiError::Serialization(json_err).is_retriable());
195 let url_err = url::Url::parse("://bad").unwrap_err();
196 assert!(!ApiError::Url(url_err).is_retriable());
197 }
198}