1use std::time::Duration;
4
5use pyo3::prelude::*;
6
7use crate::streaming::StreamError;
8
9pub const HTTP_TOO_MANY_REQUESTS: u16 = 429;
11
12pub const HTTP_SERVER_ERROR_MIN: u16 = 500;
14
15pub const HTTP_SERVICE_UNAVAILABLE: u16 = 503;
17
18pub const HTTP_SERVER_ERROR_MAX: u16 = 599;
20
21pub const HTTP_CODE_UNKNOWN: u16 = 0;
23
24const PY_CLASS_ANTIGRAVITY_CONNECTION_ERROR: &str = "AntigravityConnectionError";
26
27const PY_CLASS_ANTIGRAVITY_VALIDATION_ERROR: &str = "AntigravityValidationError";
29
30const PY_CLASS_PYDANTIC_VALIDATION_ERROR: &str = "ValidationError";
32
33const PY_MODULE_TRACEBACK: &str = "traceback";
35
36const PY_FN_FORMAT_EXCEPTION: &str = "format_exception";
38
39#[non_exhaustive]
41#[derive(Debug, Clone, thiserror::Error)]
42pub enum Error {
43 #[error("Agent is not started or has been shut down")]
45 AgentNotStarted,
46 #[error("Backend error: {message}")]
48 BackendError {
49 message: String,
51 },
52
53 #[error("Connection error: {message}")]
55 ConnectionError {
56 message: String,
58 },
59
60 #[error("Quota exceeded, retry after {retry_after:?}")]
62 QuotaExceeded {
63 retry_after: Duration,
65 },
66
67 #[error("Channel closed: {message}")]
69 ChannelClosed {
70 message: String,
72 },
73
74 #[error("Connection permanently closed: {message}")]
76 ConnectionClosed {
77 message: String,
79 },
80
81 #[error("Timeout after {duration:?}: {operation}")]
83 Timeout {
84 duration: Duration,
86 operation: String,
88 },
89
90 #[error(transparent)]
92 Stream(StreamError),
93
94 #[error("Invalid configuration: {message}")]
96 InvalidConfig {
97 message: String,
99 },
100
101 #[error("I/O error: {message}")]
103 Io {
104 message: String,
106 kind: std::io::ErrorKind,
108 },
109}
110
111impl Error {
112 #[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 #[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#[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#[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
177impl 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
215pub(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
278fn format_backend_error(py: Python<'_>, err: &PyErr) -> String {
280 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 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
308pub 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 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 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 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 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 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 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}