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    /// The agent supports an extended card but this deployment has not
127    /// configured one. Distinct from [`Self::unsupported_operation`], which
128    /// says the agent does not offer extended cards at all — a client can
129    /// act on the difference, so A2A gives it its own code.
130    pub fn extended_card_not_configured() -> Self {
131        A2AError::new(
132            error_code::EXTENDED_CARD_NOT_CONFIGURED,
133            "extended agent card not configured",
134        )
135    }
136
137    pub fn content_type_not_supported() -> Self {
138        A2AError::new(
139            error_code::CONTENT_TYPE_NOT_SUPPORTED,
140            "incompatible content types",
141        )
142    }
143
144    pub fn invalid_agent_response() -> Self {
145        A2AError::new(error_code::INVALID_AGENT_RESPONSE, "invalid agent response")
146    }
147
148    pub fn version_not_supported(version: &str) -> Self {
149        A2AError::new(
150            error_code::VERSION_NOT_SUPPORTED,
151            format!("version not supported: {version}"),
152        )
153    }
154
155    pub fn internal(msg: impl Into<String>) -> Self {
156        A2AError::new(error_code::INTERNAL_ERROR, msg)
157    }
158
159    pub fn invalid_params(msg: impl Into<String>) -> Self {
160        A2AError::new(error_code::INVALID_PARAMS, msg)
161    }
162
163    pub fn parse_error(msg: impl Into<String>) -> Self {
164        A2AError::new(error_code::PARSE_ERROR, msg)
165    }
166
167    pub fn invalid_request(msg: impl Into<String>) -> Self {
168        A2AError::new(error_code::INVALID_REQUEST, msg)
169    }
170
171    pub fn method_not_found(method: &str) -> Self {
172        A2AError::new(
173            error_code::METHOD_NOT_FOUND,
174            format!("method not found: {method}"),
175        )
176    }
177
178    /// Map A2A error code to HTTP status code for REST binding.
179    pub fn http_status_code(&self) -> u16 {
180        match self.code {
181            error_code::TASK_NOT_FOUND => 404,
182            error_code::TASK_NOT_CANCELABLE => 400,
183            error_code::PUSH_NOTIFICATION_NOT_SUPPORTED => 400,
184            error_code::UNSUPPORTED_OPERATION => 400,
185            error_code::CONTENT_TYPE_NOT_SUPPORTED => 400,
186            error_code::VERSION_NOT_SUPPORTED => 400,
187            // These two were absent and fell through to 500, contradicting
188            // `rest_grpc_status`, which already classes both as
189            // FAILED_PRECONDITION. A caller asking for something this
190            // deployment does not offer is not a server fault.
191            error_code::EXTENDED_CARD_NOT_CONFIGURED => 400,
192            error_code::EXTENSION_SUPPORT_REQUIRED => 400,
193            error_code::PARSE_ERROR => 400,
194            error_code::INVALID_REQUEST => 400,
195            error_code::METHOD_NOT_FOUND => 501,
196            error_code::INVALID_PARAMS => 400,
197            error_code::INTERNAL_ERROR => 500,
198            _ => 500,
199        }
200    }
201
202    /// Convert to a JSON-RPC error object.
203    ///
204    /// The `data` field is always populated with an array of typed detail
205    /// objects. An `ErrorInfo` detail (with reason, domain, and timestamp)
206    /// is always appended as the last element, matching the Go SDK behavior.
207    pub fn to_jsonrpc_error(&self) -> crate::JsonRpcError {
208        let reason = error_reason(self.code);
209        let metadata = HashMap::from([("timestamp".to_string(), Utc::now().to_rfc3339())]);
210
211        let mut data: Vec<Value> = self
212            .details
213            .as_ref()
214            .map(|d| {
215                d.iter()
216                    .filter(|detail| detail.type_url != errordetails::ERROR_INFO_TYPE)
217                    .map(|detail| serde_json::to_value(detail).unwrap_or_default())
218                    .collect()
219            })
220            .unwrap_or_default();
221
222        let mut error_info =
223            TypedDetail::error_info(reason, errordetails::PROTOCOL_DOMAIN, Some(metadata));
224
225        if let Some(details) = &self.details {
226            for existing in details
227                .iter()
228                .filter(|d| d.type_url == errordetails::ERROR_INFO_TYPE)
229            {
230                if let Some(Value::Object(meta)) = existing.value.get("metadata") {
231                    if let Some(Value::Object(info_meta)) = error_info.value.get_mut("metadata") {
232                        for (k, v) in meta {
233                            info_meta.entry(k.clone()).or_insert_with(|| v.clone());
234                        }
235                    }
236                }
237            }
238        }
239
240        data.push(serde_json::to_value(&error_info).unwrap_or_default());
241
242        crate::JsonRpcError {
243            code: self.code,
244            message: self.message.clone(),
245            data: Some(Value::Array(data)),
246        }
247    }
248}
249
250impl From<A2AError> for crate::JsonRpcError {
251    fn from(e: A2AError) -> Self {
252        e.to_jsonrpc_error()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_error_constructors() {
262        let e = A2AError::task_not_found("t1");
263        assert_eq!(e.code, error_code::TASK_NOT_FOUND);
264        assert!(e.message.contains("t1"));
265
266        let e = A2AError::task_not_cancelable("t2");
267        assert_eq!(e.code, error_code::TASK_NOT_CANCELABLE);
268
269        let e = A2AError::push_notification_not_supported();
270        assert_eq!(e.code, error_code::PUSH_NOTIFICATION_NOT_SUPPORTED);
271
272        let e = A2AError::unsupported_operation("nope");
273        assert_eq!(e.code, error_code::UNSUPPORTED_OPERATION);
274
275        let e = A2AError::content_type_not_supported();
276        assert_eq!(e.code, error_code::CONTENT_TYPE_NOT_SUPPORTED);
277
278        let e = A2AError::invalid_agent_response();
279        assert_eq!(e.code, error_code::INVALID_AGENT_RESPONSE);
280
281        let e = A2AError::version_not_supported("2.0");
282        assert_eq!(e.code, error_code::VERSION_NOT_SUPPORTED);
283
284        let e = A2AError::internal("boom");
285        assert_eq!(e.code, error_code::INTERNAL_ERROR);
286
287        let e = A2AError::invalid_params("bad param");
288        assert_eq!(e.code, error_code::INVALID_PARAMS);
289
290        let e = A2AError::parse_error("bad json");
291        assert_eq!(e.code, error_code::PARSE_ERROR);
292
293        let e = A2AError::invalid_request("bad req");
294        assert_eq!(e.code, error_code::INVALID_REQUEST);
295
296        let e = A2AError::method_not_found("foo");
297        assert_eq!(e.code, error_code::METHOD_NOT_FOUND);
298    }
299
300    #[test]
301    fn test_http_status_codes() {
302        assert_eq!(A2AError::task_not_found("x").http_status_code(), 404);
303        assert_eq!(A2AError::task_not_cancelable("x").http_status_code(), 400);
304        assert_eq!(A2AError::internal("x").http_status_code(), 500);
305        assert_eq!(A2AError::invalid_params("x").http_status_code(), 400);
306        assert_eq!(
307            A2AError::content_type_not_supported().http_status_code(),
308            400
309        );
310        assert_eq!(A2AError::new(9999, "unknown").http_status_code(), 500);
311    }
312
313    #[test]
314    fn test_http_status_codes_for_remaining_a2a_mappings() {
315        assert_eq!(
316            A2AError::push_notification_not_supported().http_status_code(),
317            400
318        );
319        assert_eq!(
320            A2AError::unsupported_operation("nope").http_status_code(),
321            400
322        );
323        assert_eq!(
324            A2AError::version_not_supported("9.9").http_status_code(),
325            400
326        );
327        assert_eq!(A2AError::parse_error("bad").http_status_code(), 400);
328        assert_eq!(A2AError::invalid_request("bad").http_status_code(), 400);
329        assert_eq!(
330            A2AError::method_not_found("missing").http_status_code(),
331            501
332        );
333    }
334
335    /// Both of these previously fell through to 500, which contradicted
336    /// `rest_grpc_status` in a2a-server — that already classed them as
337    /// FAILED_PRECONDITION. Asking for something a deployment does not
338    /// offer is a client-side condition, not a server fault.
339    #[test]
340    fn test_precondition_codes_are_client_errors_not_server_faults() {
341        assert_eq!(
342            A2AError::extended_card_not_configured().http_status_code(),
343            400
344        );
345        assert_eq!(
346            A2AError::new(error_code::EXTENSION_SUPPORT_REQUIRED, "needs ext").http_status_code(),
347            400
348        );
349    }
350
351    #[test]
352    fn test_extended_card_not_configured_carries_its_own_code() {
353        let error = A2AError::extended_card_not_configured();
354        assert_eq!(error.code, error_code::EXTENDED_CARD_NOT_CONFIGURED);
355        assert_ne!(error.code, error_code::UNSUPPORTED_OPERATION);
356        assert_eq!(
357            error_reason(error.code),
358            "EXTENDED_AGENT_CARD_NOT_CONFIGURED"
359        );
360    }
361
362    #[test]
363    fn test_to_jsonrpc_error() {
364        let e = A2AError::task_not_found("t1");
365        let rpc = e.to_jsonrpc_error();
366        assert_eq!(rpc.code, error_code::TASK_NOT_FOUND);
367        assert!(rpc.message.contains("t1"));
368        let data = rpc.data.expect("data should always be present");
369        let arr = data.as_array().expect("data should be an array");
370        assert_eq!(arr.len(), 1);
371        assert_eq!(arr[0]["@type"], errordetails::ERROR_INFO_TYPE);
372        assert_eq!(arr[0]["reason"], "TASK_NOT_FOUND");
373        assert_eq!(arr[0]["domain"], errordetails::PROTOCOL_DOMAIN);
374        assert!(arr[0]["metadata"]["timestamp"].is_string());
375    }
376
377    #[test]
378    fn test_with_details() {
379        use std::collections::HashMap;
380        let struct_detail = TypedDetail::from_struct(HashMap::from([(
381            "key".to_string(),
382            Value::String("val".to_string()),
383        )]));
384        let e = A2AError::internal("err").with_details(vec![struct_detail]);
385
386        let rpc = e.to_jsonrpc_error();
387        let data = rpc.data.expect("data should always be present");
388        let arr = data.as_array().unwrap();
389        assert_eq!(arr.len(), 2);
390        assert_eq!(arr[0]["key"], "val");
391        assert_eq!(arr[1]["@type"], errordetails::ERROR_INFO_TYPE);
392        assert_eq!(arr[1]["reason"], "INTERNAL_ERROR");
393    }
394
395    #[test]
396    fn test_to_jsonrpc_error_merges_existing_error_info_metadata() {
397        let existing_info = TypedDetail::error_info(
398            "TASK_NOT_FOUND",
399            errordetails::PROTOCOL_DOMAIN,
400            Some(HashMap::from([("taskId".to_string(), "t1".to_string())])),
401        );
402        let e = A2AError::task_not_found("t1").with_details(vec![existing_info]);
403        let rpc = e.to_jsonrpc_error();
404        let data = rpc.data.unwrap();
405        let arr = data.as_array().unwrap();
406        // Existing ErrorInfo is filtered; merged into auto-generated one
407        assert_eq!(arr.len(), 1);
408        assert_eq!(arr[0]["@type"], errordetails::ERROR_INFO_TYPE);
409        assert_eq!(arr[0]["metadata"]["taskId"], "t1");
410        assert!(arr[0]["metadata"]["timestamp"].is_string());
411    }
412
413    #[test]
414    fn test_reason_to_error_code() {
415        assert_eq!(
416            reason_to_error_code("TASK_NOT_FOUND"),
417            Some(error_code::TASK_NOT_FOUND)
418        );
419        assert_eq!(
420            reason_to_error_code("TASK_NOT_CANCELABLE"),
421            Some(error_code::TASK_NOT_CANCELABLE)
422        );
423        assert_eq!(
424            reason_to_error_code("PUSH_NOTIFICATION_NOT_SUPPORTED"),
425            Some(error_code::PUSH_NOTIFICATION_NOT_SUPPORTED)
426        );
427        assert_eq!(
428            reason_to_error_code("UNSUPPORTED_OPERATION"),
429            Some(error_code::UNSUPPORTED_OPERATION)
430        );
431        assert_eq!(
432            reason_to_error_code("CONTENT_TYPE_NOT_SUPPORTED"),
433            Some(error_code::CONTENT_TYPE_NOT_SUPPORTED)
434        );
435        assert_eq!(
436            reason_to_error_code("UNSUPPORTED_CONTENT_TYPE"),
437            Some(error_code::CONTENT_TYPE_NOT_SUPPORTED)
438        );
439        assert_eq!(
440            reason_to_error_code("INVALID_AGENT_RESPONSE"),
441            Some(error_code::INVALID_AGENT_RESPONSE)
442        );
443        assert_eq!(
444            reason_to_error_code("EXTENDED_AGENT_CARD_NOT_CONFIGURED"),
445            Some(error_code::EXTENDED_CARD_NOT_CONFIGURED)
446        );
447        assert_eq!(
448            reason_to_error_code("EXTENDED_CARD_NOT_CONFIGURED"),
449            Some(error_code::EXTENDED_CARD_NOT_CONFIGURED)
450        );
451        assert_eq!(
452            reason_to_error_code("EXTENSION_SUPPORT_REQUIRED"),
453            Some(error_code::EXTENSION_SUPPORT_REQUIRED)
454        );
455        assert_eq!(
456            reason_to_error_code("VERSION_NOT_SUPPORTED"),
457            Some(error_code::VERSION_NOT_SUPPORTED)
458        );
459        assert_eq!(
460            reason_to_error_code("PARSE_ERROR"),
461            Some(error_code::PARSE_ERROR)
462        );
463        assert_eq!(
464            reason_to_error_code("INVALID_REQUEST"),
465            Some(error_code::INVALID_REQUEST)
466        );
467        assert_eq!(
468            reason_to_error_code("METHOD_NOT_FOUND"),
469            Some(error_code::METHOD_NOT_FOUND)
470        );
471        assert_eq!(
472            reason_to_error_code("INVALID_PARAMS"),
473            Some(error_code::INVALID_PARAMS)
474        );
475        assert_eq!(
476            reason_to_error_code("INTERNAL_ERROR"),
477            Some(error_code::INTERNAL_ERROR)
478        );
479        assert_eq!(reason_to_error_code("UNKNOWN_REASON"), None);
480    }
481
482    #[test]
483    fn test_error_display() {
484        let e = A2AError::internal("test message");
485        assert_eq!(format!("{e}"), "test message");
486    }
487
488    #[test]
489    fn test_jsonrpc_error_from() {
490        let e = A2AError::internal("test");
491        let rpc: crate::JsonRpcError = e.into();
492        assert_eq!(rpc.code, error_code::INTERNAL_ERROR);
493    }
494}