Skip to main content

lastfm_client/
error.rs

1use serde::{Deserialize, Serialize};
2use std::time::Duration;
3use thiserror::Error;
4
5/// Raw error response from the Last.fm API
6#[derive(Debug, Deserialize, Serialize)]
7#[non_exhaustive]
8pub struct LastFmErrorResponse {
9    /// Human-readable error message
10    pub message: String,
11    /// Numeric error code
12    pub error: u32,
13}
14
15/// Errors that can occur when interacting with the Last.fm API
16#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum LastFmError {
19    /// Represents a Last.fm API error with code and message
20    /// Access details via the struct fields: `method`, `message`, `error_code`, `retryable`
21    #[error("api error (method: {method}, code: {error_code}): {message}")]
22    Api {
23        /// API method that caused the error
24        method: String,
25        /// Human-readable error message
26        message: String,
27        /// Numeric Last.fm error code
28        error_code: u32,
29        /// Whether this error is retryable
30        retryable: bool,
31    },
32
33    /// Represents rate limiting error
34    /// Access `retry_after` via the struct field
35    #[error("rate limit exceeded{}", retry_after.map(|d| format!(" (retry after {}ms)", d.as_millis())).unwrap_or_default())]
36    RateLimited {
37        /// Suggested delay before retrying
38        retry_after: Option<Duration>,
39    },
40
41    /// Represents HTTP/network errors
42    /// Access source error via `Error::source()`
43    #[error("network error: {0}")]
44    Network(#[from] reqwest::Error),
45
46    /// Represents JSON parsing errors
47    /// Access source error via `Error::source()`
48    #[error("failed to parse response: {0}")]
49    Parse(#[from] serde_json::Error),
50
51    /// Represents file I/O errors
52    /// Access source error via `Error::source()`
53    #[error("file operation failed: {0}")]
54    Io(#[from] std::io::Error),
55
56    /// Represents CSV errors
57    /// Access source error via `Error::source()`
58    #[error("csv operation failed: {0}")]
59    Csv(#[from] csv::Error),
60
61    /// Represents missing environment variable errors
62    #[error("missing required environment variable: {0}")]
63    MissingEnvVar(String),
64
65    /// Represents configuration errors
66    #[error("configuration error: {0}")]
67    Config(String),
68
69    /// Represents HTTP response errors (non-success status codes)
70    #[error("http error (status: {status})")]
71    Http {
72        /// HTTP status code
73        status: u16,
74        /// Underlying error source
75        #[source]
76        source: Option<Box<dyn std::error::Error + Send + Sync>>,
77    },
78
79    /// Represents URL parsing errors
80    #[error("invalid url: {source}")]
81    Url {
82        /// URL parse error
83        #[source]
84        source: url::ParseError,
85    },
86}
87
88impl LastFmError {
89    /// Check if this error is retryable
90    #[must_use]
91    pub const fn is_retryable(&self) -> bool {
92        match self {
93            Self::Api { retryable, .. } => *retryable,
94            Self::RateLimited { .. } | Self::Network(_) => true,
95            _ => false,
96        }
97    }
98
99    /// Get the retry delay if specified
100    #[must_use]
101    pub const fn retry_after(&self) -> Option<Duration> {
102        match self {
103            Self::RateLimited { retry_after } => *retry_after,
104            _ => None,
105        }
106    }
107
108    /// Get the API method name if this is an API error
109    #[must_use]
110    pub fn api_method(&self) -> Option<&str> {
111        match self {
112            Self::Api { method, .. } => Some(method),
113            _ => None,
114        }
115    }
116
117    /// Get the API error code if this is an API error
118    #[must_use]
119    pub const fn api_error_code(&self) -> Option<u32> {
120        match self {
121            Self::Api { error_code, .. } => Some(*error_code),
122            _ => None,
123        }
124    }
125
126    /// Get the API error message if this is an API error
127    #[must_use]
128    pub fn api_message(&self) -> Option<&str> {
129        match self {
130            Self::Api { message, .. } => Some(message),
131            _ => None,
132        }
133    }
134
135    /// Get the environment variable name if this is a missing env var error
136    #[must_use]
137    pub fn env_var_name(&self) -> Option<&str> {
138        match self {
139            Self::MissingEnvVar(name) => Some(name),
140            _ => None,
141        }
142    }
143
144    /// Get the HTTP status code if this is an HTTP error
145    #[must_use]
146    pub const fn http_status(&self) -> Option<u16> {
147        match self {
148            Self::Http { status, .. } => Some(*status),
149            _ => None,
150        }
151    }
152}
153
154impl From<url::ParseError> for LastFmError {
155    fn from(err: url::ParseError) -> Self {
156        Self::Url { source: err }
157    }
158}
159
160/// Helper type for Result with `LastFmError`
161pub type Result<T> = std::result::Result<T, LastFmError>;
162
163#[cfg(test)]
164#[allow(clippy::unwrap_used)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_retryable_errors() {
170        let api_error = LastFmError::Api {
171            method: "test.method".to_string(),
172            message: "Temporary error".to_string(),
173            error_code: 500,
174            retryable: true,
175        };
176        assert!(api_error.is_retryable());
177
178        let non_retryable = LastFmError::Api {
179            method: "test.method".to_string(),
180            message: "Invalid API key".to_string(),
181            error_code: 10,
182            retryable: false,
183        };
184        assert!(!non_retryable.is_retryable());
185
186        let rate_limited = LastFmError::RateLimited {
187            retry_after: Some(Duration::from_secs(5)),
188        };
189        assert!(rate_limited.is_retryable());
190
191        let parse_error = LastFmError::Parse(serde_json::from_str::<()>("invalid").unwrap_err());
192        assert!(!parse_error.is_retryable());
193    }
194
195    #[test]
196    fn test_rate_limit_retry_after() {
197        let error = LastFmError::RateLimited {
198            retry_after: Some(Duration::from_secs(5)),
199        };
200        assert_eq!(error.retry_after(), Some(Duration::from_secs(5)));
201
202        let api_error = LastFmError::Api {
203            method: "test".to_string(),
204            message: "Error".to_string(),
205            error_code: 500,
206            retryable: true,
207        };
208        assert_eq!(api_error.retry_after(), None);
209    }
210
211    #[test]
212    fn test_error_display() {
213        let error = LastFmError::MissingEnvVar("LAST_FM_API_KEY".to_string());
214        let display = format!("{error}");
215        assert_eq!(
216            display,
217            "missing required environment variable: LAST_FM_API_KEY"
218        );
219    }
220
221    #[test]
222    fn test_api_error_accessors() {
223        let error = LastFmError::Api {
224            method: "user.getrecenttracks".to_string(),
225            message: "Invalid API key".to_string(),
226            error_code: 10,
227            retryable: false,
228        };
229
230        assert_eq!(error.api_method(), Some("user.getrecenttracks"));
231        assert_eq!(error.api_error_code(), Some(10));
232        assert_eq!(error.api_message(), Some("Invalid API key"));
233
234        // Test that non-API errors return None
235        let parse_error = LastFmError::Parse(serde_json::from_str::<()>("invalid").unwrap_err());
236        assert_eq!(parse_error.api_method(), None);
237        assert_eq!(parse_error.api_error_code(), None);
238        assert_eq!(parse_error.api_message(), None);
239    }
240
241    #[test]
242    fn test_env_var_accessor() {
243        let error = LastFmError::MissingEnvVar("LAST_FM_API_KEY".to_string());
244        assert_eq!(error.env_var_name(), Some("LAST_FM_API_KEY"));
245
246        let api_error = LastFmError::Api {
247            method: "test".to_string(),
248            message: "Error".to_string(),
249            error_code: 10,
250            retryable: false,
251        };
252        assert_eq!(api_error.env_var_name(), None);
253    }
254
255    #[test]
256    fn test_http_error() {
257        let error = LastFmError::Http {
258            status: 404,
259            source: None,
260        };
261        assert_eq!(error.http_status(), Some(404));
262        assert_eq!(format!("{error}"), "http error (status: 404)");
263    }
264
265    #[test]
266    fn test_display_messages_format() {
267        assert_eq!(
268            format!(
269                "{}",
270                LastFmError::Api {
271                    method: "user.getrecenttracks".to_string(),
272                    message: "Invalid API key".to_string(),
273                    error_code: 10,
274                    retryable: false
275                }
276            ),
277            "api error (method: user.getrecenttracks, code: 10): Invalid API key"
278        );
279        assert_eq!(
280            format!("{}", LastFmError::RateLimited { retry_after: None }),
281            "rate limit exceeded"
282        );
283        assert_eq!(
284            format!(
285                "{}",
286                LastFmError::RateLimited {
287                    retry_after: Some(Duration::from_secs(5))
288                }
289            ),
290            "rate limit exceeded (retry after 5000ms)"
291        );
292        assert_eq!(
293            format!("{}", LastFmError::MissingEnvVar("TEST".to_string())),
294            "missing required environment variable: TEST"
295        );
296        assert_eq!(
297            format!("{}", LastFmError::Config("bad value".to_string())),
298            "configuration error: bad value"
299        );
300    }
301}