Skip to main content

agy_bridge/
error.rs

1//! Bridge error types and helpers for mapping Python exceptions to Rust errors.
2
3use std::time::Duration;
4
5use pyo3::prelude::*;
6
7use crate::streaming::StreamError;
8
9/// All errors that can occur in the bridge layer.
10#[non_exhaustive]
11#[derive(Debug, Clone, thiserror::Error)]
12pub enum Error {
13    /// The agent was not started or has been shut down before an operation was requested.
14    #[error("Agent is not started or has been shut down")]
15    AgentNotStarted,
16    /// An exception was raised in the backend.
17    #[error("Backend error: {message}")]
18    BackendError {
19        /// Formatted traceback or error message from backend.
20        message: String,
21    },
22
23    /// A connection-level error from the Antigravity SDK.
24    #[error("Connection error: {message}")]
25    ConnectionError {
26        /// Human-readable description of the connection failure.
27        message: String,
28    },
29
30    /// Quota / rate-limit error (HTTP 429 or equivalent).
31    #[error("Quota exceeded, retry after {retry_after:?}")]
32    QuotaExceeded {
33        /// Suggested wait duration before retrying.
34        retry_after: Duration,
35    },
36
37    /// The internal command channel was closed unexpectedly.
38    #[error("Channel closed: {message}")]
39    ChannelClosed {
40        /// Context about which channel closed.
41        message: String,
42    },
43
44    /// Connection was permanently closed.
45    #[error("Connection permanently closed: {message}")]
46    ConnectionClosed {
47        /// Human-readable descriptor.
48        message: String,
49    },
50
51    /// An operation exceeded its configured timeout.
52    #[error("Timeout after {duration:?}: {operation}")]
53    Timeout {
54        /// How long we waited before giving up.
55        duration: Duration,
56        /// Which operation timed out.
57        operation: String,
58    },
59
60    /// An error originating from the streaming response layer.
61    #[error(transparent)]
62    Stream(StreamError),
63
64    /// The provided configuration is invalid or self-contradictory.
65    #[error("Invalid configuration: {message}")]
66    InvalidConfig {
67        /// Human-readable description of the configuration issue.
68        message: String,
69    },
70
71    /// An I/O error occurred during a file or socket operation.
72    #[error("I/O error: {message}")]
73    Io {
74        /// The original I/O error message.
75        message: String,
76        /// The category of I/O error.
77        kind: std::io::ErrorKind,
78    },
79}
80
81impl Error {
82    /// Returns `true` if this error is potentially transient and the
83    /// operation may succeed if retried at a higher level.
84    ///
85    /// Currently retryable:
86    /// - [`Error::ConnectionError`] — network-level failures
87    /// - [`Error::QuotaExceeded`] — rate-limited, retry after backoff
88    /// - Backend errors containing HTTP 503 — server overload
89    ///
90    /// Classification only: the bridge itself is single-shot, so retrying is
91    /// the caller's responsibility (e.g. via `agent_resilience::backoff`).
92    #[must_use]
93    pub fn is_retryable(&self) -> bool {
94        match self {
95            Self::ConnectionError { .. } | Self::QuotaExceeded { .. } => true,
96            Self::BackendError { message } => message.contains("503"),
97            Self::Stream(se) => se.message.contains("503") || se.message.contains("429"),
98            _ => false,
99        }
100    }
101
102    /// Returns `true` if this error indicates a quota / rate-limit condition.
103    ///
104    /// Matches the structured [`Error::QuotaExceeded`] variant as well as
105    /// backend errors whose message contains HTTP 429, 503, or
106    /// `RESOURCE_EXHAUSTED` status indicators.
107    #[must_use]
108    pub fn is_quota_error(&self) -> bool {
109        match self {
110            Self::QuotaExceeded { .. } => true,
111            Self::BackendError { message } => {
112                message.contains("429")
113                    || message.contains("503")
114                    || message.contains("RESOURCE_EXHAUSTED")
115            }
116            Self::Stream(se) => {
117                se.message.contains("429")
118                    || se.message.contains("503")
119                    || se.message.contains("quota")
120                    || se.message.contains("RESOURCE_EXHAUSTED")
121            }
122            _ => false,
123        }
124    }
125}
126
127/// Converts a Python exception into the most specific [`Error`] variant.
128///
129/// Checks for Antigravity SDK errors (connection, validation), Pydantic
130/// validation errors, and Python `ImportError` before falling back to
131/// [`Error::BackendError`] with a formatted traceback.
132///
133/// This impl is always compiled because `pyo3` is a mandatory dependency of
134/// the bridge crate — the entire runtime requires it. If you depend on
135/// `agy-bridge` as a library, `pyo3` will be linked transitively.
136impl From<std::io::Error> for Error {
137    fn from(err: std::io::Error) -> Self {
138        Self::Io {
139            message: err.to_string(),
140            kind: err.kind(),
141        }
142    }
143}
144
145impl From<StreamError> for Error {
146    fn from(err: StreamError) -> Self {
147        Self::Stream(err)
148    }
149}
150
151#[doc(hidden)]
152impl From<PyErr> for Error {
153    fn from(err: PyErr) -> Self {
154        Python::attach(|py| classify_py_error(py, &err))
155    }
156}
157
158#[doc(hidden)]
159impl From<Error> for PyErr {
160    fn from(err: Error) -> Self {
161        pyo3::exceptions::PyRuntimeError::new_err(err.to_string())
162    }
163}
164
165/// Classify a Python exception into the most specific [`Error`] variant.
166///
167/// This is the single source of truth for mapping `PyErr` → [`Error`].
168/// Both the [`From<PyErr>`] impl and any call sites that hold a `&PyErr`
169/// (with the GIL already acquired) should use this function.
170pub(crate) fn classify_py_error(py: Python<'_>, err: &PyErr) -> Error {
171    if let Some(classified) = check_antigravity_error(py, err) {
172        return classified;
173    }
174    if let Some(classified) = check_pydantic_error(py, err) {
175        return classified;
176    }
177    if let Some(classified) = check_builtin_error(py, err) {
178        return classified;
179    }
180
181    let message = format_backend_error(py, err);
182    Error::BackendError { message }
183}
184
185fn check_antigravity_error(py: Python<'_>, err: &PyErr) -> Option<Error> {
186    match crate::runtime::py_scripts::import_serialized(py, "google.antigravity.types") {
187        Ok(types_mod) => {
188            // NOLINT: intentional fallthrough — if getattr fails, the type isn't available and we skip this check
189            if let Ok(conn_err_cls) = types_mod.getattr("AntigravityConnectionError")
190                && err.is_instance(py, &conn_err_cls)
191            {
192                return Some(Error::ConnectionError {
193                    message: err.to_string(),
194                });
195            }
196            // NOLINT: intentional fallthrough — if getattr fails, the type isn't available and we skip this check
197            if let Ok(val_err_cls) = types_mod.getattr("AntigravityValidationError")
198                && err.is_instance(py, &val_err_cls)
199            {
200                return Some(Error::BackendError {
201                    message: err.to_string(),
202                });
203            }
204        }
205        Err(import_err) => {
206            tracing::debug!(
207                error = %import_err,
208                "antigravity.types not available, skipping AntigravityError classification"
209            );
210        }
211    }
212    None
213}
214
215fn check_pydantic_error(py: Python<'_>, err: &PyErr) -> Option<Error> {
216    match crate::runtime::py_scripts::import_serialized(py, "pydantic") {
217        Ok(pydantic) => {
218            // NOLINT: intentional fallthrough — if getattr fails, the type isn't available and we skip this check
219            if let Ok(validation_err_cls) = pydantic.getattr("ValidationError")
220                && err.is_instance(py, &validation_err_cls)
221            {
222                return Some(Error::BackendError {
223                    message: err.to_string(),
224                });
225            }
226        }
227        Err(import_err) => {
228            tracing::debug!(
229                error = %import_err,
230                "pydantic not available, skipping ValidationError classification"
231            );
232        }
233    }
234    None
235}
236
237fn check_builtin_error(py: Python<'_>, err: &PyErr) -> Option<Error> {
238    // NOLINT: intentional fallthrough — if import fails, we skip the builtins check (logged in else)
239    if let Ok(builtins) = py.import("builtins") {
240        // NOLINT: intentional fallthrough — if getattr fails, the type isn't available and we skip this check
241        if let Ok(import_err_cls) = builtins.getattr("ImportError")
242            && err.is_instance(py, &import_err_cls)
243        {
244            return Some(Error::BackendError {
245                message: err.to_string(),
246            });
247        }
248    } else {
249        tracing::warn!("Failed to import Python builtins module, skipping ImportError check");
250    }
251    None
252}
253
254/// Format a backend exception into a human-readable string including traceback.
255fn format_backend_error(py: Python<'_>, err: &PyErr) -> String {
256    // Try to get the full traceback via traceback.format_exception.
257    let formatted = py
258        .import("traceback")
259        .and_then(|tb_mod| {
260            tb_mod.call_method1(
261                "format_exception",
262                (err.get_type(py), err.value(py), err.traceback(py)),
263            )
264        })
265        .and_then(|lines| lines.extract::<Vec<String>>());
266
267    match formatted {
268        Ok(lines) => lines.join(""),
269        Err(fmt_err) => {
270            tracing::warn!(error = %fmt_err, "Failed to format backend traceback, using fallback");
271            // Fall back to the inline traceback format that map_py_error used.
272            let traceback = err.traceback(py);
273            traceback.as_ref().map_or_else(
274                || err.to_string(),
275                |tb| {
276                    tb.format().map_or_else(
277                        |tb_fmt_err| {
278                            tracing::warn!(error = %tb_fmt_err, "Failed to format Python traceback");
279                            err.to_string()
280                        },
281                        |tb_str| format!("{}\nTraceback:\n{}", err.value(py), tb_str),
282                    )
283                },
284            )
285        }
286    }
287}
288
289/// Run `f` with a timeout. Returns `Error::Timeout` if the future
290/// does not complete within `timeout`.
291///
292/// # Errors
293///
294/// Returns `Error::Timeout` if the future exceeds the deadline,
295/// or propagates whatever error `f` itself returns.
296pub async fn with_timeout<F, T>(timeout: Duration, operation: &str, f: F) -> Result<T, Error>
297where
298    F: std::future::Future<Output = Result<T, Error>>,
299{
300    match tokio::time::timeout(timeout, f).await {
301        Ok(result) => result,
302        Err(_elapsed) => Err(Error::Timeout {
303            duration: timeout,
304            operation: operation.to_string(),
305        }),
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_stream_error_conversion() {
315        // All StreamErrors should pass through as Error::Stream — the bridge
316        // does not interpret or reclassify stream error messages.
317        let safety_err = StreamError {
318            message: "Step error (status=ERROR): Candidate blocked by safety".to_string(),
319        };
320        let mapped_safety = Error::from(safety_err);
321        assert!(
322            matches!(mapped_safety, Error::Stream(_)),
323            "StreamError with 'safety' should pass through as Error::Stream"
324        );
325
326        let max_tokens_err = StreamError {
327            message: "Step error (status=ERROR): Max tokens reached".to_string(),
328        };
329        let mapped_max_tokens = Error::from(max_tokens_err);
330        assert!(
331            matches!(mapped_max_tokens, Error::Stream(_)),
332            "StreamError with 'max tokens' should pass through as Error::Stream"
333        );
334
335        let other_err = StreamError {
336            message: "Some other connection issue".to_string(),
337        };
338        let mapped_other = Error::from(other_err);
339        match mapped_other {
340            Error::Stream(e) => {
341                assert_eq!(e.message, "Some other connection issue");
342            }
343            other => panic!("Expected Error::Stream, got: {other:?}"),
344        }
345    }
346
347    #[test]
348    fn test_backend_error_from_pyerr() {
349        Python::initialize();
350        let err = Python::attach(|py| {
351            let result: PyResult<()> = py.run(c"raise ValueError('test error 42')", None, None);
352            result.unwrap_err()
353        });
354
355        let bridge_err: Error = err.into();
356        match &bridge_err {
357            Error::BackendError { message } => {
358                assert!(
359                    message.contains("ValueError"),
360                    "Expected 'ValueError' in message, got: {message}"
361                );
362                assert!(
363                    message.contains("test error 42"),
364                    "Expected 'test error 42' in message, got: {message}"
365                );
366            }
367            other => panic!("Expected BackendError, got: {other:?}"),
368        }
369    }
370
371    #[tokio::test]
372    async fn test_timeout_triggers() {
373        let short_timeout = Duration::from_millis(50);
374        let result: Result<(), Error> = with_timeout(short_timeout, "test_op", async {
375            tokio::time::sleep(Duration::from_secs(10)).await;
376            Ok(())
377        })
378        .await;
379
380        match result {
381            Err(Error::Timeout {
382                duration,
383                operation,
384            }) => {
385                assert_eq!(duration, short_timeout);
386                assert_eq!(operation, "test_op");
387            }
388            other => panic!("Expected Timeout, got: {other:?}"),
389        }
390    }
391
392    #[tokio::test]
393    async fn test_timeout_succeeds_when_fast() {
394        let result = with_timeout(Duration::from_secs(5), "fast_op", async { Ok(42) }).await;
395        assert_eq!(result.unwrap(), 42);
396    }
397
398    #[test]
399    fn test_error_display_messages() {
400        let err = Error::BackendError {
401            message: "test".to_string(),
402        };
403        assert_eq!(format!("{err}"), "Backend error: test");
404
405        let err = Error::ConnectionError {
406            message: "lost".to_string(),
407        };
408        assert_eq!(format!("{err}"), "Connection error: lost");
409
410        let err = Error::QuotaExceeded {
411            retry_after: Duration::from_secs(5),
412        };
413        assert!(format!("{err}").contains("5s"));
414
415        let err = Error::ChannelClosed {
416            message: "cmd".to_string(),
417        };
418        assert_eq!(format!("{err}"), "Channel closed: cmd");
419
420        let err = Error::Timeout {
421            duration: Duration::from_secs(30),
422            operation: "chat".to_string(),
423        };
424        assert!(format!("{err}").contains("chat"));
425    }
426
427    #[tokio::test]
428    async fn test_timeout_propagates_inner_error() {
429        let result: Result<(), Error> = with_timeout(Duration::from_secs(10), "inner_err", async {
430            Err(Error::BackendError {
431                message: "inner failure".to_string(),
432            })
433        })
434        .await;
435
436        match result {
437            Err(Error::BackendError { message }) => {
438                assert_eq!(message, "inner failure");
439            }
440            other => panic!("Expected BackendError, got: {other:?}"),
441        }
442    }
443
444    #[test]
445    fn test_error_debug_format() {
446        let err = Error::BackendError {
447            message: "debug test".to_string(),
448        };
449        let debug = format!("{err:?}");
450        assert!(debug.contains("BackendError"));
451        assert!(debug.contains("debug test"));
452    }
453
454    #[test]
455    fn test_stream_error_from_conversion() {
456        let stream_err = StreamError {
457            message: "connection reset".to_string(),
458        };
459        let bridge_err = Error::from(stream_err);
460        match &bridge_err {
461            Error::Stream(inner) => {
462                assert_eq!(inner.message, "connection reset");
463            }
464            other => panic!("Expected Stream variant, got: {other:?}"),
465        }
466    }
467
468    #[test]
469    fn test_stream_error_display_through_bridge() {
470        let stream_err = StreamError {
471            message: "quota exceeded".to_string(),
472        };
473        let bridge_err = Error::from(stream_err);
474        let display = format!("{bridge_err}");
475        assert!(
476            display.contains("quota exceeded"),
477            "Expected 'quota exceeded' in display, got: {display}"
478        );
479    }
480
481    #[test]
482    fn test_is_retryable_connection_error() {
483        let err = Error::ConnectionError {
484            message: "timeout".to_string(),
485        };
486        assert!(err.is_retryable());
487    }
488
489    #[test]
490    fn test_quota_exceeded_is_retryable() {
491        let err = Error::QuotaExceeded {
492            retry_after: Duration::from_secs(5),
493        };
494        assert!(err.is_retryable());
495    }
496
497    #[test]
498    fn test_is_not_retryable_backend_error() {
499        let err = Error::BackendError {
500            message: "kaboom".to_string(),
501        };
502        assert!(!err.is_retryable());
503    }
504
505    #[test]
506    fn test_is_not_retryable_channel_closed() {
507        let err = Error::ChannelClosed {
508            message: "gone".to_string(),
509        };
510        assert!(!err.is_retryable());
511    }
512
513    #[test]
514    fn test_is_not_retryable_timeout() {
515        let err = Error::Timeout {
516            duration: Duration::from_secs(30),
517            operation: "chat".to_string(),
518        };
519        assert!(!err.is_retryable());
520    }
521
522    #[test]
523    fn test_is_not_retryable_stream() {
524        let err = Error::Stream(StreamError {
525            message: "stream failed".to_string(),
526        });
527        assert!(!err.is_retryable());
528    }
529
530    #[test]
531    fn test_is_retryable_503_backend_error() {
532        let err = Error::BackendError {
533            message: "request failed (code 503): high demand".to_string(),
534        };
535        assert!(err.is_retryable());
536    }
537
538    #[test]
539    fn test_is_quota_error_quota_exceeded() {
540        let err = Error::QuotaExceeded {
541            retry_after: Duration::from_secs(5),
542        };
543        assert!(err.is_quota_error());
544    }
545
546    #[test]
547    fn test_is_quota_error_backend_429() {
548        let err = Error::BackendError {
549            message: "HTTP 429 Too Many Requests".to_string(),
550        };
551        assert!(err.is_quota_error());
552    }
553
554    #[test]
555    fn test_is_quota_error_resource_exhausted() {
556        let err = Error::BackendError {
557            message: "RESOURCE_EXHAUSTED: quota exceeded".to_string(),
558        };
559        assert!(err.is_quota_error());
560    }
561
562    #[test]
563    fn test_is_not_quota_error_connection() {
564        let err = Error::ConnectionError {
565            message: "timeout".to_string(),
566        };
567        assert!(!err.is_quota_error());
568    }
569
570    #[test]
571    fn test_is_not_quota_error_normal_backend() {
572        let err = Error::BackendError {
573            message: "something else".to_string(),
574        };
575        assert!(!err.is_quota_error());
576    }
577
578    #[test]
579    fn test_is_quota_error_503_high_demand() {
580        let err = Error::BackendError {
581            message: "request failed (code 503): This model is currently experiencing high demand"
582                .to_string(),
583        };
584        assert!(err.is_quota_error());
585    }
586}