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    /// Generic application error for cases not covered by specific variants.
163    ///
164    /// Use this sparingly - prefer creating specific error variants
165    /// for better error handling and debugging.
166    #[error("Error: {0}")]
167    Generic(String),
168}
169
170impl AppError {
171    /// Returns a user-friendly error message suitable for CLI output.
172    pub fn user_message(&self) -> String {
173        match self {
174            AppError::DatabaseError(e) => {
175                if e.to_string().contains("connection") {
176                    "Cannot connect to database. Is PostgreSQL running?\n   Try: docker-compose up -d".to_string()
177                } else {
178                    format!("Database error: {}", e)
179                }
180            }
181            AppError::ClientError(msg) => {
182                if msg.contains("timeout") || msg.contains("timed out") {
183                    "Request timed out. The portal may be slow or unreachable.\n   Try again later or check the portal URL.".to_string()
184                } else if msg.contains("connect") {
185                    format!("Cannot connect to portal: {}\n   Check your internet connection and the portal URL.", msg)
186                } else {
187                    format!("API error: {}", msg)
188                }
189            }
190            AppError::GeminiError(details) => match details.kind {
191                GeminiErrorKind::Authentication => {
192                    "Invalid Gemini API key.\n   Check your GEMINI_API_KEY environment variable."
193                        .to_string()
194                }
195                GeminiErrorKind::RateLimit => {
196                    "Gemini rate limit reached.\n   Wait a moment and try again, or reduce concurrency."
197                        .to_string()
198                }
199                GeminiErrorKind::QuotaExceeded => {
200                    "Gemini quota exceeded.\n   Check your Google account billing.".to_string()
201                }
202                GeminiErrorKind::ServerError => {
203                    format!(
204                        "Gemini server error (HTTP {}).\n   Please try again later.",
205                        details.status_code
206                    )
207                }
208                GeminiErrorKind::NetworkError => {
209                    format!(
210                        "Network error connecting to Gemini: {}\n   Check your internet connection.",
211                        details.message
212                    )
213                }
214                GeminiErrorKind::Unknown => {
215                    format!("Gemini error: {}", details.message)
216                }
217            },
218            AppError::InvalidPortalUrl(url) => {
219                format!(
220                    "Invalid portal URL: {}\n   Example: https://dati.comune.milano.it",
221                    url
222                )
223            }
224            AppError::NetworkError(msg) => {
225                format!("Network error: {}\n   Check your internet connection.", msg)
226            }
227            AppError::Timeout(secs) => {
228                format!("Request timed out after {} seconds.\n   The server may be overloaded. Try again later.", secs)
229            }
230            AppError::RateLimitExceeded => {
231                "Too many requests. Please wait a moment and try again.".to_string()
232            }
233            AppError::EmptyResponse => {
234                "The API returned no data. The portal may be temporarily unavailable.".to_string()
235            }
236            AppError::ConfigError(msg) => {
237                format!(
238                    "Configuration error: {}\n   Check your configuration file.",
239                    msg
240                )
241            }
242            _ => self.to_string(),
243        }
244    }
245
246    /// Returns true if this error is retryable.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use ceres_core::error::AppError;
252    ///
253    /// // Network errors are retryable
254    /// let err = AppError::NetworkError("connection reset".to_string());
255    /// assert!(err.is_retryable());
256    ///
257    /// // Rate limits are retryable (after a delay)
258    /// let err = AppError::RateLimitExceeded;
259    /// assert!(err.is_retryable());
260    ///
261    /// // Dataset not found is NOT retryable
262    /// let err = AppError::DatasetNotFound("test".to_string());
263    /// assert!(!err.is_retryable());
264    /// ```
265    pub fn is_retryable(&self) -> bool {
266        match self {
267            AppError::NetworkError(_)
268            | AppError::Timeout(_)
269            | AppError::RateLimitExceeded
270            | AppError::ClientError(_) => true,
271            AppError::GeminiError(details) => matches!(
272                details.kind,
273                GeminiErrorKind::RateLimit
274                    | GeminiErrorKind::NetworkError
275                    | GeminiErrorKind::ServerError
276            ),
277            _ => false,
278        }
279    }
280
281    /// Returns true if this error should trip the circuit breaker.
282    ///
283    /// Transient errors (network issues, timeouts, rate limits, server errors)
284    /// should trip the circuit breaker. Non-transient errors (authentication,
285    /// quota exceeded, invalid data) should NOT trip the circuit.
286    ///
287    /// # Examples
288    ///
289    /// ```
290    /// use ceres_core::error::{AppError, GeminiErrorKind, GeminiErrorDetails};
291    ///
292    /// // Network errors trip the circuit
293    /// let err = AppError::NetworkError("connection reset".to_string());
294    /// assert!(err.should_trip_circuit());
295    ///
296    /// // Authentication errors do NOT trip the circuit
297    /// let err = AppError::GeminiError(GeminiErrorDetails::new(
298    ///     GeminiErrorKind::Authentication,
299    ///     "Invalid API key".to_string(),
300    ///     401,
301    /// ));
302    /// assert!(!err.should_trip_circuit());
303    /// ```
304    pub fn should_trip_circuit(&self) -> bool {
305        match self {
306            // Transient errors - should trip circuit
307            AppError::NetworkError(_) | AppError::Timeout(_) | AppError::RateLimitExceeded => true,
308
309            // Client errors are often transient (timeouts, connection issues)
310            AppError::ClientError(msg) => {
311                msg.contains("timeout")
312                    || msg.contains("timed out")
313                    || msg.contains("connect")
314                    || msg.contains("connection")
315            }
316
317            // Gemini errors - only transient ones
318            AppError::GeminiError(details) => matches!(
319                details.kind,
320                GeminiErrorKind::RateLimit
321                    | GeminiErrorKind::NetworkError
322                    | GeminiErrorKind::ServerError
323            ),
324
325            // Non-transient errors - should NOT trip circuit
326            // Authentication, quota, validation errors indicate configuration
327            // problems, not temporary service issues
328            AppError::DatabaseError(_)
329            | AppError::SerializationError(_)
330            | AppError::InvalidUrl(_)
331            | AppError::DatasetNotFound(_)
332            | AppError::InvalidPortalUrl(_)
333            | AppError::EmptyResponse
334            | AppError::ConfigError(_)
335            | AppError::Generic(_) => false,
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_error_display() {
346        let err = AppError::DatasetNotFound("test-id".to_string());
347        assert_eq!(err.to_string(), "Dataset not found: test-id");
348    }
349
350    #[test]
351    fn test_generic_error() {
352        let err = AppError::Generic("Something went wrong".to_string());
353        assert_eq!(err.to_string(), "Error: Something went wrong");
354    }
355
356    #[test]
357    fn test_empty_response_error() {
358        let err = AppError::EmptyResponse;
359        assert_eq!(err.to_string(), "Empty response from API");
360    }
361
362    #[test]
363    fn test_user_message_gemini_auth() {
364        let details = GeminiErrorDetails::new(
365            GeminiErrorKind::Authentication,
366            "Invalid API key".to_string(),
367            401,
368        );
369        let err = AppError::GeminiError(details);
370        let msg = err.user_message();
371        assert!(msg.contains("Invalid Gemini API key"));
372        assert!(msg.contains("GEMINI_API_KEY"));
373    }
374
375    #[test]
376    fn test_user_message_gemini_rate_limit() {
377        let details = GeminiErrorDetails::new(
378            GeminiErrorKind::RateLimit,
379            "Rate limit exceeded".to_string(),
380            429,
381        );
382        let err = AppError::GeminiError(details);
383        let msg = err.user_message();
384        assert!(msg.contains("rate limit"));
385    }
386
387    #[test]
388    fn test_user_message_gemini_quota() {
389        let details = GeminiErrorDetails::new(
390            GeminiErrorKind::QuotaExceeded,
391            "Insufficient quota".to_string(),
392            429,
393        );
394        let err = AppError::GeminiError(details);
395        let msg = err.user_message();
396        assert!(msg.contains("quota exceeded"));
397        assert!(msg.contains("Google account billing"));
398    }
399
400    #[test]
401    fn test_gemini_error_display() {
402        let details = GeminiErrorDetails::new(
403            GeminiErrorKind::Authentication,
404            "Invalid API key".to_string(),
405            401,
406        );
407        let err = AppError::GeminiError(details);
408        assert!(err.to_string().contains("Gemini error"));
409        assert!(err.to_string().contains("401"));
410    }
411
412    #[test]
413    fn test_gemini_error_retryable() {
414        let rate_limit = AppError::GeminiError(GeminiErrorDetails::new(
415            GeminiErrorKind::RateLimit,
416            "Rate limit".to_string(),
417            429,
418        ));
419        assert!(rate_limit.is_retryable());
420
421        let auth_error = AppError::GeminiError(GeminiErrorDetails::new(
422            GeminiErrorKind::Authentication,
423            "Invalid key".to_string(),
424            401,
425        ));
426        assert!(!auth_error.is_retryable());
427
428        let server_error = AppError::GeminiError(GeminiErrorDetails::new(
429            GeminiErrorKind::ServerError,
430            "Internal server error".to_string(),
431            500,
432        ));
433        assert!(server_error.is_retryable());
434    }
435
436    #[test]
437    fn test_invalid_portal_url() {
438        let err = AppError::InvalidPortalUrl("not a url".to_string());
439        assert!(err.to_string().contains("Invalid CKAN portal URL"));
440    }
441
442    #[test]
443    fn test_error_from_serde() {
444        let json = "{ invalid json }";
445        let result: Result<serde_json::Value, _> = serde_json::from_str(json);
446        let serde_err = result.unwrap_err();
447        let app_err: AppError = serde_err.into();
448        assert!(matches!(app_err, AppError::SerializationError(_)));
449    }
450
451    #[test]
452    fn test_user_message_database_connection() {
453        let err = AppError::DatabaseError("Pool timed out: connection".to_string());
454        let msg = err.user_message();
455        assert!(msg.contains("Cannot connect to database"));
456    }
457
458    #[test]
459    fn test_is_retryable() {
460        assert!(AppError::NetworkError("timeout".to_string()).is_retryable());
461        assert!(AppError::Timeout(30).is_retryable());
462        assert!(AppError::RateLimitExceeded.is_retryable());
463        assert!(!AppError::InvalidPortalUrl("bad".to_string()).is_retryable());
464    }
465
466    #[test]
467    fn test_timeout_error() {
468        let err = AppError::Timeout(30);
469        assert_eq!(err.to_string(), "Request timed out after 30 seconds");
470    }
471
472    #[test]
473    fn test_should_trip_circuit_transient_errors() {
474        // Transient errors should trip the circuit
475        assert!(AppError::NetworkError("connection reset".to_string()).should_trip_circuit());
476        assert!(AppError::Timeout(30).should_trip_circuit());
477        assert!(AppError::RateLimitExceeded.should_trip_circuit());
478    }
479
480    #[test]
481    fn test_should_trip_circuit_client_errors() {
482        // Client errors with transient keywords should trip
483        assert!(AppError::ClientError("connection refused".to_string()).should_trip_circuit());
484        assert!(AppError::ClientError("request timed out".to_string()).should_trip_circuit());
485
486        // Client errors without transient keywords should NOT trip
487        assert!(!AppError::ClientError("invalid json".to_string()).should_trip_circuit());
488    }
489
490    #[test]
491    fn test_should_trip_circuit_gemini_errors() {
492        // Rate limit should trip
493        let rate_limit = AppError::GeminiError(GeminiErrorDetails::new(
494            GeminiErrorKind::RateLimit,
495            "Rate limit exceeded".to_string(),
496            429,
497        ));
498        assert!(rate_limit.should_trip_circuit());
499
500        // Server error should trip
501        let server_error = AppError::GeminiError(GeminiErrorDetails::new(
502            GeminiErrorKind::ServerError,
503            "Internal server error".to_string(),
504            500,
505        ));
506        assert!(server_error.should_trip_circuit());
507
508        // Network error should trip
509        let network_error = AppError::GeminiError(GeminiErrorDetails::new(
510            GeminiErrorKind::NetworkError,
511            "Connection failed".to_string(),
512            0,
513        ));
514        assert!(network_error.should_trip_circuit());
515
516        // Authentication should NOT trip
517        let auth_error = AppError::GeminiError(GeminiErrorDetails::new(
518            GeminiErrorKind::Authentication,
519            "Invalid API key".to_string(),
520            401,
521        ));
522        assert!(!auth_error.should_trip_circuit());
523
524        // Quota exceeded should NOT trip
525        let quota_error = AppError::GeminiError(GeminiErrorDetails::new(
526            GeminiErrorKind::QuotaExceeded,
527            "Insufficient quota".to_string(),
528            429,
529        ));
530        assert!(!quota_error.should_trip_circuit());
531    }
532
533    #[test]
534    fn test_should_trip_circuit_non_transient_errors() {
535        // These should NOT trip the circuit
536        assert!(!AppError::InvalidPortalUrl("bad url".to_string()).should_trip_circuit());
537        assert!(!AppError::DatasetNotFound("missing".to_string()).should_trip_circuit());
538        assert!(!AppError::InvalidUrl("bad".to_string()).should_trip_circuit());
539        assert!(!AppError::EmptyResponse.should_trip_circuit());
540        assert!(!AppError::ConfigError("bad config".to_string()).should_trip_circuit());
541        assert!(!AppError::Generic("something".to_string()).should_trip_circuit());
542    }
543}