Skip to main content

a2a/
errors.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3use std::collections::HashMap;
4
5use chrono::Utc;
6use serde_json::Value;
7
8use crate::errordetails::{self, TypedDetail};
9
10/// A2A-specific error codes (JSON-RPC).
11pub mod error_code {
12    // A2A application errors
13    pub const TASK_NOT_FOUND: i32 = -32001;
14    pub const TASK_NOT_CANCELABLE: i32 = -32002;
15    pub const PUSH_NOTIFICATION_NOT_SUPPORTED: i32 = -32003;
16    pub const UNSUPPORTED_OPERATION: i32 = -32004;
17    pub const CONTENT_TYPE_NOT_SUPPORTED: i32 = -32005;
18    pub const INVALID_AGENT_RESPONSE: i32 = -32006;
19    pub const EXTENDED_CARD_NOT_CONFIGURED: i32 = -32007;
20    pub const EXTENSION_SUPPORT_REQUIRED: i32 = -32008;
21    pub const VERSION_NOT_SUPPORTED: i32 = -32009;
22
23    // Standard JSON-RPC errors
24    pub const PARSE_ERROR: i32 = -32700;
25    pub const INVALID_REQUEST: i32 = -32600;
26    pub const METHOD_NOT_FOUND: i32 = -32601;
27    pub const INVALID_PARAMS: i32 = -32602;
28    pub const INTERNAL_ERROR: i32 = -32603;
29}
30
31/// Returns the reason string for an error code.
32pub fn error_reason(code: i32) -> &'static str {
33    match code {
34        error_code::TASK_NOT_FOUND => "TASK_NOT_FOUND",
35        error_code::TASK_NOT_CANCELABLE => "TASK_NOT_CANCELABLE",
36        error_code::PUSH_NOTIFICATION_NOT_SUPPORTED => "PUSH_NOTIFICATION_NOT_SUPPORTED",
37        error_code::UNSUPPORTED_OPERATION => "UNSUPPORTED_OPERATION",
38        error_code::CONTENT_TYPE_NOT_SUPPORTED => "CONTENT_TYPE_NOT_SUPPORTED",
39        error_code::INVALID_AGENT_RESPONSE => "INVALID_AGENT_RESPONSE",
40        error_code::EXTENDED_CARD_NOT_CONFIGURED => "EXTENDED_AGENT_CARD_NOT_CONFIGURED",
41        error_code::EXTENSION_SUPPORT_REQUIRED => "EXTENSION_SUPPORT_REQUIRED",
42        error_code::VERSION_NOT_SUPPORTED => "VERSION_NOT_SUPPORTED",
43        error_code::PARSE_ERROR => "PARSE_ERROR",
44        error_code::INVALID_REQUEST => "INVALID_REQUEST",
45        error_code::METHOD_NOT_FOUND => "METHOD_NOT_FOUND",
46        error_code::INVALID_PARAMS => "INVALID_PARAMS",
47        _ => "INTERNAL_ERROR",
48    }
49}
50
51/// Returns the error code for a reason string, or `None` if unrecognized.
52pub fn reason_to_error_code(reason: &str) -> Option<i32> {
53    match reason {
54        "TASK_NOT_FOUND" => Some(error_code::TASK_NOT_FOUND),
55        "TASK_NOT_CANCELABLE" => Some(error_code::TASK_NOT_CANCELABLE),
56        "PUSH_NOTIFICATION_NOT_SUPPORTED" => Some(error_code::PUSH_NOTIFICATION_NOT_SUPPORTED),
57        "UNSUPPORTED_OPERATION" => Some(error_code::UNSUPPORTED_OPERATION),
58        "UNSUPPORTED_CONTENT_TYPE" | "CONTENT_TYPE_NOT_SUPPORTED" => {
59            Some(error_code::CONTENT_TYPE_NOT_SUPPORTED)
60        }
61        "INVALID_AGENT_RESPONSE" => Some(error_code::INVALID_AGENT_RESPONSE),
62        "EXTENDED_AGENT_CARD_NOT_CONFIGURED" | "EXTENDED_CARD_NOT_CONFIGURED" => {
63            Some(error_code::EXTENDED_CARD_NOT_CONFIGURED)
64        }
65        "EXTENSION_SUPPORT_REQUIRED" => Some(error_code::EXTENSION_SUPPORT_REQUIRED),
66        "VERSION_NOT_SUPPORTED" => Some(error_code::VERSION_NOT_SUPPORTED),
67        "PARSE_ERROR" => Some(error_code::PARSE_ERROR),
68        "INVALID_REQUEST" => Some(error_code::INVALID_REQUEST),
69        "METHOD_NOT_FOUND" => Some(error_code::METHOD_NOT_FOUND),
70        "INVALID_PARAMS" => Some(error_code::INVALID_PARAMS),
71        "INTERNAL_ERROR" => Some(error_code::INTERNAL_ERROR),
72        _ => None,
73    }
74}
75
76/// An A2A protocol error.
77#[derive(Debug, Clone, thiserror::Error)]
78#[error("{message}")]
79pub struct A2AError {
80    pub code: i32,
81    pub message: String,
82    pub details: Option<Vec<TypedDetail>>,
83}
84
85impl A2AError {
86    pub fn new(code: i32, message: impl Into<String>) -> Self {
87        A2AError {
88            code,
89            message: message.into(),
90            details: None,
91        }
92    }
93
94    pub fn with_details(mut self, details: Vec<TypedDetail>) -> Self {
95        self.details = Some(details);
96        self
97    }
98
99    // Convenience constructors for common errors
100
101    pub fn task_not_found(task_id: &str) -> Self {
102        A2AError::new(
103            error_code::TASK_NOT_FOUND,
104            format!("task not found: {task_id}"),
105        )
106    }
107
108    pub fn task_not_cancelable(task_id: &str) -> Self {
109        A2AError::new(
110            error_code::TASK_NOT_CANCELABLE,
111            format!("task cannot be canceled: {task_id}"),
112        )
113    }
114
115    pub fn push_notification_not_supported() -> Self {
116        A2AError::new(
117            error_code::PUSH_NOTIFICATION_NOT_SUPPORTED,
118            "push notification not supported",
119        )
120    }
121
122    pub fn unsupported_operation(msg: impl Into<String>) -> Self {
123        A2AError::new(error_code::UNSUPPORTED_OPERATION, msg)
124    }
125
126    pub fn content_type_not_supported() -> Self {
127        A2AError::new(
128            error_code::CONTENT_TYPE_NOT_SUPPORTED,
129            "incompatible content types",
130        )
131    }
132
133    pub fn invalid_agent_response() -> Self {
134        A2AError::new(error_code::INVALID_AGENT_RESPONSE, "invalid agent response")
135    }
136
137    pub fn version_not_supported(version: &str) -> Self {
138        A2AError::new(
139            error_code::VERSION_NOT_SUPPORTED,
140            format!("version not supported: {version}"),
141        )
142    }
143
144    pub fn internal(msg: impl Into<String>) -> Self {
145        A2AError::new(error_code::INTERNAL_ERROR, msg)
146    }
147
148    pub fn invalid_params(msg: impl Into<String>) -> Self {
149        A2AError::new(error_code::INVALID_PARAMS, msg)
150    }
151
152    pub fn parse_error(msg: impl Into<String>) -> Self {
153        A2AError::new(error_code::PARSE_ERROR, msg)
154    }
155
156    pub fn invalid_request(msg: impl Into<String>) -> Self {
157        A2AError::new(error_code::INVALID_REQUEST, msg)
158    }
159
160    pub fn method_not_found(method: &str) -> Self {
161        A2AError::new(
162            error_code::METHOD_NOT_FOUND,
163            format!("method not found: {method}"),
164        )
165    }
166
167    /// Map A2A error code to HTTP status code for REST binding.
168    pub fn http_status_code(&self) -> u16 {
169        match self.code {
170            error_code::TASK_NOT_FOUND => 404,
171            error_code::TASK_NOT_CANCELABLE => 400,
172            error_code::PUSH_NOTIFICATION_NOT_SUPPORTED => 400,
173            error_code::UNSUPPORTED_OPERATION => 400,
174            error_code::CONTENT_TYPE_NOT_SUPPORTED => 400,
175            error_code::VERSION_NOT_SUPPORTED => 400,
176            error_code::PARSE_ERROR => 400,
177            error_code::INVALID_REQUEST => 400,
178            error_code::METHOD_NOT_FOUND => 501,
179            error_code::INVALID_PARAMS => 400,
180            error_code::INTERNAL_ERROR => 500,
181            _ => 500,
182        }
183    }
184
185    /// Convert to a JSON-RPC error object.
186    ///
187    /// The `data` field is always populated with an array of typed detail
188    /// objects. An `ErrorInfo` detail (with reason, domain, and timestamp)
189    /// is always appended as the last element, matching the Go SDK behavior.
190    pub fn to_jsonrpc_error(&self) -> crate::JsonRpcError {
191        let reason = error_reason(self.code);
192        let metadata = HashMap::from([("timestamp".to_string(), Utc::now().to_rfc3339())]);
193
194        let mut data: Vec<Value> = self
195            .details
196            .as_ref()
197            .map(|d| {
198                d.iter()
199                    .filter(|detail| detail.type_url != errordetails::ERROR_INFO_TYPE)
200                    .map(|detail| serde_json::to_value(detail).unwrap_or_default())
201                    .collect()
202            })
203            .unwrap_or_default();
204
205        let mut error_info =
206            TypedDetail::error_info(reason, errordetails::PROTOCOL_DOMAIN, Some(metadata));
207
208        if let Some(details) = &self.details {
209            for existing in details
210                .iter()
211                .filter(|d| d.type_url == errordetails::ERROR_INFO_TYPE)
212            {
213                if let Some(Value::Object(meta)) = existing.value.get("metadata") {
214                    if let Some(Value::Object(info_meta)) = error_info.value.get_mut("metadata") {
215                        for (k, v) in meta {
216                            info_meta.entry(k.clone()).or_insert_with(|| v.clone());
217                        }
218                    }
219                }
220            }
221        }
222
223        data.push(serde_json::to_value(&error_info).unwrap_or_default());
224
225        crate::JsonRpcError {
226            code: self.code,
227            message: self.message.clone(),
228            data: Some(Value::Array(data)),
229        }
230    }
231}
232
233impl From<A2AError> for crate::JsonRpcError {
234    fn from(e: A2AError) -> Self {
235        e.to_jsonrpc_error()
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_error_constructors() {
245        let e = A2AError::task_not_found("t1");
246        assert_eq!(e.code, error_code::TASK_NOT_FOUND);
247        assert!(e.message.contains("t1"));
248
249        let e = A2AError::task_not_cancelable("t2");
250        assert_eq!(e.code, error_code::TASK_NOT_CANCELABLE);
251
252        let e = A2AError::push_notification_not_supported();
253        assert_eq!(e.code, error_code::PUSH_NOTIFICATION_NOT_SUPPORTED);
254
255        let e = A2AError::unsupported_operation("nope");
256        assert_eq!(e.code, error_code::UNSUPPORTED_OPERATION);
257
258        let e = A2AError::content_type_not_supported();
259        assert_eq!(e.code, error_code::CONTENT_TYPE_NOT_SUPPORTED);
260
261        let e = A2AError::invalid_agent_response();
262        assert_eq!(e.code, error_code::INVALID_AGENT_RESPONSE);
263
264        let e = A2AError::version_not_supported("2.0");
265        assert_eq!(e.code, error_code::VERSION_NOT_SUPPORTED);
266
267        let e = A2AError::internal("boom");
268        assert_eq!(e.code, error_code::INTERNAL_ERROR);
269
270        let e = A2AError::invalid_params("bad param");
271        assert_eq!(e.code, error_code::INVALID_PARAMS);
272
273        let e = A2AError::parse_error("bad json");
274        assert_eq!(e.code, error_code::PARSE_ERROR);
275
276        let e = A2AError::invalid_request("bad req");
277        assert_eq!(e.code, error_code::INVALID_REQUEST);
278
279        let e = A2AError::method_not_found("foo");
280        assert_eq!(e.code, error_code::METHOD_NOT_FOUND);
281    }
282
283    #[test]
284    fn test_http_status_codes() {
285        assert_eq!(A2AError::task_not_found("x").http_status_code(), 404);
286        assert_eq!(A2AError::task_not_cancelable("x").http_status_code(), 400);
287        assert_eq!(A2AError::internal("x").http_status_code(), 500);
288        assert_eq!(A2AError::invalid_params("x").http_status_code(), 400);
289        assert_eq!(
290            A2AError::content_type_not_supported().http_status_code(),
291            400
292        );
293        assert_eq!(A2AError::new(9999, "unknown").http_status_code(), 500);
294    }
295
296    #[test]
297    fn test_http_status_codes_for_remaining_a2a_mappings() {
298        assert_eq!(
299            A2AError::push_notification_not_supported().http_status_code(),
300            400
301        );
302        assert_eq!(
303            A2AError::unsupported_operation("nope").http_status_code(),
304            400
305        );
306        assert_eq!(
307            A2AError::version_not_supported("9.9").http_status_code(),
308            400
309        );
310        assert_eq!(A2AError::parse_error("bad").http_status_code(), 400);
311        assert_eq!(A2AError::invalid_request("bad").http_status_code(), 400);
312        assert_eq!(
313            A2AError::method_not_found("missing").http_status_code(),
314            501
315        );
316    }
317
318    #[test]
319    fn test_to_jsonrpc_error() {
320        let e = A2AError::task_not_found("t1");
321        let rpc = e.to_jsonrpc_error();
322        assert_eq!(rpc.code, error_code::TASK_NOT_FOUND);
323        assert!(rpc.message.contains("t1"));
324        let data = rpc.data.expect("data should always be present");
325        let arr = data.as_array().expect("data should be an array");
326        assert_eq!(arr.len(), 1);
327        assert_eq!(arr[0]["@type"], errordetails::ERROR_INFO_TYPE);
328        assert_eq!(arr[0]["reason"], "TASK_NOT_FOUND");
329        assert_eq!(arr[0]["domain"], errordetails::PROTOCOL_DOMAIN);
330        assert!(arr[0]["metadata"]["timestamp"].is_string());
331    }
332
333    #[test]
334    fn test_with_details() {
335        use std::collections::HashMap;
336        let struct_detail = TypedDetail::from_struct(HashMap::from([(
337            "key".to_string(),
338            Value::String("val".to_string()),
339        )]));
340        let e = A2AError::internal("err").with_details(vec![struct_detail]);
341
342        let rpc = e.to_jsonrpc_error();
343        let data = rpc.data.expect("data should always be present");
344        let arr = data.as_array().unwrap();
345        assert_eq!(arr.len(), 2);
346        assert_eq!(arr[0]["key"], "val");
347        assert_eq!(arr[1]["@type"], errordetails::ERROR_INFO_TYPE);
348        assert_eq!(arr[1]["reason"], "INTERNAL_ERROR");
349    }
350
351    #[test]
352    fn test_to_jsonrpc_error_merges_existing_error_info_metadata() {
353        let existing_info = TypedDetail::error_info(
354            "TASK_NOT_FOUND",
355            errordetails::PROTOCOL_DOMAIN,
356            Some(HashMap::from([("taskId".to_string(), "t1".to_string())])),
357        );
358        let e = A2AError::task_not_found("t1").with_details(vec![existing_info]);
359        let rpc = e.to_jsonrpc_error();
360        let data = rpc.data.unwrap();
361        let arr = data.as_array().unwrap();
362        // Existing ErrorInfo is filtered; merged into auto-generated one
363        assert_eq!(arr.len(), 1);
364        assert_eq!(arr[0]["@type"], errordetails::ERROR_INFO_TYPE);
365        assert_eq!(arr[0]["metadata"]["taskId"], "t1");
366        assert!(arr[0]["metadata"]["timestamp"].is_string());
367    }
368
369    #[test]
370    fn test_reason_to_error_code() {
371        assert_eq!(
372            reason_to_error_code("TASK_NOT_FOUND"),
373            Some(error_code::TASK_NOT_FOUND)
374        );
375        assert_eq!(
376            reason_to_error_code("TASK_NOT_CANCELABLE"),
377            Some(error_code::TASK_NOT_CANCELABLE)
378        );
379        assert_eq!(
380            reason_to_error_code("PUSH_NOTIFICATION_NOT_SUPPORTED"),
381            Some(error_code::PUSH_NOTIFICATION_NOT_SUPPORTED)
382        );
383        assert_eq!(
384            reason_to_error_code("UNSUPPORTED_OPERATION"),
385            Some(error_code::UNSUPPORTED_OPERATION)
386        );
387        assert_eq!(
388            reason_to_error_code("CONTENT_TYPE_NOT_SUPPORTED"),
389            Some(error_code::CONTENT_TYPE_NOT_SUPPORTED)
390        );
391        assert_eq!(
392            reason_to_error_code("UNSUPPORTED_CONTENT_TYPE"),
393            Some(error_code::CONTENT_TYPE_NOT_SUPPORTED)
394        );
395        assert_eq!(
396            reason_to_error_code("INVALID_AGENT_RESPONSE"),
397            Some(error_code::INVALID_AGENT_RESPONSE)
398        );
399        assert_eq!(
400            reason_to_error_code("EXTENDED_AGENT_CARD_NOT_CONFIGURED"),
401            Some(error_code::EXTENDED_CARD_NOT_CONFIGURED)
402        );
403        assert_eq!(
404            reason_to_error_code("EXTENDED_CARD_NOT_CONFIGURED"),
405            Some(error_code::EXTENDED_CARD_NOT_CONFIGURED)
406        );
407        assert_eq!(
408            reason_to_error_code("EXTENSION_SUPPORT_REQUIRED"),
409            Some(error_code::EXTENSION_SUPPORT_REQUIRED)
410        );
411        assert_eq!(
412            reason_to_error_code("VERSION_NOT_SUPPORTED"),
413            Some(error_code::VERSION_NOT_SUPPORTED)
414        );
415        assert_eq!(
416            reason_to_error_code("PARSE_ERROR"),
417            Some(error_code::PARSE_ERROR)
418        );
419        assert_eq!(
420            reason_to_error_code("INVALID_REQUEST"),
421            Some(error_code::INVALID_REQUEST)
422        );
423        assert_eq!(
424            reason_to_error_code("METHOD_NOT_FOUND"),
425            Some(error_code::METHOD_NOT_FOUND)
426        );
427        assert_eq!(
428            reason_to_error_code("INVALID_PARAMS"),
429            Some(error_code::INVALID_PARAMS)
430        );
431        assert_eq!(
432            reason_to_error_code("INTERNAL_ERROR"),
433            Some(error_code::INTERNAL_ERROR)
434        );
435        assert_eq!(reason_to_error_code("UNKNOWN_REASON"), None);
436    }
437
438    #[test]
439    fn test_error_display() {
440        let e = A2AError::internal("test message");
441        assert_eq!(format!("{e}"), "test message");
442    }
443
444    #[test]
445    fn test_jsonrpc_error_from() {
446        let e = A2AError::internal("test");
447        let rpc: crate::JsonRpcError = e.into();
448        assert_eq!(rpc.code, error_code::INTERNAL_ERROR);
449    }
450}