pubmed_client/
error.rs

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