1use thiserror::Error;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GeminiErrorKind {
33 Authentication,
35 RateLimit,
37 QuotaExceeded,
39 ServerError,
41 NetworkError,
43 Unknown,
45}
46
47#[derive(Debug, Clone)]
49pub struct GeminiErrorDetails {
50 pub kind: GeminiErrorKind,
52 pub message: String,
54 pub status_code: u16,
56}
57
58impl GeminiErrorDetails {
59 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 #[error("Database error: {0}")]
86 DatabaseError(#[from] sqlx::Error),
87
88 #[error("API Client error: {0}")]
93 ClientError(String),
94
95 #[error("Gemini error: {0}")]
101 GeminiError(GeminiErrorDetails),
102
103 #[error("Serialization error: {0}")]
108 SerializationError(#[from] serde_json::Error),
109
110 #[error("Invalid URL: {0}")]
115 InvalidUrl(String),
116
117 #[error("Dataset not found: {0}")]
121 DatasetNotFound(String),
122
123 #[error("Invalid CKAN portal URL: {0}")]
128 InvalidPortalUrl(String),
129
130 #[error("Empty response from API")]
135 EmptyResponse,
136
137 #[error("Network error: {0}")]
142 NetworkError(String),
143
144 #[error("Request timed out after {0} seconds")]
148 Timeout(u64),
149
150 #[error("Rate limit exceeded. Please wait and try again.")]
154 RateLimitExceeded,
155
156 #[error("Configuration error: {0}")]
161 ConfigError(String),
162
163 #[error("Error: {0}")]
168 Generic(String),
169}
170
171impl AppError {
172 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 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 pub fn should_trip_circuit(&self) -> bool {
306 match self {
307 AppError::NetworkError(_) | AppError::Timeout(_) | AppError::RateLimitExceeded => true,
309
310 AppError::ClientError(msg) => {
312 msg.contains("timeout")
313 || msg.contains("timed out")
314 || msg.contains("connect")
315 || msg.contains("connection")
316 }
317
318 AppError::GeminiError(details) => matches!(
320 details.kind,
321 GeminiErrorKind::RateLimit
322 | GeminiErrorKind::NetworkError
323 | GeminiErrorKind::ServerError
324 ),
325
326 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 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 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 assert!(AppError::ClientError("connection refused".to_string()).should_trip_circuit());
486 assert!(AppError::ClientError("request timed out".to_string()).should_trip_circuit());
487
488 assert!(!AppError::ClientError("invalid json".to_string()).should_trip_circuit());
490 }
491
492 #[test]
493 fn test_should_trip_circuit_gemini_errors() {
494 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 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 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 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 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 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}