1use reqwest::Client;
6use serde::Deserialize;
7use thiserror::Error;
8
9#[derive(Debug, Deserialize)]
11struct SpotifyErrorResponse {
12 error: SpotifyError,
13}
14
15#[derive(Debug, Deserialize)]
16struct SpotifyError {
17 #[allow(dead_code)]
18 status: u16,
19 message: String,
20}
21
22#[derive(Debug, Error)]
24pub enum HttpError {
25 #[error("Network error: {0}")]
26 Network(#[from] reqwest::Error),
27
28 #[error("{message}")]
29 Api { status: u16, message: String },
30
31 #[error("Rate limited - retry after {retry_after_secs} seconds")]
32 RateLimited { retry_after_secs: u64 },
33
34 #[error("Token expired or invalid")]
35 Unauthorized,
36
37 #[error("Access denied")]
38 Forbidden,
39
40 #[error("Resource not found")]
41 NotFound,
42}
43
44impl HttpError {
45 pub async fn from_response(response: reqwest::Response) -> Self {
47 let status = response.status().as_u16();
48
49 let retry_after = response
51 .headers()
52 .get("retry-after")
53 .and_then(|v| v.to_str().ok())
54 .and_then(|s| s.parse::<u64>().ok())
55 .unwrap_or(1); let body = response.text().await.unwrap_or_default();
58
59 let message = if let Ok(spotify_err) = serde_json::from_str::<SpotifyErrorResponse>(&body) {
61 spotify_err.error.message
62 } else if body.len() < 200 && !body.contains('<') {
63 body
65 } else {
66 match status {
68 400 => "Bad request".to_string(),
69 401 => "Unauthorized".to_string(),
70 403 => "Forbidden".to_string(),
71 404 => "Not found".to_string(),
72 429 => "Rate limited".to_string(),
73 500..=599 => "Spotify server error".to_string(),
74 _ => format!("HTTP error {}", status),
75 }
76 };
77
78 match status {
80 401 => HttpError::Unauthorized,
81 403 => HttpError::Forbidden,
82 404 => HttpError::NotFound,
83 429 => HttpError::RateLimited {
84 retry_after_secs: retry_after,
85 },
86 _ => HttpError::Api { status, message },
87 }
88 }
89
90 pub fn retry_after(&self) -> Option<u64> {
92 match self {
93 HttpError::RateLimited { retry_after_secs } => Some(*retry_after_secs),
94 _ => None,
95 }
96 }
97
98 pub fn status_code(&self) -> u16 {
100 match self {
101 HttpError::Network(_) => 503,
102 HttpError::Api { status, .. } => *status,
103 HttpError::RateLimited { .. } => 429,
104 HttpError::Unauthorized => 401,
105 HttpError::Forbidden => 403,
106 HttpError::NotFound => 404,
107 }
108 }
109
110 pub fn user_message(&self) -> &str {
112 match self {
113 HttpError::Network(_) => "Network error - check your connection",
114 HttpError::Api { message, .. } => message,
115 HttpError::RateLimited { .. } => "Too many requests - please wait a moment",
116 HttpError::Unauthorized => "Session expired - run: spotify-cli auth refresh",
117 HttpError::Forbidden => "You don't have permission for this action",
118 HttpError::NotFound => "Resource not found",
119 }
120 }
121}
122
123pub struct HttpClient {
127 client: Client,
128}
129
130impl HttpClient {
131 pub fn new() -> Self {
133 Self {
134 client: Client::new(),
135 }
136 }
137
138 pub fn inner(&self) -> &Client {
140 &self.client
141 }
142}
143
144impl Default for HttpClient {
145 fn default() -> Self {
146 Self::new()
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn http_error_status_codes() {
156 assert_eq!(HttpError::Unauthorized.status_code(), 401);
157 assert_eq!(HttpError::Forbidden.status_code(), 403);
158 assert_eq!(HttpError::NotFound.status_code(), 404);
159 assert_eq!(
160 HttpError::RateLimited {
161 retry_after_secs: 5
162 }
163 .status_code(),
164 429
165 );
166 assert_eq!(
167 HttpError::Api {
168 status: 500,
169 message: "Server error".to_string()
170 }
171 .status_code(),
172 500
173 );
174 }
175
176 #[test]
177 fn http_error_user_messages() {
178 assert_eq!(
179 HttpError::Unauthorized.user_message(),
180 "Session expired - run: spotify-cli auth refresh"
181 );
182 assert_eq!(
183 HttpError::Forbidden.user_message(),
184 "You don't have permission for this action"
185 );
186 assert_eq!(HttpError::NotFound.user_message(), "Resource not found");
187 assert_eq!(
188 HttpError::RateLimited {
189 retry_after_secs: 5
190 }
191 .user_message(),
192 "Too many requests - please wait a moment"
193 );
194 assert_eq!(
195 HttpError::Api {
196 status: 500,
197 message: "Custom error".to_string()
198 }
199 .user_message(),
200 "Custom error"
201 );
202 }
203
204 #[test]
205 fn http_error_retry_after() {
206 assert_eq!(
207 HttpError::RateLimited {
208 retry_after_secs: 30
209 }
210 .retry_after(),
211 Some(30)
212 );
213 assert_eq!(HttpError::Unauthorized.retry_after(), None);
214 assert_eq!(HttpError::NotFound.retry_after(), None);
215 }
216
217 #[test]
218 fn http_error_display() {
219 assert_eq!(
220 format!("{}", HttpError::Unauthorized),
221 "Token expired or invalid"
222 );
223 assert_eq!(format!("{}", HttpError::Forbidden), "Access denied");
224 assert_eq!(format!("{}", HttpError::NotFound), "Resource not found");
225 assert_eq!(
226 format!(
227 "{}",
228 HttpError::RateLimited {
229 retry_after_secs: 10
230 }
231 ),
232 "Rate limited - retry after 10 seconds"
233 );
234 assert_eq!(
235 format!(
236 "{}",
237 HttpError::Api {
238 status: 400,
239 message: "Bad request".to_string()
240 }
241 ),
242 "Bad request"
243 );
244 }
245
246 #[test]
247 fn http_client_default() {
248 let client = HttpClient::default();
249 let _ = client.inner();
251 }
252
253 #[test]
254 fn http_client_new() {
255 let client = HttpClient::new();
256 let _ = client.inner();
258 }
259
260 #[test]
261 fn http_error_api_various_statuses() {
262 let statuses = [400, 402, 405, 500, 502, 503];
263 for status in statuses {
264 let err = HttpError::Api {
265 status,
266 message: "test".to_string(),
267 };
268 assert_eq!(err.status_code(), status);
269 }
270 }
271
272 #[test]
273 fn http_error_is_debug() {
274 let err = HttpError::Unauthorized;
275 let debug = format!("{:?}", err);
276 assert!(debug.contains("Unauthorized"));
277 }
278
279 #[test]
280 fn http_error_api_user_message() {
281 let err = HttpError::Api {
282 status: 400,
283 message: "test msg".to_string(),
284 };
285 assert_eq!(err.user_message(), "test msg");
286 }
287
288 #[test]
289 fn http_error_display_for_all_variants() {
290 let api_err = HttpError::Api {
292 status: 500,
293 message: "Server error".to_string(),
294 };
295 assert_eq!(format!("{}", api_err), "Server error");
296
297 let rate_err = HttpError::RateLimited {
298 retry_after_secs: 30,
299 };
300 assert!(format!("{}", rate_err).contains("30"));
301 }
302
303 #[test]
304 fn spotify_error_response_deserialization() {
305 let json = r#"{"error": {"status": 400, "message": "Bad request"}}"#;
306 let err: SpotifyErrorResponse = serde_json::from_str(json).unwrap();
307 assert_eq!(err.error.message, "Bad request");
308 }
309}