Skip to main content

a2a/
errors.rs

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