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