1use serde_json::{Value, json};
8use turul_a2a_types::wire::errors;
9
10#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum A2aError {
17 #[error("Task not found: {task_id}")]
18 TaskNotFound { task_id: String },
19
20 #[error("Task not cancelable: {task_id}")]
21 TaskNotCancelable { task_id: String },
22
23 #[error("Push notifications not supported")]
24 PushNotificationNotSupported,
25
26 #[error("Unsupported operation: {message}")]
27 UnsupportedOperation { message: String },
28
29 #[error("Content type not supported: {content_type}")]
30 ContentTypeNotSupported { content_type: String },
31
32 #[error("Invalid agent response: {message}")]
33 InvalidAgentResponse { message: String },
34
35 #[error("Extended agent card not configured")]
36 ExtendedAgentCardNotConfigured,
37
38 #[error("Extension support required: {extension}")]
39 ExtensionSupportRequired { extension: String },
40
41 #[error("Version not supported: {version}")]
42 VersionNotSupported { version: String },
43
44 #[error("Invalid request: {message}")]
45 InvalidRequest { message: String },
46
47 #[error("Internal error: {0}")]
48 Internal(String),
49}
50
51impl A2aError {
52 pub fn http_status(&self) -> u16 {
54 match self {
55 Self::TaskNotFound { .. } => errors::HTTP_TASK_NOT_FOUND,
56 Self::TaskNotCancelable { .. } => errors::HTTP_TASK_NOT_CANCELABLE,
57 Self::PushNotificationNotSupported => errors::HTTP_PUSH_NOTIFICATION_NOT_SUPPORTED,
58 Self::UnsupportedOperation { .. } => errors::HTTP_UNSUPPORTED_OPERATION,
59 Self::ContentTypeNotSupported { .. } => errors::HTTP_CONTENT_TYPE_NOT_SUPPORTED,
60 Self::InvalidAgentResponse { .. } => errors::HTTP_INVALID_AGENT_RESPONSE,
61 Self::ExtendedAgentCardNotConfigured => errors::HTTP_EXTENDED_AGENT_CARD_NOT_CONFIGURED,
62 Self::ExtensionSupportRequired { .. } => errors::HTTP_EXTENSION_SUPPORT_REQUIRED,
63 Self::VersionNotSupported { .. } => errors::HTTP_VERSION_NOT_SUPPORTED,
64 Self::InvalidRequest { .. } => 400,
65 Self::Internal(_) => 500,
66 }
67 }
68
69 pub fn jsonrpc_code(&self) -> i32 {
72 match self {
73 Self::TaskNotFound { .. } => errors::JSONRPC_TASK_NOT_FOUND,
74 Self::TaskNotCancelable { .. } => errors::JSONRPC_TASK_NOT_CANCELABLE,
75 Self::PushNotificationNotSupported => errors::JSONRPC_PUSH_NOTIFICATION_NOT_SUPPORTED,
76 Self::UnsupportedOperation { .. } => errors::JSONRPC_UNSUPPORTED_OPERATION,
77 Self::ContentTypeNotSupported { .. } => errors::JSONRPC_CONTENT_TYPE_NOT_SUPPORTED,
78 Self::InvalidAgentResponse { .. } => errors::JSONRPC_INVALID_AGENT_RESPONSE,
79 Self::ExtendedAgentCardNotConfigured => {
80 errors::JSONRPC_EXTENDED_AGENT_CARD_NOT_CONFIGURED
81 }
82 Self::ExtensionSupportRequired { .. } => errors::JSONRPC_EXTENSION_SUPPORT_REQUIRED,
83 Self::VersionNotSupported { .. } => errors::JSONRPC_VERSION_NOT_SUPPORTED,
84 Self::InvalidRequest { .. } => -32602, Self::Internal(_) => -32603, }
87 }
88
89 pub fn error_reason(&self) -> Option<&'static str> {
92 match self {
93 Self::TaskNotFound { .. } => Some(errors::REASON_TASK_NOT_FOUND),
94 Self::TaskNotCancelable { .. } => Some(errors::REASON_TASK_NOT_CANCELABLE),
95 Self::PushNotificationNotSupported => {
96 Some(errors::REASON_PUSH_NOTIFICATION_NOT_SUPPORTED)
97 }
98 Self::UnsupportedOperation { .. } => Some(errors::REASON_UNSUPPORTED_OPERATION),
99 Self::ContentTypeNotSupported { .. } => Some(errors::REASON_CONTENT_TYPE_NOT_SUPPORTED),
100 Self::InvalidAgentResponse { .. } => Some(errors::REASON_INVALID_AGENT_RESPONSE),
101 Self::ExtendedAgentCardNotConfigured => {
102 Some(errors::REASON_EXTENDED_AGENT_CARD_NOT_CONFIGURED)
103 }
104 Self::ExtensionSupportRequired { .. } => {
105 Some(errors::REASON_EXTENSION_SUPPORT_REQUIRED)
106 }
107 Self::VersionNotSupported { .. } => Some(errors::REASON_VERSION_NOT_SUPPORTED),
108 _ => None,
109 }
110 }
111
112 pub fn error_info(&self) -> Option<Value> {
115 self.error_reason().map(|reason| {
116 json!({
117 "@type": errors::ERROR_INFO_TYPE,
118 "reason": reason,
119 "domain": errors::ERROR_DOMAIN,
120 })
121 })
122 }
123
124 pub fn to_http_error_body(&self) -> Value {
126 let mut body = json!({
127 "error": {
128 "code": self.http_status(),
129 "message": self.to_string(),
130 }
131 });
132
133 if let Some(info) = self.error_info() {
134 body["error"]["details"] = json!([info]);
135 }
136
137 body
138 }
139
140 pub fn to_jsonrpc_error(&self, id: Option<&Value>) -> Value {
142 let mut error = json!({
143 "code": self.jsonrpc_code(),
144 "message": self.to_string(),
145 });
146
147 if let Some(info) = self.error_info() {
148 error["data"] = info;
149 }
150
151 json!({
152 "jsonrpc": "2.0",
153 "id": id.cloned().unwrap_or(Value::Null),
154 "error": error,
155 })
156 }
157}
158
159impl turul_rpc::r#async::ToJsonRpcError for A2aError {
163 fn to_error_object(&self) -> turul_rpc::error::JsonRpcErrorObject {
164 turul_rpc::error::JsonRpcErrorObject {
165 code: self.jsonrpc_code() as i64,
166 message: self.to_string(),
167 data: self.error_info(),
168 }
169 }
170}
171
172impl From<crate::storage::A2aStorageError> for A2aError {
173 fn from(err: crate::storage::A2aStorageError) -> Self {
174 use crate::storage::A2aStorageError;
175 match err {
176 A2aStorageError::TaskNotFound(id) => A2aError::TaskNotFound { task_id: id },
177 A2aStorageError::TerminalState(_) => A2aError::TaskNotCancelable {
178 task_id: String::new(),
179 },
180 A2aStorageError::TerminalStateAlreadySet { task_id, .. } => {
185 A2aError::TaskNotCancelable { task_id }
186 }
187 A2aStorageError::InvalidTransition { .. } => A2aError::TaskNotCancelable {
188 task_id: String::new(),
189 },
190 other => A2aError::Internal(other.to_string()),
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
204 fn task_not_found_maps_to_404() {
205 let err = A2aError::TaskNotFound {
206 task_id: "t-1".into(),
207 };
208 assert_eq!(err.http_status(), 404);
209 assert_eq!(err.jsonrpc_code(), errors::JSONRPC_TASK_NOT_FOUND);
210 assert_eq!(err.error_reason(), Some(errors::REASON_TASK_NOT_FOUND));
211 }
212
213 #[test]
214 fn task_not_cancelable_maps_to_409() {
215 let err = A2aError::TaskNotCancelable {
216 task_id: "t-1".into(),
217 };
218 assert_eq!(err.http_status(), 409);
219 assert_eq!(err.jsonrpc_code(), errors::JSONRPC_TASK_NOT_CANCELABLE);
220 assert_eq!(err.error_reason(), Some(errors::REASON_TASK_NOT_CANCELABLE));
221 }
222
223 #[test]
224 fn content_type_not_supported_maps_to_415() {
225 let err = A2aError::ContentTypeNotSupported {
226 content_type: "text/xml".into(),
227 };
228 assert_eq!(err.http_status(), 415);
229 }
230
231 #[test]
232 fn invalid_agent_response_maps_to_502() {
233 let err = A2aError::InvalidAgentResponse {
234 message: "bad".into(),
235 };
236 assert_eq!(err.http_status(), 502);
237 }
238
239 #[test]
240 fn push_notification_not_supported_maps_to_400() {
241 let err = A2aError::PushNotificationNotSupported;
242 assert_eq!(err.http_status(), 400);
243 assert_eq!(
244 err.jsonrpc_code(),
245 errors::JSONRPC_PUSH_NOTIFICATION_NOT_SUPPORTED
246 );
247 }
248
249 #[test]
250 fn all_a2a_errors_have_error_info() {
251 let a2a_errors: Vec<A2aError> = vec![
252 A2aError::TaskNotFound {
253 task_id: "t".into(),
254 },
255 A2aError::TaskNotCancelable {
256 task_id: "t".into(),
257 },
258 A2aError::PushNotificationNotSupported,
259 A2aError::UnsupportedOperation {
260 message: "x".into(),
261 },
262 A2aError::ContentTypeNotSupported {
263 content_type: "x".into(),
264 },
265 A2aError::InvalidAgentResponse {
266 message: "x".into(),
267 },
268 A2aError::ExtendedAgentCardNotConfigured,
269 A2aError::ExtensionSupportRequired {
270 extension: "x".into(),
271 },
272 A2aError::VersionNotSupported {
273 version: "x".into(),
274 },
275 ];
276
277 for err in &a2a_errors {
278 let info = err.error_info();
279 assert!(info.is_some(), "{err} should have ErrorInfo");
280 let info = info.unwrap();
281 assert_eq!(
282 info["@type"],
283 errors::ERROR_INFO_TYPE,
284 "{err} ErrorInfo @type"
285 );
286 assert_eq!(
287 info["domain"],
288 errors::ERROR_DOMAIN,
289 "{err} ErrorInfo domain"
290 );
291 assert!(
292 info["reason"].is_string(),
293 "{err} ErrorInfo reason should be string"
294 );
295 }
296 }
297
298 #[test]
299 fn non_a2a_errors_have_no_error_info() {
300 assert!(
301 A2aError::InvalidRequest {
302 message: "x".into()
303 }
304 .error_info()
305 .is_none()
306 );
307 assert!(A2aError::Internal("x".into()).error_info().is_none());
308 }
309
310 #[test]
311 fn http_error_body_follows_aip193() {
312 let err = A2aError::TaskNotFound {
313 task_id: "t-123".into(),
314 };
315 let body = err.to_http_error_body();
316
317 assert_eq!(body["error"]["code"], 404);
318 assert!(body["error"]["message"].as_str().unwrap().contains("t-123"));
319 let details = body["error"]["details"].as_array().unwrap();
320 assert_eq!(details.len(), 1);
321 assert_eq!(details[0]["@type"], errors::ERROR_INFO_TYPE);
322 assert_eq!(details[0]["reason"], errors::REASON_TASK_NOT_FOUND);
323 assert_eq!(details[0]["domain"], errors::ERROR_DOMAIN);
324 }
325
326 #[test]
327 fn jsonrpc_error_follows_spec() {
328 let err = A2aError::TaskNotCancelable {
329 task_id: "t-456".into(),
330 };
331 let id = json!(42);
332 let resp = err.to_jsonrpc_error(Some(&id));
333
334 assert_eq!(resp["jsonrpc"], "2.0");
335 assert_eq!(resp["id"], 42);
336 assert_eq!(resp["error"]["code"], errors::JSONRPC_TASK_NOT_CANCELABLE);
337 let data = &resp["error"]["data"];
338 assert!(data.is_object(), "JSON-RPC error data should be an object");
339 assert_eq!(data["@type"], errors::ERROR_INFO_TYPE);
340 assert_eq!(data["reason"], errors::REASON_TASK_NOT_CANCELABLE);
341 assert_eq!(data["domain"], errors::ERROR_DOMAIN);
342 }
343
344 #[test]
345 fn jsonrpc_error_null_id_when_none() {
346 let err = A2aError::Internal("oops".into());
347 let resp = err.to_jsonrpc_error(None);
348 assert!(resp["id"].is_null());
349 assert!(resp["error"].get("data").is_none());
351 }
352
353 #[test]
354 fn all_nine_a2a_jsonrpc_codes_in_range() {
355 let a2a_errors: Vec<A2aError> = vec![
356 A2aError::TaskNotFound {
357 task_id: "t".into(),
358 },
359 A2aError::TaskNotCancelable {
360 task_id: "t".into(),
361 },
362 A2aError::PushNotificationNotSupported,
363 A2aError::UnsupportedOperation {
364 message: "x".into(),
365 },
366 A2aError::ContentTypeNotSupported {
367 content_type: "x".into(),
368 },
369 A2aError::InvalidAgentResponse {
370 message: "x".into(),
371 },
372 A2aError::ExtendedAgentCardNotConfigured,
373 A2aError::ExtensionSupportRequired {
374 extension: "x".into(),
375 },
376 A2aError::VersionNotSupported {
377 version: "x".into(),
378 },
379 ];
380
381 let codes: Vec<i32> = a2a_errors.iter().map(|e| e.jsonrpc_code()).collect();
382 assert_eq!(codes.len(), 9);
383 for code in &codes {
384 assert!(
385 (-32099..=-32001).contains(code),
386 "JSON-RPC code {code} out of A2A range"
387 );
388 }
389 let unique: std::collections::HashSet<_> = codes.iter().collect();
391 assert_eq!(unique.len(), 9, "All 9 A2A error codes must be unique");
392 }
393}