Skip to main content

qobuz_api/
errors.rs

1//! Error types for the Qobuz API client.
2
3use std::io::Error as IoError;
4
5use {reqwest::Error as ReqwestError, thiserror::Error};
6
7/// Error type for all Qobuz API operations.
8///
9/// Implements `Send + Sync + 'static` for use in async Tokio tasks.
10#[derive(Error, Debug)]
11pub enum QobuzApiError {
12    /// Authentication failure (invalid credentials, expired tokens).
13    #[error("Authentication failed: {message}")]
14    AuthenticationError {
15        /// Error description.
16        message: String,
17    },
18
19    /// HTTP request failure (network, timeout).
20    #[error("HTTP request failed: {0}")]
21    HttpError(#[from] ReqwestError),
22
23    /// I/O failure (file system, permissions).
24    #[error("I/O error: {0}")]
25    IoError(#[from] IoError),
26
27    /// API returned an error response with code and message.
28    #[error("API error {code}: {message}")]
29    ApiErrorResponse {
30        /// API error code.
31        code: i32,
32        /// API error message.
33        message: String,
34        /// API error status.
35        status: String,
36    },
37
38    /// Failed to parse the API response body.
39    #[error("Failed to parse API response: {content}")]
40    ApiResponseParseError {
41        /// The content that failed to parse.
42        content: String,
43        /// Optional source of the parse failure.
44        source_info: Option<String>,
45    },
46
47    /// Service initialization failure.
48    #[error("Service initialization failed: {message}")]
49    InitializationError {
50        /// Error description.
51        message: String,
52    },
53
54    /// Invalid or missing credentials.
55    #[error("Invalid credentials: {message}")]
56    CredentialsError {
57        /// Error description.
58        message: String,
59    },
60
61    /// Download failure (file creation, network, disk).
62    #[error("Download failed: {message}")]
63    DownloadError {
64        /// Error description.
65        message: String,
66    },
67
68    /// Metadata tagging failure.
69    #[error("Metadata error: {0}")]
70    MetadataError(String),
71
72    /// Requested resource not found by ID.
73    #[error("{resource_type} not found: {resource_id}")]
74    ResourceNotFoundError {
75        /// Type of resource (album, artist, track, playlist).
76        resource_type: String,
77        /// Identifier that was not found.
78        resource_id: String,
79    },
80
81    /// Rate limited by the API (retries exhausted).
82    #[error("Rate limited: {message}")]
83    RateLimitError {
84        /// Error description.
85        message: String,
86    },
87
88    /// Invalid parameter passed to an API method.
89    #[error("Invalid parameter: {message}")]
90    InvalidParameterError {
91        /// Error description.
92        message: String,
93    },
94
95    /// Unexpected API response structure or content.
96    #[error("Unexpected API response: {message}")]
97    UnexpectedApiResponseError {
98        /// Error description.
99        message: String,
100    },
101
102    /// Download was cancelled by the user.
103    #[error("Download cancelled")]
104    Canceled,
105}
106
107#[cfg(test)]
108mod tests {
109    use std::io::{Error, ErrorKind};
110
111    use {reqwest::Client, tokio::runtime::Runtime};
112
113    use crate::errors::QobuzApiError::{
114        self, ApiErrorResponse, ApiResponseParseError, AuthenticationError, Canceled,
115        CredentialsError, DownloadError, HttpError, InitializationError, InvalidParameterError,
116        IoError, MetadataError, RateLimitError, ResourceNotFoundError, UnexpectedApiResponseError,
117    };
118
119    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
120
121    #[test]
122    fn all_variants_satisfy_send_sync_static() {
123        assert_send_sync_static::<QobuzApiError>();
124    }
125
126    #[test]
127    fn authentication_error_display() {
128        let err = AuthenticationError {
129            message: "bad token".into(),
130        };
131        let msg = format!("{err}");
132        assert!(msg.contains("Authentication failed"));
133        assert!(msg.contains("bad token"));
134    }
135
136    #[test]
137    fn http_error_display() {
138        let Ok(rt) = Runtime::new() else {
139            return;
140        };
141        let result = rt.block_on(async { Client::new().get("http://localhost:1").send().await });
142        let Err(req_err) = result else { return };
143        let err = HttpError(req_err);
144        let msg = format!("{err}");
145        assert!(msg.contains("HTTP request failed"));
146    }
147
148    #[test]
149    fn io_error_display() {
150        let err = IoError(Error::new(ErrorKind::NotFound, "file missing"));
151        let msg = format!("{err}");
152        assert!(msg.contains("I/O error"));
153    }
154
155    #[test]
156    fn api_error_response_display() {
157        let err = ApiErrorResponse {
158            code: 403,
159            message: "Forbidden".into(),
160            status: "403".into(),
161        };
162        let msg = format!("{err}");
163        assert!(msg.contains("API error 403"));
164        assert!(msg.contains("Forbidden"));
165    }
166
167    #[test]
168    fn api_response_parse_error_display() {
169        let err = ApiResponseParseError {
170            content: "not json".into(),
171            source_info: Some("expected `{`".into()),
172        };
173        let msg = format!("{err}");
174        assert!(msg.contains("Failed to parse API response"));
175        assert!(msg.contains("not json"));
176    }
177
178    #[test]
179    fn initialization_error_display() {
180        let err = InitializationError {
181            message: "no app_id".into(),
182        };
183        let msg = format!("{err}");
184        assert!(msg.contains("Service initialization failed"));
185    }
186
187    #[test]
188    fn credentials_error_display() {
189        let err = CredentialsError {
190            message: "missing email".into(),
191        };
192        let msg = format!("{err}");
193        assert!(msg.contains("Invalid credentials"));
194    }
195
196    #[test]
197    fn download_error_display() {
198        let err = DownloadError {
199            message: "disk full".into(),
200        };
201        let msg = format!("{err}");
202        assert!(msg.contains("Download failed"));
203    }
204
205    #[test]
206    fn metadata_error_display() {
207        let err = MetadataError("tag write failed".into());
208        let msg = format!("{err}");
209        assert!(msg.contains("Metadata error"));
210    }
211
212    #[test]
213    fn resource_not_found_error_display() {
214        let err = ResourceNotFoundError {
215            resource_type: "album".into(),
216            resource_id: "123".into(),
217        };
218        let msg = format!("{err}");
219        assert!(msg.contains("album not found"));
220        assert!(msg.contains("123"));
221    }
222
223    #[test]
224    fn rate_limit_error_display() {
225        let err = RateLimitError {
226            message: "too many requests".into(),
227        };
228        let msg = format!("{err}");
229        assert!(msg.contains("Rate limited"));
230    }
231
232    #[test]
233    fn invalid_parameter_error_display() {
234        let err = InvalidParameterError {
235            message: "negative id".into(),
236        };
237        let msg = format!("{err}");
238        assert!(msg.contains("Invalid parameter"));
239    }
240
241    #[test]
242    fn unexpected_api_response_error_has_remediation() {
243        let err = UnexpectedApiResponseError {
244            message: "missing field".into(),
245        };
246        let msg = format!("{err}").to_lowercase();
247        assert!(msg.contains("unexpected") || msg.contains("response") || msg.contains("missing"));
248    }
249
250    #[test]
251    fn canceled_error_display() {
252        let err = Canceled;
253        let msg = format!("{err}");
254        assert_eq!(msg, "Download cancelled");
255    }
256
257    #[test]
258    fn sc006_authentication_error_has_remediation() {
259        let err = AuthenticationError {
260            message: "Invalid credentials. Check your QOBUZ_EMAIL and QOBUZ_PASSWORD environment \
261                      variables"
262                .into(),
263        };
264        let msg = format!("{err}").to_lowercase();
265        assert!(msg.contains("credentials") || msg.contains("check") || msg.contains("verify"));
266    }
267
268    #[test]
269    fn sc006_credentials_error_has_remediation() {
270        let err = CredentialsError {
271            message: "Failed to extract credentials from web player. Configure QOBUZ_APP_ID and \
272                      QOBUZ_APP_SECRET manually"
273                .into(),
274        };
275        let msg = format!("{err}").to_lowercase();
276        assert!(msg.contains("configure") || msg.contains("manual") || msg.contains("set"));
277    }
278
279    #[test]
280    fn sc006_resource_not_found_error_has_remediation() {
281        let err = ResourceNotFoundError {
282            resource_type: "album".into(),
283            resource_id: "xyz".into(),
284        };
285        let msg = format!("{err}").to_lowercase();
286        assert!(msg.contains("not found"));
287    }
288
289    #[test]
290    fn sc006_download_error_has_remediation() {
291        let err = DownloadError {
292            message: "Failed to write file. Check disk space and permissions".into(),
293        };
294        let msg = format!("{err}").to_lowercase();
295        assert!(msg.contains("check") || msg.contains("disk") || msg.contains("permission"));
296    }
297
298    #[test]
299    fn sc006_initialization_error_has_remediation() {
300        let err = InitializationError {
301            message: "app_id and app_secret must be non-empty. Verify your credentials \
302                      configuration"
303                .into(),
304        };
305        let msg = format!("{err}").to_lowercase();
306        assert!(msg.contains("verify") || msg.contains("check") || msg.contains("must"));
307    }
308
309    #[test]
310    fn sc006_rate_limit_error_has_remediation() {
311        let err = RateLimitError {
312            message: "Rate limited, retry 3/3. Wait before retrying".into(),
313        };
314        let msg = format!("{err}").to_lowercase();
315        assert!(msg.contains("wait") || msg.contains("retry") || msg.contains("limited"));
316    }
317
318    #[test]
319    fn sc006_metadata_error_has_remediation() {
320        let err = MetadataError(
321            "Probe open failed. Verify the file exists and is a valid audio format".into(),
322        );
323        let msg = format!("{err}").to_lowercase();
324        assert!(msg.contains("verify") || msg.contains("valid") || msg.contains("check"));
325    }
326}