Skip to main content

pubmed_client/
error.rs

1use std::result;
2
3use crate::retry::RetryableError;
4use thiserror::Error;
5
6/// Error types for PubMed client operations
7#[derive(Error, Debug)]
8pub enum PubMedError {
9    /// HTTP request failed
10    #[error("HTTP request failed: {0}")]
11    RequestError(#[from] reqwest::Error),
12
13    /// JSON parsing failed
14    #[error("JSON parsing failed: {0}")]
15    JsonError(#[from] serde_json::Error),
16
17    /// XML parsing failed
18    #[error("XML parsing failed: {0}")]
19    XmlError(String),
20
21    /// Article not found
22    #[error("Article not found: PMID {pmid}")]
23    ArticleNotFound { pmid: String },
24
25    /// PMC full text not available
26    #[error("PMC full text not available for {id}")]
27    PmcNotAvailable { id: String },
28
29    /// Invalid PMID format
30    #[error("Invalid PMID format: {pmid}")]
31    InvalidPmid { pmid: String },
32
33    /// Invalid PMC ID format
34    #[error("Invalid PMC ID format: {pmcid}")]
35    InvalidPmcid { pmcid: String },
36
37    /// Invalid query structure or parameters
38    #[error("Invalid query: {0}")]
39    InvalidQuery(String),
40
41    /// API rate limit exceeded
42    #[error("API rate limit exceeded")]
43    RateLimitExceeded,
44
45    /// Generic API error with HTTP status code
46    #[error("API error {status}: {message}")]
47    ApiError { status: u16, message: String },
48
49    /// IO error for file operations
50    #[error("IO error: {message}")]
51    IoError { message: String },
52
53    /// Search limit exceeded
54    /// This error is returned when a search query requests more results than the maximum retrievable limit.
55    #[error("Search limit exceeded: requested {requested}, maximum is {maximum}")]
56    SearchLimitExceeded { requested: usize, maximum: usize },
57
58    /// History session expired or invalid
59    /// This error is returned when the WebEnv session is no longer valid (typically after 1 hour).
60    #[error("History session expired or invalid: {0}")]
61    HistorySessionError(String),
62
63    /// WebEnv not available in search result
64    /// This error is returned when attempting to use history server features but the search
65    /// did not return WebEnv/query_key (e.g., usehistory=y was not specified).
66    #[error("WebEnv not available in search result")]
67    WebEnvNotAvailable,
68}
69
70pub type Result<T> = result::Result<T, PubMedError>;
71
72impl RetryableError for PubMedError {
73    fn is_retryable(&self) -> bool {
74        match self {
75            // Network errors are typically transient
76            PubMedError::RequestError(err) => {
77                // Check if it's a network-related error
78                #[cfg(not(target_arch = "wasm32"))]
79                {
80                    if err.is_timeout() || err.is_connect() {
81                        return true;
82                    }
83                }
84
85                #[cfg(target_arch = "wasm32")]
86                {
87                    if err.is_timeout() {
88                        return true;
89                    }
90                }
91
92                // Check for server errors (5xx)
93                if let Some(status) = err.status() {
94                    return status.is_server_error() || status.as_u16() == 429;
95                }
96
97                // DNS and other network errors
98                !err.is_builder() && !err.is_redirect() && !err.is_decode()
99            }
100
101            // Rate limiting should be retried after delay
102            PubMedError::RateLimitExceeded => true,
103
104            // API errors might be retryable if they indicate server issues
105            PubMedError::ApiError { status, message } => {
106                // Server errors (5xx) and rate limiting (429) are retryable
107                (*status >= 500 && *status < 600) || *status == 429 || {
108                    // Also check message for specific error conditions
109                    let lower_msg = message.to_lowercase();
110                    lower_msg.contains("temporarily unavailable")
111                        || lower_msg.contains("timeout")
112                        || lower_msg.contains("connection")
113                }
114            }
115
116            // All other errors are not retryable
117            PubMedError::JsonError(_)
118            | PubMedError::XmlError(_)
119            | PubMedError::ArticleNotFound { .. }
120            | PubMedError::PmcNotAvailable { .. }
121            | PubMedError::InvalidPmid { .. }
122            | PubMedError::InvalidPmcid { .. }
123            | PubMedError::InvalidQuery(_)
124            | PubMedError::IoError { .. }
125            | PubMedError::SearchLimitExceeded { .. }
126            | PubMedError::HistorySessionError(_)
127            | PubMedError::WebEnvNotAvailable => false,
128        }
129    }
130
131    fn retry_reason(&self) -> &str {
132        if self.is_retryable() {
133            match self {
134                PubMedError::RequestError(err) if err.is_timeout() => "Request timeout",
135                #[cfg(not(target_arch = "wasm32"))]
136                PubMedError::RequestError(err) if err.is_connect() => "Connection error",
137                PubMedError::RequestError(_) => "Network error",
138                PubMedError::RateLimitExceeded => "Rate limit exceeded",
139                PubMedError::ApiError { status, .. } => match status {
140                    429 => "Rate limit exceeded",
141                    500..=599 => "Server error",
142                    _ => "Temporary API error",
143                },
144                _ => "Transient error",
145            }
146        } else {
147            match self {
148                PubMedError::JsonError(_) => "Invalid JSON response",
149                PubMedError::XmlError(_) => "Invalid XML response",
150                PubMedError::ArticleNotFound { .. } => "Article does not exist",
151                PubMedError::PmcNotAvailable { .. } => "Content not available",
152                PubMedError::InvalidPmid { .. } | PubMedError::InvalidPmcid { .. } => {
153                    "Invalid input"
154                }
155                PubMedError::InvalidQuery(_) => "Invalid query",
156                PubMedError::IoError { .. } => "File system error",
157                PubMedError::HistorySessionError(_) => "History session expired",
158                PubMedError::WebEnvNotAvailable => "WebEnv not available",
159                _ => "Non-transient error",
160            }
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    // Tests for non-retryable errors
170
171    #[test]
172    fn test_json_error_not_retryable() {
173        let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
174        let err = PubMedError::JsonError(json_err);
175
176        assert!(!err.is_retryable());
177        assert_eq!(err.retry_reason(), "Invalid JSON response");
178    }
179
180    #[test]
181    fn test_xml_error_not_retryable() {
182        let err = PubMedError::XmlError("Invalid XML format".to_string());
183
184        assert!(!err.is_retryable());
185        assert_eq!(err.retry_reason(), "Invalid XML response");
186    }
187
188    #[test]
189    fn test_article_not_found_not_retryable() {
190        let err = PubMedError::ArticleNotFound {
191            pmid: "12345".to_string(),
192        };
193
194        assert!(!err.is_retryable());
195        assert_eq!(err.retry_reason(), "Article does not exist");
196        assert!(format!("{}", err).contains("12345"));
197    }
198
199    #[test]
200    fn test_pmc_not_available_not_retryable() {
201        let err = PubMedError::PmcNotAvailable {
202            id: "67890".to_string(),
203        };
204
205        assert!(!err.is_retryable());
206        assert_eq!(err.retry_reason(), "Content not available");
207        assert!(format!("{}", err).contains("67890"));
208    }
209
210    #[test]
211    fn test_pmc_not_available_by_pmcid_not_retryable() {
212        let err = PubMedError::PmcNotAvailable {
213            id: "PMC123456".to_string(),
214        };
215
216        assert!(!err.is_retryable());
217        assert_eq!(err.retry_reason(), "Content not available");
218        assert!(format!("{}", err).contains("PMC123456"));
219    }
220
221    #[test]
222    fn test_invalid_pmid_not_retryable() {
223        let err = PubMedError::InvalidPmid {
224            pmid: "invalid".to_string(),
225        };
226
227        assert!(!err.is_retryable());
228        assert_eq!(err.retry_reason(), "Invalid input");
229        assert!(format!("{}", err).contains("invalid"));
230    }
231
232    #[test]
233    fn test_invalid_pmcid_not_retryable() {
234        let err = PubMedError::InvalidPmcid {
235            pmcid: "PMCinvalid".to_string(),
236        };
237
238        assert!(!err.is_retryable());
239        assert_eq!(err.retry_reason(), "Invalid input");
240        assert!(format!("{}", err).contains("PMCinvalid"));
241        assert!(format!("{}", err).contains("Invalid PMC ID format"));
242    }
243
244    #[test]
245    fn test_invalid_query_not_retryable() {
246        let err = PubMedError::InvalidQuery("Empty query string".to_string());
247
248        assert!(!err.is_retryable());
249        assert_eq!(err.retry_reason(), "Invalid query");
250        assert!(format!("{}", err).contains("Empty query"));
251    }
252
253    #[test]
254    fn test_io_error_not_retryable() {
255        let err = PubMedError::IoError {
256            message: "File not found".to_string(),
257        };
258
259        assert!(!err.is_retryable());
260        assert_eq!(err.retry_reason(), "File system error");
261        assert!(format!("{}", err).contains("File not found"));
262    }
263
264    #[test]
265    fn test_search_limit_exceeded_not_retryable() {
266        let err = PubMedError::SearchLimitExceeded {
267            requested: 15000,
268            maximum: 10000,
269        };
270
271        assert!(!err.is_retryable());
272        assert!(format!("{}", err).contains("15000"));
273        assert!(format!("{}", err).contains("10000"));
274    }
275
276    // Tests for retryable errors
277
278    #[test]
279    fn test_rate_limit_exceeded_is_retryable() {
280        let err = PubMedError::RateLimitExceeded;
281
282        assert!(err.is_retryable());
283        assert_eq!(err.retry_reason(), "Rate limit exceeded");
284    }
285
286    #[test]
287    fn test_api_error_429_is_retryable() {
288        let err = PubMedError::ApiError {
289            status: 429,
290            message: "Too Many Requests".to_string(),
291        };
292
293        assert!(err.is_retryable());
294        assert_eq!(err.retry_reason(), "Rate limit exceeded");
295        assert!(format!("{}", err).contains("429"));
296    }
297
298    #[test]
299    fn test_api_error_500_is_retryable() {
300        let err = PubMedError::ApiError {
301            status: 500,
302            message: "Internal Server Error".to_string(),
303        };
304
305        assert!(err.is_retryable());
306        assert_eq!(err.retry_reason(), "Server error");
307    }
308
309    #[test]
310    fn test_api_error_503_is_retryable() {
311        let err = PubMedError::ApiError {
312            status: 503,
313            message: "Service Unavailable".to_string(),
314        };
315
316        assert!(err.is_retryable());
317        assert_eq!(err.retry_reason(), "Server error");
318    }
319
320    #[test]
321    fn test_api_error_temporarily_unavailable_is_retryable() {
322        let err = PubMedError::ApiError {
323            status: 400,
324            message: "Service temporarily unavailable".to_string(),
325        };
326
327        assert!(err.is_retryable());
328        assert_eq!(err.retry_reason(), "Temporary API error");
329    }
330
331    #[test]
332    fn test_api_error_timeout_message_is_retryable() {
333        let err = PubMedError::ApiError {
334            status: 408,
335            message: "Request timeout".to_string(),
336        };
337
338        assert!(err.is_retryable());
339        assert_eq!(err.retry_reason(), "Temporary API error");
340    }
341
342    #[test]
343    fn test_api_error_connection_message_is_retryable() {
344        let err = PubMedError::ApiError {
345            status: 400,
346            message: "Connection reset by peer".to_string(),
347        };
348
349        assert!(err.is_retryable());
350        assert_eq!(err.retry_reason(), "Temporary API error");
351    }
352
353    #[test]
354    fn test_api_error_404_not_retryable() {
355        let err = PubMedError::ApiError {
356            status: 404,
357            message: "Not Found".to_string(),
358        };
359
360        assert!(!err.is_retryable());
361    }
362
363    #[test]
364    fn test_api_error_400_not_retryable() {
365        let err = PubMedError::ApiError {
366            status: 400,
367            message: "Bad Request".to_string(),
368        };
369
370        assert!(!err.is_retryable());
371    }
372
373    // Tests for error display formatting
374
375    #[test]
376    fn test_error_display_messages() {
377        let test_cases = vec![
378            (
379                PubMedError::XmlError("test".to_string()),
380                "XML parsing failed: test",
381            ),
382            (
383                PubMedError::InvalidQuery("bad query".to_string()),
384                "Invalid query: bad query",
385            ),
386            (PubMedError::RateLimitExceeded, "API rate limit exceeded"),
387        ];
388
389        for (error, expected_message) in test_cases {
390            assert_eq!(format!("{}", error), expected_message);
391        }
392    }
393
394    #[test]
395    fn test_error_display_with_fields() {
396        let err = PubMedError::ArticleNotFound {
397            pmid: "12345".to_string(),
398        };
399        let display = format!("{}", err);
400        assert!(display.contains("Article not found"));
401        assert!(display.contains("12345"));
402
403        let err = PubMedError::ApiError {
404            status: 500,
405            message: "Server Error".to_string(),
406        };
407        let display = format!("{}", err);
408        assert!(display.contains("500"));
409        assert!(display.contains("Server Error"));
410    }
411
412    #[test]
413    fn test_result_type_alias() {
414        // Test that Result<T> type alias works correctly
415        fn returns_ok() -> Result<String> {
416            Ok("success".to_string())
417        }
418
419        fn returns_err() -> Result<String> {
420            Err(PubMedError::RateLimitExceeded)
421        }
422
423        assert!(returns_ok().is_ok());
424        assert!(returns_err().is_err());
425    }
426}