1use thiserror::Error;
2
3#[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 #[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 #[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 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 pub fn transport(msg: impl Into<String>, retryable: bool) -> Self {
90 Self::Transport {
91 message: msg.into(),
92 retryable,
93 }
94 }
95
96 pub fn status(&self) -> Option<u16> {
98 match self {
99 Self::Http { status, .. } => Some(*status),
100 _ => None,
101 }
102 }
103
104 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 pub fn is_user_input(&self) -> bool {
121 matches!(self, Self::Validation(_))
122 }
123}
124
125fn 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}