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 let message = serde_json::from_str::<serde_json::Value>(&body_text)
49 .ok()
50 .and_then(|v| {
51 v.get("error")
52 .or(v.get("message"))
53 .and_then(|m| m.as_str())
54 .map(String::from)
55 })
56 .unwrap_or_else(|| body_text.clone());
57
58 match status {
59 401 | 403 => Self::Authentication(message),
60 400 => Self::Validation(message),
61 429 => Self::RateLimit(message),
62 408 => Self::Timeout,
63 _ => Self::Api { status, message },
64 }
65 }
66
67 pub fn is_retriable(&self) -> bool {
81 match self {
82 Self::Api { status, .. } => *status == 425 || *status >= 500,
85 Self::RateLimit(_) | Self::Timeout => true,
86 Self::Network(e) => e.is_timeout() || e.is_connect(),
87 Self::Authentication(_) | Self::Validation(_) => false,
88 Self::Serialization(_) | Self::Url(_) => false,
89 }
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn test_rate_limit_error_carries_message() {
99 let err = ApiError::RateLimit("too many requests, retry after 5s".to_string());
100 let display = format!("{}", err);
101 assert!(
102 display.contains("too many requests"),
103 "RateLimit display should contain the message: {}",
104 display
105 );
106 }
107
108 #[test]
109 fn test_rate_limit_error_display_format() {
110 let err = ApiError::RateLimit("slow down".to_string());
111 assert_eq!(format!("{}", err), "Rate limit exceeded: slow down");
112 }
113
114 #[test]
117 fn test_retriable_transient_failures() {
118 assert!(ApiError::RateLimit("slow down".into()).is_retriable());
119 assert!(ApiError::Timeout.is_retriable());
120 for status in [500u16, 502, 503, 504] {
121 assert!(
122 ApiError::Api {
123 status,
124 message: String::new()
125 }
126 .is_retriable(),
127 "{status} should be retriable"
128 );
129 }
130 }
131
132 #[test]
133 fn test_retriable_425_too_early() {
134 assert!(ApiError::Api {
137 status: 425,
138 message: String::new()
139 }
140 .is_retriable());
141 }
142
143 #[test]
144 fn test_not_retriable_deterministic_failures() {
145 assert!(!ApiError::Validation("bad payload".into()).is_retriable());
146 assert!(!ApiError::Authentication("Invalid API key".into()).is_retriable());
147 for status in [404u16, 409, 418] {
148 assert!(
149 !ApiError::Api {
150 status,
151 message: String::new()
152 }
153 .is_retriable(),
154 "{status} should not be retriable"
155 );
156 }
157 }
158
159 #[test]
160 fn test_not_retriable_local_encode_decode_failures() {
161 let json_err = serde_json::from_str::<String>("not json").unwrap_err();
162 assert!(!ApiError::Serialization(json_err).is_retriable());
163 let url_err = url::Url::parse("://bad").unwrap_err();
164 assert!(!ApiError::Url(url_err).is_retriable());
165 }
166}