1use std::result;
2
3use crate::retry::RetryableError;
4use thiserror::Error;
5
6pub use pubmed_parser::ParseError;
8
9#[derive(Error, Debug)]
11pub enum PubMedError {
12 #[error(transparent)]
14 ParseError(#[from] pubmed_parser::ParseError),
15
16 #[error("HTTP request failed: {0}")]
18 RequestError(#[from] reqwest::Error),
19
20 #[error("Invalid query: {0}")]
22 InvalidQuery(String),
23
24 #[error("API rate limit exceeded")]
26 RateLimitExceeded,
27
28 #[error("API error {status}: {message}")]
30 ApiError { status: u16, message: String },
31
32 #[error("Search limit exceeded: requested {requested}, maximum is {maximum}")]
35 SearchLimitExceeded { requested: usize, maximum: usize },
36
37 #[error("History session expired or invalid: {0}")]
40 HistorySessionError(String),
41
42 #[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 PubMedError::RequestError(err) => {
56 #[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 if let Some(status) = err.status() {
73 return status.is_server_error() || status.as_u16() == 429;
74 }
75
76 !err.is_builder() && !err.is_redirect() && !err.is_decode()
78 }
79
80 PubMedError::RateLimitExceeded => true,
82
83 PubMedError::ApiError { status, message } => {
85 (*status >= 500 && *status < 600) || *status == 429 || {
87 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 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 #[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 #[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 #[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 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}