1use std::time::Duration;
4
5use pyo3::prelude::*;
6
7use crate::streaming::StreamError;
8
9#[non_exhaustive]
11#[derive(Debug, Clone, thiserror::Error)]
12pub enum Error {
13 #[error("Agent is not started or has been shut down")]
15 AgentNotStarted,
16 #[error("Backend error: {message}")]
18 BackendError {
19 message: String,
21 },
22
23 #[error("Connection error: {message}")]
25 ConnectionError {
26 message: String,
28 },
29
30 #[error("Quota exceeded, retry after {retry_after:?}")]
32 QuotaExceeded {
33 retry_after: Duration,
35 },
36
37 #[error("Channel closed: {message}")]
39 ChannelClosed {
40 message: String,
42 },
43
44 #[error("Connection permanently closed: {message}")]
46 ConnectionClosed {
47 message: String,
49 },
50
51 #[error("Timeout after {duration:?}: {operation}")]
53 Timeout {
54 duration: Duration,
56 operation: String,
58 },
59
60 #[error(transparent)]
62 Stream(StreamError),
63
64 #[error("Invalid configuration: {message}")]
66 InvalidConfig {
67 message: String,
69 },
70
71 #[error("I/O error: {message}")]
73 Io {
74 message: String,
76 kind: std::io::ErrorKind,
78 },
79}
80
81impl Error {
82 #[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 #[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
127impl 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
165pub(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 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 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 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 if let Ok(builtins) = py.import("builtins") {
240 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
254fn format_backend_error(py: Python<'_>, err: &PyErr) -> String {
256 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 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
289pub 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 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}