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("Error: {0}")]
167 Generic(String),
168}
169
170impl AppError {
171 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 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 pub fn should_trip_circuit(&self) -> bool {
305 match self {
306 AppError::NetworkError(_) | AppError::Timeout(_) | AppError::RateLimitExceeded => true,
308
309 AppError::ClientError(msg) => {
311 msg.contains("timeout")
312 || msg.contains("timed out")
313 || msg.contains("connect")
314 || msg.contains("connection")
315 }
316
317 AppError::GeminiError(details) => matches!(
319 details.kind,
320 GeminiErrorKind::RateLimit
321 | GeminiErrorKind::NetworkError
322 | GeminiErrorKind::ServerError
323 ),
324
325 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 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 assert!(AppError::ClientError("connection refused".to_string()).should_trip_circuit());
484 assert!(AppError::ClientError("request timed out".to_string()).should_trip_circuit());
485
486 assert!(!AppError::ClientError("invalid json".to_string()).should_trip_circuit());
488 }
489
490 #[test]
491 fn test_should_trip_circuit_gemini_errors() {
492 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 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 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 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 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 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}