Skip to main content

agui_rs_core/
error.rs

1use thiserror::Error;
2
3/// The unified error type for the AG-UI SDK.
4///
5/// The variant set is intentionally `#[non_exhaustive]` so new categories can be
6/// added without a breaking change. Match with a wildcard arm when consuming it
7/// outside of this crate.
8///
9/// Use [`AgUiError::is_retryable`] to decide whether an operation can be safely
10/// retried (e.g. with backoff), and [`AgUiError::is_user_input`] to distinguish
11/// caller mistakes from transient/transport failures.
12#[derive(Debug, Error)]
13#[non_exhaustive]
14pub enum AgUiError {
15    #[error("AG-UI protocol error: {0}")]
16    Protocol(String),
17
18    #[error("operation cancelled")]
19    Cancelled,
20
21    #[error("JSON serialization failed: {0}")]
22    Json(#[from] serde_json::Error),
23
24    #[error("connect not implemented for this agent")]
25    ConnectNotImplemented,
26
27    #[error("event validation failed: {0}")]
28    Validation(String),
29
30    #[error("unsupported operation: {0}")]
31    Unsupported(String),
32
33    /// A non-success HTTP status returned by an agent endpoint.
34    ///
35    /// `status` is the raw status code, kept transport-agnostic (the core crate
36    /// does not depend on any HTTP library). The response `body` and
37    /// `content_type` are preserved for diagnostics.
38    #[error("{}", format_http(*status, url.as_deref(), content_type.as_deref(), body))]
39    Http {
40        status: u16,
41        url: Option<String>,
42        content_type: Option<String>,
43        body: String,
44    },
45
46    /// A transport-level failure (connection refused, timeout, DNS, etc.).
47    ///
48    /// `retryable` is set by the layer that produced the error (which has access
49    /// to transport details) and is surfaced through [`AgUiError::is_retryable`].
50    #[error("transport error: {message}")]
51    Transport { message: String, retryable: bool },
52
53    #[error("{0}")]
54    Other(String),
55}
56
57impl AgUiError {
58    pub fn protocol(msg: impl Into<String>) -> Self {
59        Self::Protocol(msg.into())
60    }
61
62    pub fn cancelled() -> Self {
63        Self::Cancelled
64    }
65
66    pub fn validation(msg: impl Into<String>) -> Self {
67        Self::Validation(msg.into())
68    }
69
70    pub fn unsupported(msg: impl Into<String>) -> Self {
71        Self::Unsupported(msg.into())
72    }
73
74    pub fn other(msg: impl Into<String>) -> Self {
75        Self::Other(msg.into())
76    }
77
78    /// Builds an [`AgUiError::Http`] from a status code and response body.
79    pub fn http(status: u16, body: impl Into<String>) -> Self {
80        Self::Http {
81            status,
82            url: None,
83            content_type: None,
84            body: body.into(),
85        }
86    }
87
88    /// Builds a transport error, explicitly flagging whether a retry is sensible.
89    pub fn transport(msg: impl Into<String>, retryable: bool) -> Self {
90        Self::Transport {
91            message: msg.into(),
92            retryable,
93        }
94    }
95
96    /// The HTTP status code, if this error carries one.
97    pub fn status(&self) -> Option<u16> {
98        match self {
99            Self::Http { status, .. } => Some(*status),
100            _ => None,
101        }
102    }
103
104    /// Whether retrying the failed operation is likely to help.
105    ///
106    /// Retryable cases:
107    /// - transport failures flagged retryable (connect/timeout/request errors)
108    /// - HTTP `5xx` server errors
109    /// - HTTP `429 Too Many Requests` (rate limiting / throttling)
110    pub fn is_retryable(&self) -> bool {
111        match self {
112            Self::Transport { retryable, .. } => *retryable,
113            Self::Http { status, .. } => *status >= 500 || *status == 429,
114            _ => false,
115        }
116    }
117
118    /// Whether the error stems from invalid caller input rather than a
119    /// transient or transport condition.
120    pub fn is_user_input(&self) -> bool {
121        matches!(self, Self::Validation(_))
122    }
123}
124
125/// Renders the human-readable message for [`AgUiError::Http`], preserving the
126/// `HTTP <status> from <url> (content-type: <ct>) <body>` shape.
127fn format_http(status: u16, url: Option<&str>, content_type: Option<&str>, body: &str) -> String {
128    let mut message = format!("HTTP {status}");
129    if let Some(url) = url {
130        message.push_str(&format!(" from {url}"));
131    }
132    match content_type {
133        Some(content_type) if !content_type.is_empty() => {
134            message.push_str(&format!(" (content-type: {content_type})"));
135        }
136        _ => {}
137    }
138    if !body.is_empty() {
139        message.push(' ');
140        message.push_str(body);
141    }
142    message
143}
144
145pub type Result<T> = std::result::Result<T, AgUiError>;
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn http_error_message_includes_status_url_and_body() {
153        let error = AgUiError::Http {
154            status: 404,
155            url: Some("http://localhost:3000/".into()),
156            content_type: Some("application/json".into()),
157            body: r#"{"message":"User not found"}"#.into(),
158        };
159
160        let message = error.to_string();
161        assert!(message.contains("HTTP 404"));
162        assert!(message.contains("http://localhost:3000/"));
163        assert!(message.contains("application/json"));
164        assert!(message.contains("User not found"));
165    }
166
167    #[test]
168    fn http_error_message_omits_optional_parts() {
169        assert_eq!(AgUiError::http(503, "").to_string(), "HTTP 503");
170    }
171
172    #[test]
173    fn server_errors_and_rate_limit_are_retryable() {
174        assert!(AgUiError::http(500, "boom").is_retryable());
175        assert!(AgUiError::http(503, "boom").is_retryable());
176        assert!(AgUiError::http(429, "slow down").is_retryable());
177    }
178
179    #[test]
180    fn client_errors_are_not_retryable() {
181        assert!(!AgUiError::http(404, "nope").is_retryable());
182        assert!(!AgUiError::http(400, "bad").is_retryable());
183    }
184
185    #[test]
186    fn transport_retryability_is_explicit() {
187        assert!(AgUiError::transport("connection refused", true).is_retryable());
188        assert!(!AgUiError::transport("malformed", false).is_retryable());
189    }
190
191    #[test]
192    fn validation_is_user_input_and_not_retryable() {
193        let error = AgUiError::validation("bad field");
194        assert!(error.is_user_input());
195        assert!(!error.is_retryable());
196    }
197
198    #[test]
199    fn status_accessor_returns_code_only_for_http() {
200        assert_eq!(AgUiError::http(418, "teapot").status(), Some(418));
201        assert_eq!(AgUiError::other("x").status(), None);
202    }
203}