1use thiserror::Error;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum GeminiErrorKind {
32 Authentication,
34 RateLimit,
36 QuotaExceeded,
38 ServerError,
40 NetworkError,
42 Unknown,
44}
45
46#[derive(Debug, Clone)]
48pub struct GeminiErrorDetails {
49 pub kind: GeminiErrorKind,
51 pub message: String,
53 pub status_code: u16,
55}
56
57impl GeminiErrorDetails {
58 pub fn new(kind: GeminiErrorKind, message: String, status_code: u16) -> Self {
60 Self {
61 kind,
62 message,
63 status_code,
64 }
65 }
66}
67
68impl std::fmt::Display for GeminiErrorDetails {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 write!(
71 f,
72 "Gemini API error (HTTP {}): {}",
73 self.status_code, self.message
74 )
75 }
76}
77
78#[derive(Error, Debug)]
79pub enum AppError {
80 #[error("Database error: {0}")]
85 DatabaseError(String),
86
87 #[error("API Client error: {0}")]
92 ClientError(String),
93
94 #[error("Gemini error: {0}")]
100 GeminiError(GeminiErrorDetails),
101
102 #[error("Serialization error: {0}")]
107 SerializationError(#[from] serde_json::Error),
108
109 #[error("Invalid URL: {0}")]
114 InvalidUrl(String),
115
116 #[error("Dataset not found: {0}")]
120 DatasetNotFound(String),
121
122 #[error("Invalid CKAN portal URL: {0}")]
127 InvalidPortalUrl(String),
128
129 #[error("Empty response from API")]
134 EmptyResponse,
135
136 #[error("Network error: {0}")]
141 NetworkError(String),
142
143 #[error("Request timed out after {0} seconds")]
147 Timeout(u64),
148
149 #[error("Rate limit exceeded. Please wait and try again.")]
153 RateLimitExceeded,
154
155 #[error("Configuration error: {0}")]
160 ConfigError(String),
161
162 #[error("I/O error: {0}")]
167 IoError(String),
168
169 #[error("Export error: {0}")]
174 ExportError(String),
175
176 #[error("Error: {0}")]
181 Generic(String),
182}
183
184impl AppError {
185 pub fn user_message(&self) -> String {
187 match self {
188 AppError::DatabaseError(e) => {
189 if e.to_string().contains("connection") {
190 "Cannot connect to database. Is PostgreSQL running?\n Try: docker-compose up -d".to_string()
191 } else {
192 format!("Database error: {}", e)
193 }
194 }
195 AppError::ClientError(msg) => {
196 if msg.contains("timeout") || msg.contains("timed out") {
197 "Request timed out. The portal may be slow or unreachable.\n Try again later or check the portal URL.".to_string()
198 } else if msg.contains("connect") {
199 format!("Cannot connect to portal: {}\n Check your internet connection and the portal URL.", msg)
200 } else {
201 format!("API error: {}", msg)
202 }
203 }
204 AppError::GeminiError(details) => match details.kind {
205 GeminiErrorKind::Authentication => {
206 "Invalid Gemini API key.\n Check your GEMINI_API_KEY environment variable."
207 .to_string()
208 }
209 GeminiErrorKind::RateLimit => {
210 "Gemini rate limit reached.\n Wait a moment and try again, or reduce concurrency."
211 .to_string()
212 }
213 GeminiErrorKind::QuotaExceeded => {
214 "Gemini quota exceeded.\n Check your Google account billing.".to_string()
215 }
216 GeminiErrorKind::ServerError => {
217 format!(
218 "Gemini server error (HTTP {}).\n Please try again later.",
219 details.status_code
220 )
221 }
222 GeminiErrorKind::NetworkError => {
223 format!(
224 "Network error connecting to Gemini: {}\n Check your internet connection.",
225 details.message
226 )
227 }
228 GeminiErrorKind::Unknown => {
229 format!("Gemini error: {}", details.message)
230 }
231 },
232 AppError::InvalidPortalUrl(url) => {
233 format!(
234 "Invalid portal URL: {}\n Example: https://dati.comune.milano.it",
235 url
236 )
237 }
238 AppError::NetworkError(msg) => {
239 format!("Network error: {}\n Check your internet connection.", msg)
240 }
241 AppError::Timeout(secs) => {
242 format!("Request timed out after {} seconds.\n The server may be overloaded. Try again later.", secs)
243 }
244 AppError::RateLimitExceeded => {
245 "Too many requests. Please wait a moment and try again.".to_string()
246 }
247 AppError::EmptyResponse => {
248 "The API returned no data. The portal may be temporarily unavailable.".to_string()
249 }
250 AppError::ConfigError(msg) => {
251 format!(
252 "Configuration error: {}\n Check your configuration file.",
253 msg
254 )
255 }
256 _ => self.to_string(),
257 }
258 }
259
260 pub fn is_retryable(&self) -> bool {
280 match self {
281 AppError::NetworkError(_)
282 | AppError::Timeout(_)
283 | AppError::RateLimitExceeded
284 | AppError::ClientError(_) => true,
285 AppError::GeminiError(details) => matches!(
286 details.kind,
287 GeminiErrorKind::RateLimit
288 | GeminiErrorKind::NetworkError
289 | GeminiErrorKind::ServerError
290 ),
291 _ => false,
292 }
293 }
294
295 pub fn should_trip_circuit(&self) -> bool {
319 match self {
320 AppError::NetworkError(_) | AppError::Timeout(_) | AppError::RateLimitExceeded => true,
322
323 AppError::ClientError(msg) => {
325 msg.contains("timeout")
326 || msg.contains("timed out")
327 || msg.contains("connect")
328 || msg.contains("connection")
329 }
330
331 AppError::GeminiError(details) => matches!(
333 details.kind,
334 GeminiErrorKind::RateLimit
335 | GeminiErrorKind::NetworkError
336 | GeminiErrorKind::ServerError
337 ),
338
339 AppError::DatabaseError(_)
343 | AppError::SerializationError(_)
344 | AppError::InvalidUrl(_)
345 | AppError::DatasetNotFound(_)
346 | AppError::InvalidPortalUrl(_)
347 | AppError::EmptyResponse
348 | AppError::ConfigError(_)
349 | AppError::IoError(_)
350 | AppError::ExportError(_)
351 | AppError::Generic(_) => false,
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_error_display() {
362 let err = AppError::DatasetNotFound("test-id".to_string());
363 assert_eq!(err.to_string(), "Dataset not found: test-id");
364 }
365
366 #[test]
367 fn test_generic_error() {
368 let err = AppError::Generic("Something went wrong".to_string());
369 assert_eq!(err.to_string(), "Error: Something went wrong");
370 }
371
372 #[test]
373 fn test_empty_response_error() {
374 let err = AppError::EmptyResponse;
375 assert_eq!(err.to_string(), "Empty response from API");
376 }
377
378 #[test]
379 fn test_user_message_gemini_auth() {
380 let details = GeminiErrorDetails::new(
381 GeminiErrorKind::Authentication,
382 "Invalid API key".to_string(),
383 401,
384 );
385 let err = AppError::GeminiError(details);
386 let msg = err.user_message();
387 assert!(msg.contains("Invalid Gemini API key"));
388 assert!(msg.contains("GEMINI_API_KEY"));
389 }
390
391 #[test]
392 fn test_user_message_gemini_rate_limit() {
393 let details = GeminiErrorDetails::new(
394 GeminiErrorKind::RateLimit,
395 "Rate limit exceeded".to_string(),
396 429,
397 );
398 let err = AppError::GeminiError(details);
399 let msg = err.user_message();
400 assert!(msg.contains("rate limit"));
401 }
402
403 #[test]
404 fn test_user_message_gemini_quota() {
405 let details = GeminiErrorDetails::new(
406 GeminiErrorKind::QuotaExceeded,
407 "Insufficient quota".to_string(),
408 429,
409 );
410 let err = AppError::GeminiError(details);
411 let msg = err.user_message();
412 assert!(msg.contains("quota exceeded"));
413 assert!(msg.contains("Google account billing"));
414 }
415
416 #[test]
417 fn test_gemini_error_display() {
418 let details = GeminiErrorDetails::new(
419 GeminiErrorKind::Authentication,
420 "Invalid API key".to_string(),
421 401,
422 );
423 let err = AppError::GeminiError(details);
424 assert!(err.to_string().contains("Gemini error"));
425 assert!(err.to_string().contains("401"));
426 }
427
428 #[test]
429 fn test_gemini_error_retryable() {
430 let rate_limit = AppError::GeminiError(GeminiErrorDetails::new(
431 GeminiErrorKind::RateLimit,
432 "Rate limit".to_string(),
433 429,
434 ));
435 assert!(rate_limit.is_retryable());
436
437 let auth_error = AppError::GeminiError(GeminiErrorDetails::new(
438 GeminiErrorKind::Authentication,
439 "Invalid key".to_string(),
440 401,
441 ));
442 assert!(!auth_error.is_retryable());
443
444 let server_error = AppError::GeminiError(GeminiErrorDetails::new(
445 GeminiErrorKind::ServerError,
446 "Internal server error".to_string(),
447 500,
448 ));
449 assert!(server_error.is_retryable());
450 }
451
452 #[test]
453 fn test_invalid_portal_url() {
454 let err = AppError::InvalidPortalUrl("not a url".to_string());
455 assert!(err.to_string().contains("Invalid CKAN portal URL"));
456 }
457
458 #[test]
459 fn test_error_from_serde() {
460 let json = "{ invalid json }";
461 let result: Result<serde_json::Value, _> = serde_json::from_str(json);
462 let serde_err = result.unwrap_err();
463 let app_err: AppError = serde_err.into();
464 assert!(matches!(app_err, AppError::SerializationError(_)));
465 }
466
467 #[test]
468 fn test_user_message_database_connection() {
469 let err = AppError::DatabaseError("Pool timed out: connection".to_string());
470 let msg = err.user_message();
471 assert!(msg.contains("Cannot connect to database"));
472 }
473
474 #[test]
475 fn test_is_retryable() {
476 assert!(AppError::NetworkError("timeout".to_string()).is_retryable());
477 assert!(AppError::Timeout(30).is_retryable());
478 assert!(AppError::RateLimitExceeded.is_retryable());
479 assert!(!AppError::InvalidPortalUrl("bad".to_string()).is_retryable());
480 }
481
482 #[test]
483 fn test_timeout_error() {
484 let err = AppError::Timeout(30);
485 assert_eq!(err.to_string(), "Request timed out after 30 seconds");
486 }
487
488 #[test]
489 fn test_should_trip_circuit_transient_errors() {
490 assert!(AppError::NetworkError("connection reset".to_string()).should_trip_circuit());
492 assert!(AppError::Timeout(30).should_trip_circuit());
493 assert!(AppError::RateLimitExceeded.should_trip_circuit());
494 }
495
496 #[test]
497 fn test_should_trip_circuit_client_errors() {
498 assert!(AppError::ClientError("connection refused".to_string()).should_trip_circuit());
500 assert!(AppError::ClientError("request timed out".to_string()).should_trip_circuit());
501
502 assert!(!AppError::ClientError("invalid json".to_string()).should_trip_circuit());
504 }
505
506 #[test]
507 fn test_should_trip_circuit_gemini_errors() {
508 let rate_limit = AppError::GeminiError(GeminiErrorDetails::new(
510 GeminiErrorKind::RateLimit,
511 "Rate limit exceeded".to_string(),
512 429,
513 ));
514 assert!(rate_limit.should_trip_circuit());
515
516 let server_error = AppError::GeminiError(GeminiErrorDetails::new(
518 GeminiErrorKind::ServerError,
519 "Internal server error".to_string(),
520 500,
521 ));
522 assert!(server_error.should_trip_circuit());
523
524 let network_error = AppError::GeminiError(GeminiErrorDetails::new(
526 GeminiErrorKind::NetworkError,
527 "Connection failed".to_string(),
528 0,
529 ));
530 assert!(network_error.should_trip_circuit());
531
532 let auth_error = AppError::GeminiError(GeminiErrorDetails::new(
534 GeminiErrorKind::Authentication,
535 "Invalid API key".to_string(),
536 401,
537 ));
538 assert!(!auth_error.should_trip_circuit());
539
540 let quota_error = AppError::GeminiError(GeminiErrorDetails::new(
542 GeminiErrorKind::QuotaExceeded,
543 "Insufficient quota".to_string(),
544 429,
545 ));
546 assert!(!quota_error.should_trip_circuit());
547 }
548
549 #[test]
550 fn test_should_trip_circuit_non_transient_errors() {
551 assert!(!AppError::InvalidPortalUrl("bad url".to_string()).should_trip_circuit());
553 assert!(!AppError::DatasetNotFound("missing".to_string()).should_trip_circuit());
554 assert!(!AppError::InvalidUrl("bad".to_string()).should_trip_circuit());
555 assert!(!AppError::EmptyResponse.should_trip_circuit());
556 assert!(!AppError::ConfigError("bad config".to_string()).should_trip_circuit());
557 assert!(!AppError::Generic("something".to_string()).should_trip_circuit());
558 }
559}