spotify_cli/http/
client.rs

1//! Low-level HTTP client and error types.
2//!
3//! Provides a thin wrapper around reqwest with Spotify-specific error handling.
4
5use reqwest::Client;
6use serde::Deserialize;
7use thiserror::Error;
8
9/// Spotify API error response structure.
10#[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/// HTTP errors from Spotify API requests.
23#[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    /// Create an HttpError from a non-success HTTP response
46    pub async fn from_response(response: reqwest::Response) -> Self {
47        let status = response.status().as_u16();
48
49        // Extract Retry-After header before consuming response body
50        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); // Default to 1 second if not specified
56
57        let body = response.text().await.unwrap_or_default();
58
59        // Try to parse Spotify's error format: {"error": {"status": 401, "message": "..."}}
60        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            // Use raw body if it's short and not HTML
64            body
65        } else {
66            // Fallback to generic message based on status
67            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        // Return specific error types for common cases
79        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    /// Get the retry-after duration if this is a rate limit error
91    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    /// Get the HTTP status code for this error
99    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    /// Get a user-friendly error message
111    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
123/// Base HTTP client wrapper.
124///
125/// Thin wrapper around reqwest::Client used by SpotifyApi and SpotifyAuth.
126pub struct HttpClient {
127    client: Client,
128}
129
130impl HttpClient {
131    /// Create a new HTTP client.
132    pub fn new() -> Self {
133        Self {
134            client: Client::new(),
135        }
136    }
137
138    /// Get the underlying reqwest client for making requests.
139    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        // Just verify it creates successfully
250        let _ = client.inner();
251    }
252
253    #[test]
254    fn http_client_new() {
255        let client = HttpClient::new();
256        // Just verify it creates and inner() works
257        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        // Test display for all constructible variants
291        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}