genai_rs/
errors.rs

1use thiserror::Error;
2
3/// Defines errors that can occur when interacting with the GenAI API.
4///
5/// # Example: Handling API Errors
6///
7/// ```ignore
8/// match client.interaction().create().await {
9///     Err(GenaiError::Api { status_code: 429, request_id, .. }) => {
10///         tracing::warn!("Rate limited, request_id: {:?}", request_id);
11///         // Retry with backoff
12///     }
13///     Err(GenaiError::Api { status_code, message, request_id }) => {
14///         tracing::error!("API error {}: {} (request: {:?})", status_code, message, request_id);
15///     }
16///     // ...
17/// }
18/// ```
19#[derive(Debug, Error)]
20#[non_exhaustive]
21pub enum GenaiError {
22    #[error("HTTP request error: {0}")]
23    Http(#[from] reqwest::Error),
24    #[error("SSE parsing error: {0}")]
25    Parse(String),
26    #[error("JSON deserialization error: {0}")]
27    Json(#[from] serde_json::Error),
28    #[error("UTF-8 decoding error: {0}")]
29    Utf8(#[from] std::str::Utf8Error),
30    /// API error with structured context for debugging and automated handling.
31    ///
32    /// Contains the HTTP status code (for retry logic), error message, and
33    /// optional request ID (for correlation with Google API logs/support).
34    #[error("API error (HTTP {status_code}): {message}")]
35    Api {
36        /// HTTP status code (e.g., 400, 429, 500)
37        status_code: u16,
38        /// Error message from the API response body
39        message: String,
40        /// Request ID from `x-goog-request-id` header, if available
41        request_id: Option<String>,
42        /// Retry delay from `Retry-After` header (for 429 rate limit errors).
43        ///
44        /// When present, this indicates how long to wait before retrying.
45        /// The value is parsed from the `Retry-After` header, which can be:
46        /// - Seconds (e.g., "120" → 120 seconds)
47        /// - HTTP date (e.g., "Wed, 21 Oct 2015 07:28:00 GMT" → duration until then)
48        ///
49        /// This field is typically only populated for 429 (Too Many Requests) errors.
50        retry_after: Option<std::time::Duration>,
51    },
52    #[error("Internal client error: {0}")]
53    Internal(String),
54    #[error("Invalid input: {0}")]
55    InvalidInput(String),
56    /// API returned a successful response but with unexpected or invalid content.
57    ///
58    /// This indicates the API response didn't match the expected schema,
59    /// possibly due to API evolution or an undocumented response format.
60    /// Unlike `InvalidInput` (user's fault), this represents an issue with
61    /// the API response itself.
62    #[error("Malformed API response: {0}")]
63    MalformedResponse(String),
64    /// Request timed out after the specified duration.
65    ///
66    /// This error is returned when a request exceeds the timeout configured
67    /// via `with_timeout()`. The duration indicates how long the request
68    /// was allowed to run before being cancelled.
69    #[error("Request timed out after {0:?}")]
70    Timeout(std::time::Duration),
71    /// Failed to build the HTTP client.
72    ///
73    /// This typically only occurs in exceptional circumstances such as
74    /// TLS backend initialization failures.
75    #[error("Failed to build HTTP client: {0}")]
76    ClientBuild(String),
77}
78
79impl GenaiError {
80    /// Returns `true` if this error is likely transient and the request may succeed on retry.
81    ///
82    /// This helper identifies errors that are typically recoverable:
83    /// - **HTTP errors**: Network issues, connection resets, TLS errors
84    /// - **Rate limits (429)**: Temporary throttling, retry after backoff
85    /// - **Server errors (5xx)**: Temporary server issues
86    /// - **Timeouts**: Request took too long but may succeed with retry
87    ///
88    /// Errors that return `false` are typically permanent and retrying won't help:
89    /// - **Client errors (4xx except 429)**: Bad request, unauthorized, not found
90    /// - **Parse/JSON errors**: Response format issues
91    /// - **Invalid input**: Request validation failures
92    /// - **Malformed response**: API contract violations
93    ///
94    /// # Example
95    ///
96    /// ```rust
97    /// use genai_rs::GenaiError;
98    /// use std::time::Duration;
99    ///
100    /// fn should_retry(error: &GenaiError, attempt: u32, max_attempts: u32) -> bool {
101    ///     attempt < max_attempts && error.is_retryable()
102    /// }
103    ///
104    /// // Rate limit errors are retryable
105    /// let rate_limited = GenaiError::Api {
106    ///     status_code: 429,
107    ///     message: "Resource exhausted".to_string(),
108    ///     request_id: None,
109    ///     retry_after: Some(std::time::Duration::from_secs(60)),
110    /// };
111    /// assert!(rate_limited.is_retryable());
112    ///
113    /// // Bad request errors are not retryable
114    /// let bad_request = GenaiError::Api {
115    ///     status_code: 400,
116    ///     message: "Invalid model".to_string(),
117    ///     request_id: None,
118    ///     retry_after: None,
119    /// };
120    /// assert!(!bad_request.is_retryable());
121    ///
122    /// // Timeouts are retryable
123    /// let timeout = GenaiError::Timeout(Duration::from_secs(30));
124    /// assert!(timeout.is_retryable());
125    /// ```
126    ///
127    /// # Retry Strategy
128    ///
129    /// When implementing retry logic, consider:
130    /// - Use exponential backoff with jitter
131    /// - Set a maximum number of retries
132    /// - For 429 errors, use the `retry_after` field if available (extracted from `Retry-After` header)
133    /// - Log retries for observability
134    ///
135    /// See `examples/retry_with_backoff.rs` for a complete retry implementation.
136    #[must_use]
137    pub fn is_retryable(&self) -> bool {
138        match self {
139            // Network-level errors are typically transient
140            GenaiError::Http(_) => true,
141
142            // API errors: 429 (rate limit) and 5xx (server errors) are retryable
143            GenaiError::Api { status_code, .. } => *status_code == 429 || *status_code >= 500,
144
145            // Timeouts may succeed on retry
146            GenaiError::Timeout(_) => true,
147
148            // These are permanent errors - retrying won't help
149            GenaiError::Parse(_)
150            | GenaiError::Json(_)
151            | GenaiError::Utf8(_)
152            | GenaiError::Internal(_)
153            | GenaiError::InvalidInput(_)
154            | GenaiError::MalformedResponse(_)
155            | GenaiError::ClientBuild(_) => false,
156        }
157    }
158
159    /// Returns the server-suggested retry delay for rate limit (429) errors.
160    ///
161    /// This extracts the `Retry-After` header value that was parsed when the error
162    /// was created. Only `Api` errors with status code 429 typically have this field set.
163    ///
164    /// # Returns
165    ///
166    /// - `Some(Duration)` if the error has a `retry_after` value
167    /// - `None` for all other error types or if no `Retry-After` header was present
168    ///
169    /// # Example
170    ///
171    /// ```rust
172    /// use genai_rs::GenaiError;
173    /// use std::time::Duration;
174    ///
175    /// async fn with_server_suggested_delay(error: &GenaiError) {
176    ///     if let Some(delay) = error.retry_after() {
177    ///         // Server told us how long to wait
178    ///         tokio::time::sleep(delay).await;
179    ///     } else {
180    ///         // Fall back to our own backoff strategy
181    ///         tokio::time::sleep(Duration::from_secs(1)).await;
182    ///     }
183    /// }
184    /// ```
185    #[must_use]
186    pub fn retry_after(&self) -> Option<std::time::Duration> {
187        match self {
188            GenaiError::Api { retry_after, .. } => *retry_after,
189            _ => None,
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_genai_error_parse_display() {
200        let error = GenaiError::Parse("Invalid SSE format".to_string());
201        let display = format!("{}", error);
202        assert!(display.contains("SSE parsing error"));
203        assert!(display.contains("Invalid SSE format"));
204    }
205
206    #[test]
207    fn test_genai_error_api_display() {
208        let error = GenaiError::Api {
209            status_code: 429,
210            message: "Rate limited".to_string(),
211            request_id: Some("req-123".to_string()),
212            retry_after: None,
213        };
214        let display = format!("{}", error);
215        assert!(display.contains("429"));
216        assert!(display.contains("Rate limited"));
217    }
218
219    #[test]
220    fn test_genai_error_api_without_request_id() {
221        let error = GenaiError::Api {
222            status_code: 500,
223            message: "Internal error".to_string(),
224            request_id: None,
225            retry_after: None,
226        };
227        let display = format!("{}", error);
228        assert!(display.contains("500"));
229        assert!(display.contains("Internal error"));
230    }
231
232    #[test]
233    fn test_genai_error_internal_display() {
234        let error = GenaiError::Internal("Max function loops exceeded".to_string());
235        let display = format!("{}", error);
236        assert!(display.contains("Internal client error"));
237        assert!(display.contains("Max function loops exceeded"));
238    }
239
240    #[test]
241    fn test_genai_error_invalid_input_display() {
242        let error = GenaiError::InvalidInput("Missing model or agent".to_string());
243        let display = format!("{}", error);
244        assert!(display.contains("Invalid input"));
245        assert!(display.contains("Missing model or agent"));
246    }
247
248    #[test]
249    fn test_genai_error_json_from() {
250        let json_str = "not valid json";
251        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
252        let genai_err: GenaiError = json_err.into();
253        let display = format!("{}", genai_err);
254        assert!(display.contains("JSON deserialization error"));
255    }
256
257    #[test]
258    fn test_genai_error_utf8_from() {
259        // Create an invalid UTF-8 byte sequence
260        let bytes = vec![0xff, 0xfe];
261        let utf8_err = std::str::from_utf8(&bytes).unwrap_err();
262        let genai_err: GenaiError = utf8_err.into();
263        let display = format!("{}", genai_err);
264        assert!(display.contains("UTF-8 decoding error"));
265    }
266
267    #[test]
268    fn test_genai_error_debug_format() {
269        let error = GenaiError::Api {
270            status_code: 400,
271            message: "Bad request".to_string(),
272            request_id: Some("req-456".to_string()),
273            retry_after: None,
274        };
275        let debug = format!("{:?}", error);
276        assert!(debug.contains("Api"));
277        assert!(debug.contains("400"));
278        assert!(debug.contains("req-456"));
279    }
280
281    #[test]
282    fn test_genai_error_api_status_codes() {
283        // Test common HTTP status codes
284        let status_codes = [
285            (400, "Bad Request"),
286            (401, "Unauthorized"),
287            (403, "Forbidden"),
288            (404, "Not Found"),
289            (429, "Too Many Requests"),
290            (500, "Internal Server Error"),
291            (503, "Service Unavailable"),
292        ];
293
294        for (code, message) in status_codes {
295            let error = GenaiError::Api {
296                status_code: code,
297                message: message.to_string(),
298                request_id: None,
299                retry_after: None,
300            };
301            let display = format!("{}", error);
302            assert!(
303                display.contains(&code.to_string()),
304                "Expected {} in display: {}",
305                code,
306                display
307            );
308        }
309    }
310
311    #[test]
312    fn test_genai_error_api_with_empty_message() {
313        // Some APIs might return empty error messages
314        let error = GenaiError::Api {
315            status_code: 500,
316            message: "".to_string(),
317            request_id: None,
318            retry_after: None,
319        };
320        let display = format!("{}", error);
321        assert!(display.contains("500"));
322        // Should still display properly even with empty message
323        assert!(display.contains("API error"));
324    }
325
326    #[test]
327    fn test_genai_error_malformed_response_display() {
328        let error = GenaiError::MalformedResponse(
329            "Function call 'get_weather' is missing required call_id field".to_string(),
330        );
331        let display = format!("{}", error);
332        assert!(display.contains("Malformed API response"));
333        assert!(display.contains("call_id"));
334    }
335
336    #[test]
337    fn test_genai_error_malformed_response_stream() {
338        let error =
339            GenaiError::MalformedResponse("Stream ended without Complete event".to_string());
340        let display = format!("{}", error);
341        assert!(display.contains("Malformed API response"));
342        assert!(display.contains("Complete event"));
343    }
344
345    #[test]
346    fn test_genai_error_timeout_display() {
347        let error = GenaiError::Timeout(std::time::Duration::from_secs(30));
348        let display = format!("{}", error);
349        assert!(display.contains("Request timed out"));
350        assert!(display.contains("30s"));
351    }
352
353    #[test]
354    fn test_genai_error_timeout_debug() {
355        let error = GenaiError::Timeout(std::time::Duration::from_millis(500));
356        let debug = format!("{:?}", error);
357        assert!(debug.contains("Timeout"));
358        assert!(debug.contains("500ms"));
359    }
360
361    #[test]
362    fn test_genai_error_client_build_display() {
363        let error = GenaiError::ClientBuild("TLS initialization failed".to_string());
364        let display = format!("{}", error);
365        assert!(display.contains("Failed to build HTTP client"));
366        assert!(display.contains("TLS initialization failed"));
367    }
368
369    #[test]
370    fn test_genai_error_client_build_debug() {
371        let error = GenaiError::ClientBuild("some error".to_string());
372        let debug = format!("{:?}", error);
373        assert!(debug.contains("ClientBuild"));
374        assert!(debug.contains("some error"));
375    }
376
377    // =============================================================================
378    // is_retryable() Tests
379    // =============================================================================
380
381    #[test]
382    fn test_is_retryable_rate_limit_429() {
383        let error = GenaiError::Api {
384            status_code: 429,
385            message: "Resource exhausted".to_string(),
386            request_id: None,
387            retry_after: Some(std::time::Duration::from_secs(60)),
388        };
389        assert!(error.is_retryable(), "429 errors should be retryable");
390    }
391
392    #[test]
393    fn test_is_retryable_server_errors_5xx() {
394        for status_code in [500, 502, 503, 504] {
395            let error = GenaiError::Api {
396                status_code,
397                message: "Server error".to_string(),
398                request_id: None,
399                retry_after: None,
400            };
401            assert!(
402                error.is_retryable(),
403                "{} errors should be retryable",
404                status_code
405            );
406        }
407    }
408
409    #[test]
410    fn test_is_retryable_client_errors_4xx_not_retryable() {
411        // Client errors (except 429) should NOT be retryable
412        for status_code in [400, 401, 403, 404, 422] {
413            let error = GenaiError::Api {
414                status_code,
415                message: "Client error".to_string(),
416                request_id: None,
417                retry_after: None,
418            };
419            assert!(
420                !error.is_retryable(),
421                "{} errors should NOT be retryable",
422                status_code
423            );
424        }
425    }
426
427    #[test]
428    fn test_is_retryable_timeout() {
429        let error = GenaiError::Timeout(std::time::Duration::from_secs(30));
430        assert!(error.is_retryable(), "Timeout errors should be retryable");
431    }
432
433    #[test]
434    fn test_is_retryable_parse_error_not_retryable() {
435        let error = GenaiError::Parse("Invalid SSE".to_string());
436        assert!(
437            !error.is_retryable(),
438            "Parse errors should NOT be retryable"
439        );
440    }
441
442    #[test]
443    fn test_is_retryable_json_error_not_retryable() {
444        let json_str = "not valid json";
445        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
446        let error: GenaiError = json_err.into();
447        assert!(!error.is_retryable(), "JSON errors should NOT be retryable");
448    }
449
450    #[test]
451    fn test_is_retryable_invalid_input_not_retryable() {
452        let error = GenaiError::InvalidInput("Missing model".to_string());
453        assert!(
454            !error.is_retryable(),
455            "InvalidInput errors should NOT be retryable"
456        );
457    }
458
459    #[test]
460    fn test_is_retryable_malformed_response_not_retryable() {
461        let error = GenaiError::MalformedResponse("Missing call_id".to_string());
462        assert!(
463            !error.is_retryable(),
464            "MalformedResponse errors should NOT be retryable"
465        );
466    }
467
468    #[test]
469    fn test_is_retryable_internal_error_not_retryable() {
470        let error = GenaiError::Internal("Max loops exceeded".to_string());
471        assert!(
472            !error.is_retryable(),
473            "Internal errors should NOT be retryable"
474        );
475    }
476
477    #[test]
478    fn test_is_retryable_client_build_not_retryable() {
479        let error = GenaiError::ClientBuild("TLS init failed".to_string());
480        assert!(
481            !error.is_retryable(),
482            "ClientBuild errors should NOT be retryable"
483        );
484    }
485
486    #[test]
487    fn test_is_retryable_utf8_error_not_retryable() {
488        let bytes = vec![0xff, 0xfe];
489        let utf8_err = std::str::from_utf8(&bytes).unwrap_err();
490        let error: GenaiError = utf8_err.into();
491        assert!(
492            !error.is_retryable(),
493            "UTF-8 errors should NOT be retryable"
494        );
495    }
496}