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/// - `sqlx::Error` → `AppError::DatabaseError`
13/// - `reqwest::Error` → `AppError::ClientError`
14/// - `serde_json::Error` → `AppError::SerializationError`
15/// - `url::ParseError` → `AppError::InvalidUrl`
16///
17/// # Examples
18///
19/// ```no_run
20/// use ceres_core::error::AppError;
21///
22/// fn example() -> Result<(), AppError> {
23///     // Errors automatically convert
24///     Err(AppError::Generic("Something went wrong".to_string()))
25/// }
26/// ```
27///
28/// # Gemini Error Classification
29///
30/// Gemini API errors are classified into specific categories for better error handling.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GeminiErrorKind {
33    /// Authentication failure (401, invalid API key)
34    Authentication,
35    /// Rate limit exceeded (429)
36    RateLimit,
37    /// Quota exceeded (insufficient_quota)
38    QuotaExceeded,
39    /// Server error (5xx)
40    ServerError,
41    /// Network/connection error
42    NetworkError,
43    /// Unknown or unclassified error
44    Unknown,
45}
46
47/// Structured error details from Gemini API
48#[derive(Debug, Clone)]
49pub struct GeminiErrorDetails {
50    /// The specific error category
51    pub kind: GeminiErrorKind,
52    /// Human-readable error message from the API
53    pub message: String,
54    /// HTTP status code
55    pub status_code: u16,
56}
57
58impl GeminiErrorDetails {
59    /// Create a new GeminiErrorDetails
60    pub fn new(kind: GeminiErrorKind, message: String, status_code: u16) -> Self {
61        Self {
62            kind,
63            message,
64            status_code,
65        }
66    }
67}
68
69impl std::fmt::Display for GeminiErrorDetails {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(
72            f,
73            "Gemini API error (HTTP {}): {}",
74            self.status_code, self.message
75        )
76    }
77}
78
79#[derive(Error, Debug)]
80pub enum AppError {
81    /// Database operation failed.
82    ///
83    /// This error wraps all errors from SQLx database operations, including
84    /// connection failures, query errors, and constraint violations.
85    #[error("Database error: {0}")]
86    DatabaseError(#[from] sqlx::Error),
87
88    /// HTTP client request failed.
89    ///
90    /// This error occurs when HTTP requests fail due to network issues,
91    /// timeouts, or server errors.
92    #[error("API Client error: {0}")]
93    ClientError(String),
94
95    /// Gemini API call failed.
96    ///
97    /// This error occurs when Gemini API calls fail, including
98    /// authentication failures, rate limiting, and API errors.
99    /// Contains structured error information for better error handling.
100    #[error("Gemini error: {0}")]
101    GeminiError(GeminiErrorDetails),
102
103    /// JSON serialization or deserialization failed.
104    ///
105    /// This error occurs when converting between Rust types and JSON,
106    /// typically when parsing API responses or preparing database values.
107    #[error("Serialization error: {0}")]
108    SerializationError(#[from] serde_json::Error),
109
110    /// URL parsing failed.
111    ///
112    /// This error occurs when attempting to parse an invalid URL string,
113    /// typically when constructing API endpoints or validating portal URLs.
114    #[error("Invalid URL: {0}")]
115    InvalidUrl(String),
116
117    /// Dataset not found in the database.
118    ///
119    /// This error indicates that a requested dataset does not exist.
120    #[error("Dataset not found: {0}")]
121    DatasetNotFound(String),
122
123    /// Invalid CKAN portal URL provided.
124    ///
125    /// This error occurs when the provided CKAN portal URL is malformed
126    /// or cannot be used to construct valid API endpoints.
127    #[error("Invalid CKAN portal URL: {0}")]
128    InvalidPortalUrl(String),
129
130    /// API response contained no data.
131    ///
132    /// This error occurs when an API returns a successful status but
133    /// the response body is empty or missing expected data.
134    #[error("Empty response from API")]
135    EmptyResponse,
136
137    /// Network or connection error.
138    ///
139    /// This error occurs when a network request fails due to connectivity issues,
140    /// DNS resolution failures, or the remote server being unreachable.
141    #[error("Network error: {0}")]
142    NetworkError(String),
143
144    /// Request timeout.
145    ///
146    /// This error occurs when a request takes longer than the configured timeout.
147    #[error("Request timed out after {0} seconds")]
148    Timeout(u64),
149
150    /// Rate limit exceeded.
151    ///
152    /// This error occurs when too many requests are made in a short period.
153    #[error("Rate limit exceeded. Please wait and try again.")]
154    RateLimitExceeded,
155
156    /// Configuration file error.
157    ///
158    /// This error occurs when reading or parsing the configuration file fails,
159    /// such as when the portals.toml file is malformed or contains invalid values.
160    #[error("Configuration error: {0}")]
161    ConfigError(String),
162
163    /// Generic application error for cases not covered by specific variants.
164    ///
165    /// Use this sparingly - prefer creating specific error variants
166    /// for better error handling and debugging.
167    #[error("Error: {0}")]
168    Generic(String),
169}
170
171impl AppError {
172    /// Returns a user-friendly error message suitable for CLI output.
173    pub fn user_message(&self) -> String {
174        match self {
175            AppError::DatabaseError(e) => {
176                if e.to_string().contains("connection") {
177                    "Cannot connect to database. Is PostgreSQL running?\n   Try: docker-compose up -d".to_string()
178                } else {
179                    format!("Database error: {}", e)
180                }
181            }
182            AppError::ClientError(msg) => {
183                if msg.contains("timeout") || msg.contains("timed out") {
184                    "Request timed out. The portal may be slow or unreachable.\n   Try again later or check the portal URL.".to_string()
185                } else if msg.contains("connect") {
186                    format!("Cannot connect to portal: {}\n   Check your internet connection and the portal URL.", msg)
187                } else {
188                    format!("API error: {}", msg)
189                }
190            }
191            AppError::GeminiError(details) => match details.kind {
192                GeminiErrorKind::Authentication => {
193                    "Invalid Gemini API key.\n   Check your GEMINI_API_KEY environment variable."
194                        .to_string()
195                }
196                GeminiErrorKind::RateLimit => {
197                    "Gemini rate limit reached.\n   Wait a moment and try again, or reduce concurrency."
198                        .to_string()
199                }
200                GeminiErrorKind::QuotaExceeded => {
201                    "Gemini quota exceeded.\n   Check your Google account billing.".to_string()
202                }
203                GeminiErrorKind::ServerError => {
204                    format!(
205                        "Gemini server error (HTTP {}).\n   Please try again later.",
206                        details.status_code
207                    )
208                }
209                GeminiErrorKind::NetworkError => {
210                    format!(
211                        "Network error connecting to Gemini: {}\n   Check your internet connection.",
212                        details.message
213                    )
214                }
215                GeminiErrorKind::Unknown => {
216                    format!("Gemini error: {}", details.message)
217                }
218            },
219            AppError::InvalidPortalUrl(url) => {
220                format!(
221                    "Invalid portal URL: {}\n   Example: https://dati.comune.milano.it",
222                    url
223                )
224            }
225            AppError::NetworkError(msg) => {
226                format!("Network error: {}\n   Check your internet connection.", msg)
227            }
228            AppError::Timeout(secs) => {
229                format!("Request timed out after {} seconds.\n   The server may be overloaded. Try again later.", secs)
230            }
231            AppError::RateLimitExceeded => {
232                "Too many requests. Please wait a moment and try again.".to_string()
233            }
234            AppError::EmptyResponse => {
235                "The API returned no data. The portal may be temporarily unavailable.".to_string()
236            }
237            AppError::ConfigError(msg) => {
238                format!(
239                    "Configuration error: {}\n   Check your configuration file.",
240                    msg
241                )
242            }
243            _ => self.to_string(),
244        }
245    }
246
247    /// Returns true if this error is retryable.
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use ceres_core::error::AppError;
253    ///
254    /// // Network errors are retryable
255    /// let err = AppError::NetworkError("connection reset".to_string());
256    /// assert!(err.is_retryable());
257    ///
258    /// // Rate limits are retryable (after a delay)
259    /// let err = AppError::RateLimitExceeded;
260    /// assert!(err.is_retryable());
261    ///
262    /// // Dataset not found is NOT retryable
263    /// let err = AppError::DatasetNotFound("test".to_string());
264    /// assert!(!err.is_retryable());
265    /// ```
266    pub fn is_retryable(&self) -> bool {
267        match self {
268            AppError::NetworkError(_)
269            | AppError::Timeout(_)
270            | AppError::RateLimitExceeded
271            | AppError::ClientError(_) => true,
272            AppError::GeminiError(details) => matches!(
273                details.kind,
274                GeminiErrorKind::RateLimit
275                    | GeminiErrorKind::NetworkError
276                    | GeminiErrorKind::ServerError
277            ),
278            _ => false,
279        }
280    }
281
282    /// Returns true if this error should trip the circuit breaker.
283    ///
284    /// Transient errors (network issues, timeouts, rate limits, server errors)
285    /// should trip the circuit breaker. Non-transient errors (authentication,
286    /// quota exceeded, invalid data) should NOT trip the circuit.
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use ceres_core::error::{AppError, GeminiErrorKind, GeminiErrorDetails};
292    ///
293    /// // Network errors trip the circuit
294    /// let err = AppError::NetworkError("connection reset".to_string());
295    /// assert!(err.should_trip_circuit());
296    ///
297    /// // Authentication errors do NOT trip the circuit
298    /// let err = AppError::GeminiError(GeminiErrorDetails::new(
299    ///     GeminiErrorKind::Authentication,
300    ///     "Invalid API key".to_string(),
301    ///     401,
302    /// ));
303    /// assert!(!err.should_trip_circuit());
304    /// ```
305    pub fn should_trip_circuit(&self) -> bool {
306        match self {
307            // Transient errors - should trip circuit
308            AppError::NetworkError(_) | AppError::Timeout(_) | AppError::RateLimitExceeded => true,
309
310            // Client errors are often transient (timeouts, connection issues)
311            AppError::ClientError(msg) => {
312                msg.contains("timeout")
313                    || msg.contains("timed out")
314                    || msg.contains("connect")
315                    || msg.contains("connection")
316            }
317
318            // Gemini errors - only transient ones
319            AppError::GeminiError(details) => matches!(
320                details.kind,
321                GeminiErrorKind::RateLimit
322                    | GeminiErrorKind::NetworkError
323                    | GeminiErrorKind::ServerError
324            ),
325
326            // Non-transient errors - should NOT trip circuit
327            // Authentication, quota, validation errors indicate configuration
328            // problems, not temporary service issues
329            AppError::DatabaseError(_)
330            | AppError::SerializationError(_)
331            | AppError::InvalidUrl(_)
332            | AppError::DatasetNotFound(_)
333            | AppError::InvalidPortalUrl(_)
334            | AppError::EmptyResponse
335            | AppError::ConfigError(_)
336            | AppError::Generic(_) => false,
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_error_display() {
347        let err = AppError::DatasetNotFound("test-id".to_string());
348        assert_eq!(err.to_string(), "Dataset not found: test-id");
349    }
350
351    #[test]
352    fn test_generic_error() {
353        let err = AppError::Generic("Something went wrong".to_string());
354        assert_eq!(err.to_string(), "Error: Something went wrong");
355    }
356
357    #[test]
358    fn test_empty_response_error() {
359        let err = AppError::EmptyResponse;
360        assert_eq!(err.to_string(), "Empty response from API");
361    }
362
363    #[test]
364    fn test_user_message_gemini_auth() {
365        let details = GeminiErrorDetails::new(
366            GeminiErrorKind::Authentication,
367            "Invalid API key".to_string(),
368            401,
369        );
370        let err = AppError::GeminiError(details);
371        let msg = err.user_message();
372        assert!(msg.contains("Invalid Gemini API key"));
373        assert!(msg.contains("GEMINI_API_KEY"));
374    }
375
376    #[test]
377    fn test_user_message_gemini_rate_limit() {
378        let details = GeminiErrorDetails::new(
379            GeminiErrorKind::RateLimit,
380            "Rate limit exceeded".to_string(),
381            429,
382        );
383        let err = AppError::GeminiError(details);
384        let msg = err.user_message();
385        assert!(msg.contains("rate limit"));
386    }
387
388    #[test]
389    fn test_user_message_gemini_quota() {
390        let details = GeminiErrorDetails::new(
391            GeminiErrorKind::QuotaExceeded,
392            "Insufficient quota".to_string(),
393            429,
394        );
395        let err = AppError::GeminiError(details);
396        let msg = err.user_message();
397        assert!(msg.contains("quota exceeded"));
398        assert!(msg.contains("Google account billing"));
399    }
400
401    #[test]
402    fn test_gemini_error_display() {
403        let details = GeminiErrorDetails::new(
404            GeminiErrorKind::Authentication,
405            "Invalid API key".to_string(),
406            401,
407        );
408        let err = AppError::GeminiError(details);
409        assert!(err.to_string().contains("Gemini error"));
410        assert!(err.to_string().contains("401"));
411    }
412
413    #[test]
414    fn test_gemini_error_retryable() {
415        let rate_limit = AppError::GeminiError(GeminiErrorDetails::new(
416            GeminiErrorKind::RateLimit,
417            "Rate limit".to_string(),
418            429,
419        ));
420        assert!(rate_limit.is_retryable());
421
422        let auth_error = AppError::GeminiError(GeminiErrorDetails::new(
423            GeminiErrorKind::Authentication,
424            "Invalid key".to_string(),
425            401,
426        ));
427        assert!(!auth_error.is_retryable());
428
429        let server_error = AppError::GeminiError(GeminiErrorDetails::new(
430            GeminiErrorKind::ServerError,
431            "Internal server error".to_string(),
432            500,
433        ));
434        assert!(server_error.is_retryable());
435    }
436
437    #[test]
438    fn test_invalid_portal_url() {
439        let err = AppError::InvalidPortalUrl("not a url".to_string());
440        assert!(err.to_string().contains("Invalid CKAN portal URL"));
441    }
442
443    #[test]
444    fn test_error_from_serde() {
445        let json = "{ invalid json }";
446        let result: Result<serde_json::Value, _> = serde_json::from_str(json);
447        let serde_err = result.unwrap_err();
448        let app_err: AppError = serde_err.into();
449        assert!(matches!(app_err, AppError::SerializationError(_)));
450    }
451
452    #[test]
453    fn test_user_message_database_connection() {
454        // PoolTimedOut message contains "connection", so it triggers the connection error branch
455        let err = AppError::DatabaseError(sqlx::Error::PoolTimedOut);
456        let msg = err.user_message();
457        assert!(msg.contains("Cannot connect to database") || msg.contains("Database error"));
458    }
459
460    #[test]
461    fn test_is_retryable() {
462        assert!(AppError::NetworkError("timeout".to_string()).is_retryable());
463        assert!(AppError::Timeout(30).is_retryable());
464        assert!(AppError::RateLimitExceeded.is_retryable());
465        assert!(!AppError::InvalidPortalUrl("bad".to_string()).is_retryable());
466    }
467
468    #[test]
469    fn test_timeout_error() {
470        let err = AppError::Timeout(30);
471        assert_eq!(err.to_string(), "Request timed out after 30 seconds");
472    }
473
474    #[test]
475    fn test_should_trip_circuit_transient_errors() {
476        // Transient errors should trip the circuit
477        assert!(AppError::NetworkError("connection reset".to_string()).should_trip_circuit());
478        assert!(AppError::Timeout(30).should_trip_circuit());
479        assert!(AppError::RateLimitExceeded.should_trip_circuit());
480    }
481
482    #[test]
483    fn test_should_trip_circuit_client_errors() {
484        // Client errors with transient keywords should trip
485        assert!(AppError::ClientError("connection refused".to_string()).should_trip_circuit());
486        assert!(AppError::ClientError("request timed out".to_string()).should_trip_circuit());
487
488        // Client errors without transient keywords should NOT trip
489        assert!(!AppError::ClientError("invalid json".to_string()).should_trip_circuit());
490    }
491
492    #[test]
493    fn test_should_trip_circuit_gemini_errors() {
494        // Rate limit should trip
495        let rate_limit = AppError::GeminiError(GeminiErrorDetails::new(
496            GeminiErrorKind::RateLimit,
497            "Rate limit exceeded".to_string(),
498            429,
499        ));
500        assert!(rate_limit.should_trip_circuit());
501
502        // Server error should trip
503        let server_error = AppError::GeminiError(GeminiErrorDetails::new(
504            GeminiErrorKind::ServerError,
505            "Internal server error".to_string(),
506            500,
507        ));
508        assert!(server_error.should_trip_circuit());
509
510        // Network error should trip
511        let network_error = AppError::GeminiError(GeminiErrorDetails::new(
512            GeminiErrorKind::NetworkError,
513            "Connection failed".to_string(),
514            0,
515        ));
516        assert!(network_error.should_trip_circuit());
517
518        // Authentication should NOT trip
519        let auth_error = AppError::GeminiError(GeminiErrorDetails::new(
520            GeminiErrorKind::Authentication,
521            "Invalid API key".to_string(),
522            401,
523        ));
524        assert!(!auth_error.should_trip_circuit());
525
526        // Quota exceeded should NOT trip
527        let quota_error = AppError::GeminiError(GeminiErrorDetails::new(
528            GeminiErrorKind::QuotaExceeded,
529            "Insufficient quota".to_string(),
530            429,
531        ));
532        assert!(!quota_error.should_trip_circuit());
533    }
534
535    #[test]
536    fn test_should_trip_circuit_non_transient_errors() {
537        // These should NOT trip the circuit
538        assert!(!AppError::InvalidPortalUrl("bad url".to_string()).should_trip_circuit());
539        assert!(!AppError::DatasetNotFound("missing".to_string()).should_trip_circuit());
540        assert!(!AppError::InvalidUrl("bad".to_string()).should_trip_circuit());
541        assert!(!AppError::EmptyResponse.should_trip_circuit());
542        assert!(!AppError::ConfigError("bad config".to_string()).should_trip_circuit());
543        assert!(!AppError::Generic("something".to_string()).should_trip_circuit());
544    }
545}