Skip to main content

ceres_core/
error.rs

1use thiserror::Error;
2
3/// Application-wide error types.
4///
5/// This enum represents all possible errors that can occur in the Ceres application.
6/// It uses the `thiserror` crate for ergonomic error handling and automatic conversion
7/// from underlying library errors.
8///
9/// # Error Conversion
10///
11/// Most errors automatically convert from their source types using the `#[from]` attribute:
12/// - `serde_json::Error` → `AppError::SerializationError`
13///
14/// Database errors are converted explicitly in the database layer.
15///
16/// # Examples
17///
18/// ```no_run
19/// use ceres_core::error::AppError;
20///
21/// fn example() -> Result<(), AppError> {
22///     // Errors automatically convert
23///     Err(AppError::Generic("Something went wrong".to_string()))
24/// }
25/// ```
26///
27/// # Gemini Error Classification
28///
29/// Gemini API errors are classified into specific categories for better error handling.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum GeminiErrorKind {
32    /// Authentication failure (401, invalid API key)
33    Authentication,
34    /// Rate limit exceeded (429)
35    RateLimit,
36    /// Quota exceeded (insufficient_quota)
37    QuotaExceeded,
38    /// Server error (5xx)
39    ServerError,
40    /// Network/connection error
41    NetworkError,
42    /// Unknown or unclassified error
43    Unknown,
44}
45
46/// Structured error details from Gemini API
47#[derive(Debug, Clone)]
48pub struct GeminiErrorDetails {
49    /// The specific error category
50    pub kind: GeminiErrorKind,
51    /// Human-readable error message from the API
52    pub message: String,
53    /// HTTP status code
54    pub status_code: u16,
55}
56
57impl GeminiErrorDetails {
58    /// Create a new GeminiErrorDetails
59    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    /// Database operation failed.
81    ///
82    /// This error wraps database operation failures as plain strings,
83    /// keeping the domain layer independent of any specific database library.
84    #[error("Database error: {0}")]
85    DatabaseError(String),
86
87    /// HTTP client request failed.
88    ///
89    /// This error occurs when HTTP requests fail due to network issues,
90    /// timeouts, or server errors.
91    #[error("API Client error: {0}")]
92    ClientError(String),
93
94    /// Gemini API call failed.
95    ///
96    /// This error occurs when Gemini API calls fail, including
97    /// authentication failures, rate limiting, and API errors.
98    /// Contains structured error information for better error handling.
99    #[error("Gemini error: {0}")]
100    GeminiError(GeminiErrorDetails),
101
102    /// JSON serialization or deserialization failed.
103    ///
104    /// This error occurs when converting between Rust types and JSON,
105    /// typically when parsing API responses or preparing database values.
106    #[error("Serialization error: {0}")]
107    SerializationError(#[from] serde_json::Error),
108
109    /// URL parsing failed.
110    ///
111    /// This error occurs when attempting to parse an invalid URL string,
112    /// typically when constructing API endpoints or validating portal URLs.
113    #[error("Invalid URL: {0}")]
114    InvalidUrl(String),
115
116    /// Dataset not found in the database.
117    ///
118    /// This error indicates that a requested dataset does not exist.
119    #[error("Dataset not found: {0}")]
120    DatasetNotFound(String),
121
122    /// Invalid CKAN portal URL provided.
123    ///
124    /// This error occurs when the provided CKAN portal URL is malformed
125    /// or cannot be used to construct valid API endpoints.
126    #[error("Invalid CKAN portal URL: {0}")]
127    InvalidPortalUrl(String),
128
129    /// API response contained no data.
130    ///
131    /// This error occurs when an API returns a successful status but
132    /// the response body is empty or missing expected data.
133    #[error("Empty response from API")]
134    EmptyResponse,
135
136    /// Network or connection error.
137    ///
138    /// This error occurs when a network request fails due to connectivity issues,
139    /// DNS resolution failures, or the remote server being unreachable.
140    #[error("Network error: {0}")]
141    NetworkError(String),
142
143    /// Request timeout.
144    ///
145    /// This error occurs when a request takes longer than the configured timeout.
146    #[error("Request timed out after {0} seconds")]
147    Timeout(u64),
148
149    /// Rate limit exceeded.
150    ///
151    /// This error occurs when too many requests are made in a short period.
152    #[error("Rate limit exceeded. Please wait and try again.")]
153    RateLimitExceeded,
154
155    /// Configuration file error.
156    ///
157    /// This error occurs when reading or parsing the configuration file fails,
158    /// such as when the portals.toml file is malformed or contains invalid values.
159    #[error("Configuration error: {0}")]
160    ConfigError(String),
161
162    /// Filesystem I/O error.
163    ///
164    /// This error wraps `std::io::Error` failures from reading or writing files,
165    /// typically in export paths (creating output directories, writing files).
166    #[error("I/O error: {0}")]
167    IoError(String),
168
169    /// Data export error.
170    ///
171    /// This error covers failures specific to the export pipeline that are not
172    /// plain I/O — e.g. Arrow/Parquet schema or serialization failures.
173    #[error("Export error: {0}")]
174    ExportError(String),
175
176    /// Generic application error for cases not covered by specific variants.
177    ///
178    /// Use this sparingly - prefer creating specific error variants
179    /// for better error handling and debugging.
180    #[error("Error: {0}")]
181    Generic(String),
182}
183
184impl AppError {
185    /// Returns a user-friendly error message suitable for CLI output.
186    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    /// Returns true if this error is retryable.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use ceres_core::error::AppError;
266    ///
267    /// // Network errors are retryable
268    /// let err = AppError::NetworkError("connection reset".to_string());
269    /// assert!(err.is_retryable());
270    ///
271    /// // Rate limits are retryable (after a delay)
272    /// let err = AppError::RateLimitExceeded;
273    /// assert!(err.is_retryable());
274    ///
275    /// // Dataset not found is NOT retryable
276    /// let err = AppError::DatasetNotFound("test".to_string());
277    /// assert!(!err.is_retryable());
278    /// ```
279    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    /// Returns true if this error should trip the circuit breaker.
296    ///
297    /// Transient errors (network issues, timeouts, rate limits, server errors)
298    /// should trip the circuit breaker. Non-transient errors (authentication,
299    /// quota exceeded, invalid data) should NOT trip the circuit.
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// use ceres_core::error::{AppError, GeminiErrorKind, GeminiErrorDetails};
305    ///
306    /// // Network errors trip the circuit
307    /// let err = AppError::NetworkError("connection reset".to_string());
308    /// assert!(err.should_trip_circuit());
309    ///
310    /// // Authentication errors do NOT trip the circuit
311    /// let err = AppError::GeminiError(GeminiErrorDetails::new(
312    ///     GeminiErrorKind::Authentication,
313    ///     "Invalid API key".to_string(),
314    ///     401,
315    /// ));
316    /// assert!(!err.should_trip_circuit());
317    /// ```
318    pub fn should_trip_circuit(&self) -> bool {
319        match self {
320            // Transient errors - should trip circuit
321            AppError::NetworkError(_) | AppError::Timeout(_) | AppError::RateLimitExceeded => true,
322
323            // Client errors are often transient (timeouts, connection issues)
324            AppError::ClientError(msg) => {
325                msg.contains("timeout")
326                    || msg.contains("timed out")
327                    || msg.contains("connect")
328                    || msg.contains("connection")
329            }
330
331            // Gemini errors - only transient ones
332            AppError::GeminiError(details) => matches!(
333                details.kind,
334                GeminiErrorKind::RateLimit
335                    | GeminiErrorKind::NetworkError
336                    | GeminiErrorKind::ServerError
337            ),
338
339            // Non-transient errors - should NOT trip circuit
340            // Authentication, quota, validation errors indicate configuration
341            // problems, not temporary service issues
342            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        // Transient errors should trip the circuit
491        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        // Client errors with transient keywords should trip
499        assert!(AppError::ClientError("connection refused".to_string()).should_trip_circuit());
500        assert!(AppError::ClientError("request timed out".to_string()).should_trip_circuit());
501
502        // Client errors without transient keywords should NOT trip
503        assert!(!AppError::ClientError("invalid json".to_string()).should_trip_circuit());
504    }
505
506    #[test]
507    fn test_should_trip_circuit_gemini_errors() {
508        // Rate limit should trip
509        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        // Server error should trip
517        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        // Network error should trip
525        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        // Authentication should NOT trip
533        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        // Quota exceeded should NOT trip
541        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        // These should NOT trip the circuit
552        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}