Skip to main content

a2a_rust/
error.rs

1use std::collections::BTreeMap;
2
3use http::StatusCode;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use thiserror::Error;
7
8use crate::jsonrpc;
9use crate::jsonrpc::JsonRpcError;
10
11/// Type URL used for structured `ErrorInfo` entries.
12pub const ERROR_INFO_TYPE_URL: &str = "type.googleapis.com/google.rpc.ErrorInfo";
13/// Domain used for SDK-generated structured error details.
14pub const ERROR_INFO_DOMAIN: &str = "a2a-protocol.org";
15
16/// Structured protocol error detail modeled after `google.rpc.ErrorInfo`.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ErrorInfo {
19    /// Type URL identifying the detail payload.
20    #[serde(rename = "@type", default = "error_info_type_url")]
21    pub type_url: String,
22    /// Stable machine-readable reason string.
23    pub reason: String,
24    /// Domain that defined the reason.
25    pub domain: String,
26    /// Additional structured metadata for the error.
27    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
28    pub metadata: BTreeMap<String, String>,
29}
30
31/// Structured HTTP error payload using RFC 9457-style problem details.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct ProblemDetails {
34    /// Stable type URI for the problem kind.
35    #[serde(rename = "type")]
36    pub type_url: String,
37    /// Short human-readable problem title.
38    pub title: String,
39    /// HTTP status code.
40    pub status: u16,
41    /// Human-readable error detail message.
42    pub detail: String,
43    /// Optional stable machine-readable reason string.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub reason: Option<String>,
46    /// Optional domain associated with the reason.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub domain: Option<String>,
49    /// Additional structured problem metadata.
50    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
51    pub extensions: BTreeMap<String, Value>,
52}
53
54/// Unified error type for A2A protocol, HTTP, and serialization failures.
55#[derive(Debug, Error)]
56pub enum A2AError {
57    /// The requested task identifier does not exist.
58    #[error("task not found: {0}")]
59    TaskNotFound(String),
60    /// The requested task cannot transition to canceled.
61    #[error("task not cancelable: {0}")]
62    TaskNotCancelable(String),
63    /// Push notifications are disabled or unsupported for this agent.
64    #[error("push notification not supported: {0}")]
65    PushNotificationNotSupported(String),
66    /// The requested operation is not implemented.
67    #[error("unsupported operation: {0}")]
68    UnsupportedOperation(String),
69    /// The request content type is not supported by the peer.
70    #[error("content type not supported: {0}")]
71    ContentTypeNotSupported(String),
72    /// The remote agent returned an invalid response payload.
73    #[error("invalid agent response: {0}")]
74    InvalidAgentResponse(String),
75    /// Extended agent card retrieval is not configured for the agent.
76    #[error("extended agent card not configured: {0}")]
77    ExtendedAgentCardNotConfigured(String),
78    /// A required extension is not supported by the peer.
79    #[error("extension support required: {0}")]
80    ExtensionSupportRequired(String),
81    /// The peer rejected the requested A2A protocol version.
82    #[error("version not supported: {0}")]
83    VersionNotSupported(String),
84    /// The request body could not be parsed as valid JSON-RPC or JSON.
85    #[error("parse error: {0}")]
86    ParseError(String),
87    /// The request shape is structurally invalid.
88    #[error("invalid request: {0}")]
89    InvalidRequest(String),
90    /// The requested method or route does not exist.
91    #[error("method not found: {0}")]
92    MethodNotFound(String),
93    /// The supplied parameters could not be deserialized or validated.
94    #[error("invalid params: {0}")]
95    InvalidParams(String),
96    /// An internal SDK or server error occurred.
97    #[error("internal error: {0}")]
98    Internal(String),
99    /// JSON serialization or deserialization failed locally.
100    #[error("serialization error: {0}")]
101    Serialization(#[from] serde_json::Error),
102    #[cfg(feature = "client")]
103    /// The underlying HTTP client returned an error.
104    #[error("http error: {0}")]
105    Http(#[from] reqwest::Error),
106}
107
108impl A2AError {
109    /// Return the stable structured reason name for this error.
110    pub fn reason(&self) -> &'static str {
111        match self {
112            Self::TaskNotFound(_) => "TASK_NOT_FOUND",
113            Self::TaskNotCancelable(_) => "TASK_NOT_CANCELABLE",
114            Self::PushNotificationNotSupported(_) => "PUSH_NOTIFICATION_NOT_SUPPORTED",
115            Self::UnsupportedOperation(_) => "UNSUPPORTED_OPERATION",
116            Self::ContentTypeNotSupported(_) => "CONTENT_TYPE_NOT_SUPPORTED",
117            Self::InvalidAgentResponse(_) => "INVALID_AGENT_RESPONSE",
118            Self::ExtendedAgentCardNotConfigured(_) => "EXTENDED_AGENT_CARD_NOT_CONFIGURED",
119            Self::ExtensionSupportRequired(_) => "EXTENSION_SUPPORT_REQUIRED",
120            Self::VersionNotSupported(_) => "VERSION_NOT_SUPPORTED",
121            Self::ParseError(_) => "PARSE_ERROR",
122            Self::InvalidRequest(_) => "INVALID_REQUEST",
123            Self::MethodNotFound(_) => "METHOD_NOT_FOUND",
124            Self::InvalidParams(_) => "INVALID_PARAMS",
125            Self::Internal(_) | Self::Serialization(_) => "INTERNAL",
126            #[cfg(feature = "client")]
127            Self::Http(_) => "HTTP",
128        }
129    }
130
131    /// Return the JSON-RPC error code associated with this error.
132    pub fn code(&self) -> i32 {
133        match self {
134            Self::TaskNotFound(_) => jsonrpc::TASK_NOT_FOUND,
135            Self::TaskNotCancelable(_) => jsonrpc::TASK_NOT_CANCELABLE,
136            Self::PushNotificationNotSupported(_) => jsonrpc::PUSH_NOTIFICATION_NOT_SUPPORTED,
137            Self::UnsupportedOperation(_) => jsonrpc::UNSUPPORTED_OPERATION,
138            Self::ContentTypeNotSupported(_) => jsonrpc::CONTENT_TYPE_NOT_SUPPORTED,
139            Self::InvalidAgentResponse(_) => jsonrpc::INVALID_AGENT_RESPONSE,
140            Self::ExtendedAgentCardNotConfigured(_) => jsonrpc::EXTENDED_AGENT_CARD_NOT_CONFIGURED,
141            Self::ExtensionSupportRequired(_) => jsonrpc::EXTENSION_SUPPORT_REQUIRED,
142            Self::VersionNotSupported(_) => jsonrpc::VERSION_NOT_SUPPORTED,
143            Self::ParseError(_) => jsonrpc::PARSE_ERROR,
144            Self::InvalidRequest(_) => jsonrpc::INVALID_REQUEST,
145            Self::MethodNotFound(_) => jsonrpc::METHOD_NOT_FOUND,
146            Self::InvalidParams(_) => jsonrpc::INVALID_PARAMS,
147            Self::Internal(_) => jsonrpc::INTERNAL_ERROR,
148            Self::Serialization(_) => jsonrpc::INTERNAL_ERROR,
149            #[cfg(feature = "client")]
150            Self::Http(_) => jsonrpc::INTERNAL_ERROR,
151        }
152    }
153
154    /// Convert this error into a JSON-RPC error object.
155    pub fn to_jsonrpc_error(&self) -> JsonRpcError {
156        JsonRpcError {
157            code: self.code(),
158            message: self.to_string(),
159            data: Some(
160                serde_json::to_value(self.to_error_info()).expect("error details should serialize"),
161            ),
162        }
163    }
164
165    /// Convert this error into an RFC 9457-style HTTP problem payload.
166    pub fn to_problem_details(&self) -> ProblemDetails {
167        let status_code = self.status_code();
168        ProblemDetails {
169            type_url: self.problem_type_url().to_owned(),
170            title: self.problem_title().to_owned(),
171            status: status_code.as_u16(),
172            detail: self.to_string(),
173            reason: Some(self.reason().to_owned()),
174            domain: Some(ERROR_INFO_DOMAIN.to_owned()),
175            extensions: self
176                .metadata()
177                .into_iter()
178                .map(|(key, value)| (key, Value::String(value)))
179                .collect(),
180        }
181    }
182
183    /// Return the HTTP status code associated with this error.
184    pub fn status_code(&self) -> StatusCode {
185        match self {
186            Self::TaskNotFound(_) => StatusCode::NOT_FOUND,
187            Self::TaskNotCancelable(_) => StatusCode::CONFLICT,
188            Self::PushNotificationNotSupported(_) => StatusCode::BAD_REQUEST,
189            Self::UnsupportedOperation(_) => StatusCode::BAD_REQUEST,
190            Self::ContentTypeNotSupported(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
191            Self::InvalidAgentResponse(_) => StatusCode::BAD_GATEWAY,
192            Self::ExtendedAgentCardNotConfigured(_) => StatusCode::BAD_REQUEST,
193            Self::ExtensionSupportRequired(_) => StatusCode::BAD_REQUEST,
194            Self::VersionNotSupported(_) => StatusCode::BAD_REQUEST,
195            Self::ParseError(_) => StatusCode::BAD_REQUEST,
196            Self::InvalidRequest(_) => StatusCode::BAD_REQUEST,
197            Self::MethodNotFound(_) => StatusCode::NOT_FOUND,
198            Self::InvalidParams(_) => StatusCode::BAD_REQUEST,
199            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
200            Self::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
201            #[cfg(feature = "client")]
202            Self::Http(_) => StatusCode::BAD_GATEWAY,
203        }
204    }
205
206    /// Convert this error into a structured `ErrorInfo` detail.
207    pub fn to_error_info(&self) -> ErrorInfo {
208        ErrorInfo {
209            type_url: error_info_type_url(),
210            reason: self.reason().to_owned(),
211            domain: ERROR_INFO_DOMAIN.to_owned(),
212            metadata: self.metadata(),
213        }
214    }
215
216    /// Reconstruct an `A2AError` from HTTP problem details when possible.
217    pub fn from_problem_details(problem: &ProblemDetails) -> Self {
218        let reason = problem
219            .reason
220            .clone()
221            .unwrap_or_else(|| problem_reason(problem.type_url.as_str()).to_owned());
222        let info = ErrorInfo {
223            type_url: error_info_type_url(),
224            reason: reason.clone(),
225            domain: problem
226                .domain
227                .clone()
228                .unwrap_or_else(|| ERROR_INFO_DOMAIN.to_owned()),
229            metadata: problem
230                .extensions
231                .iter()
232                .filter_map(|(key, value)| match value {
233                    Value::String(value) => Some((key.clone(), value.clone())),
234                    Value::Number(value) => Some((key.clone(), value.to_string())),
235                    Value::Bool(value) => Some((key.clone(), value.to_string())),
236                    _ => None,
237                })
238                .collect(),
239        };
240
241        Self::from_error_info(reason_code(reason.as_str()), &problem.detail, Some(&info))
242    }
243
244    /// Reconstruct an `A2AError` from structured error details when possible.
245    pub fn from_error_info(error_code: i32, message: &str, info: Option<&ErrorInfo>) -> Self {
246        let fallback_detail = info
247            .and_then(|info| info.metadata.get("detail").cloned())
248            .unwrap_or_else(|| message.to_owned());
249
250        let reason = info.map(|info| info.reason.as_str()).unwrap_or("");
251        let metadata = info.map(|info| &info.metadata);
252
253        match (error_code, reason) {
254            (jsonrpc::TASK_NOT_FOUND, "TASK_NOT_FOUND") => Self::TaskNotFound(
255                metadata
256                    .and_then(|metadata| metadata.get("taskId").cloned())
257                    .unwrap_or(fallback_detail),
258            ),
259            (jsonrpc::TASK_NOT_CANCELABLE, "TASK_NOT_CANCELABLE") => Self::TaskNotCancelable(
260                metadata
261                    .and_then(|metadata| metadata.get("taskId").cloned())
262                    .unwrap_or(fallback_detail),
263            ),
264            (jsonrpc::PUSH_NOTIFICATION_NOT_SUPPORTED, _) => {
265                Self::PushNotificationNotSupported(fallback_detail)
266            }
267            (jsonrpc::UNSUPPORTED_OPERATION, _) => Self::UnsupportedOperation(fallback_detail),
268            (jsonrpc::CONTENT_TYPE_NOT_SUPPORTED, _) => {
269                Self::ContentTypeNotSupported(fallback_detail)
270            }
271            (jsonrpc::INVALID_AGENT_RESPONSE, _) => Self::InvalidAgentResponse(fallback_detail),
272            (jsonrpc::EXTENDED_AGENT_CARD_NOT_CONFIGURED, _) => {
273                Self::ExtendedAgentCardNotConfigured(fallback_detail)
274            }
275            (jsonrpc::EXTENSION_SUPPORT_REQUIRED, _) => {
276                Self::ExtensionSupportRequired(fallback_detail)
277            }
278            (jsonrpc::VERSION_NOT_SUPPORTED, _) => Self::VersionNotSupported(fallback_detail),
279            (jsonrpc::PARSE_ERROR, _) => Self::ParseError(fallback_detail),
280            (jsonrpc::INVALID_REQUEST, _) => Self::InvalidRequest(fallback_detail),
281            (jsonrpc::METHOD_NOT_FOUND, _) => Self::MethodNotFound(fallback_detail),
282            (jsonrpc::INVALID_PARAMS, _) => Self::InvalidParams(fallback_detail),
283            (jsonrpc::INTERNAL_ERROR, _) => Self::Internal(fallback_detail),
284            _ => Self::Internal(fallback_detail),
285        }
286    }
287
288    fn problem_type_url(&self) -> &'static str {
289        match self {
290            Self::TaskNotFound(_) => "https://a2a-protocol.org/errors/task-not-found",
291            Self::TaskNotCancelable(_) => "https://a2a-protocol.org/errors/task-not-cancelable",
292            Self::PushNotificationNotSupported(_) => {
293                "https://a2a-protocol.org/errors/push-notification-not-supported"
294            }
295            Self::UnsupportedOperation(_) => {
296                "https://a2a-protocol.org/errors/unsupported-operation"
297            }
298            Self::ContentTypeNotSupported(_) => {
299                "https://a2a-protocol.org/errors/content-type-not-supported"
300            }
301            Self::InvalidAgentResponse(_) => {
302                "https://a2a-protocol.org/errors/invalid-agent-response"
303            }
304            Self::ExtendedAgentCardNotConfigured(_) => {
305                "https://a2a-protocol.org/errors/extended-agent-card-not-configured"
306            }
307            Self::ExtensionSupportRequired(_) => {
308                "https://a2a-protocol.org/errors/extension-support-required"
309            }
310            Self::VersionNotSupported(_) => "https://a2a-protocol.org/errors/version-not-supported",
311            Self::ParseError(_) => "about:blank",
312            Self::InvalidRequest(_) => "about:blank",
313            Self::MethodNotFound(_) => "about:blank",
314            Self::InvalidParams(_) => "about:blank",
315            Self::Internal(_) | Self::Serialization(_) => "about:blank",
316            #[cfg(feature = "client")]
317            Self::Http(_) => "about:blank",
318        }
319    }
320
321    fn problem_title(&self) -> &'static str {
322        match self {
323            Self::TaskNotFound(_) => "Task not found",
324            Self::TaskNotCancelable(_) => "Task not cancelable",
325            Self::PushNotificationNotSupported(_) => "Push notifications not supported",
326            Self::UnsupportedOperation(_) => "Unsupported operation",
327            Self::ContentTypeNotSupported(_) => "Content type not supported",
328            Self::InvalidAgentResponse(_) => "Invalid agent response",
329            Self::ExtendedAgentCardNotConfigured(_) => "Extended agent card not configured",
330            Self::ExtensionSupportRequired(_) => "Extension support required",
331            Self::VersionNotSupported(_) => "Version not supported",
332            Self::ParseError(_) => "Bad Request",
333            Self::InvalidRequest(_) => "Bad Request",
334            Self::MethodNotFound(_) => "Not Found",
335            Self::InvalidParams(_) => "Bad Request",
336            Self::Internal(_) | Self::Serialization(_) => "Internal Server Error",
337            #[cfg(feature = "client")]
338            Self::Http(_) => "Bad Gateway",
339        }
340    }
341
342    fn metadata(&self) -> BTreeMap<String, String> {
343        let mut metadata = BTreeMap::new();
344
345        match self {
346            Self::TaskNotFound(task_id) | Self::TaskNotCancelable(task_id) => {
347                metadata.insert("taskId".to_owned(), task_id.clone());
348            }
349            Self::PushNotificationNotSupported(detail)
350            | Self::UnsupportedOperation(detail)
351            | Self::ContentTypeNotSupported(detail)
352            | Self::InvalidAgentResponse(detail)
353            | Self::ExtendedAgentCardNotConfigured(detail)
354            | Self::ExtensionSupportRequired(detail)
355            | Self::VersionNotSupported(detail)
356            | Self::ParseError(detail)
357            | Self::InvalidRequest(detail)
358            | Self::MethodNotFound(detail)
359            | Self::InvalidParams(detail)
360            | Self::Internal(detail) => {
361                metadata.insert("detail".to_owned(), detail.clone());
362            }
363            Self::Serialization(error) => {
364                metadata.insert("detail".to_owned(), error.to_string());
365            }
366            #[cfg(feature = "client")]
367            Self::Http(error) => {
368                metadata.insert("detail".to_owned(), error.to_string());
369            }
370        }
371
372        metadata
373    }
374}
375
376impl ProblemDetails {
377    /// Convert this HTTP error payload back into an `A2AError`.
378    pub fn to_a2a_error(&self) -> A2AError {
379        A2AError::from_problem_details(self)
380    }
381}
382
383impl JsonRpcError {
384    /// Return the first structured `ErrorInfo` entry from `data`, when present.
385    pub fn first_error_info(&self) -> Option<ErrorInfo> {
386        match self.data.as_ref()? {
387            Value::Array(details) => details
388                .iter()
389                .find_map(|detail| serde_json::from_value::<ErrorInfo>(detail.clone()).ok()),
390            Value::Object(_) => serde_json::from_value::<ErrorInfo>(self.data.clone()?).ok(),
391            _ => None,
392        }
393    }
394}
395
396fn error_info_type_url() -> String {
397    ERROR_INFO_TYPE_URL.to_owned()
398}
399
400fn problem_code(type_url: &str) -> i32 {
401    match type_url {
402        "https://a2a-protocol.org/errors/task-not-found" => jsonrpc::TASK_NOT_FOUND,
403        "https://a2a-protocol.org/errors/task-not-cancelable" => jsonrpc::TASK_NOT_CANCELABLE,
404        "https://a2a-protocol.org/errors/push-notification-not-supported" => {
405            jsonrpc::PUSH_NOTIFICATION_NOT_SUPPORTED
406        }
407        "https://a2a-protocol.org/errors/unsupported-operation" => jsonrpc::UNSUPPORTED_OPERATION,
408        "https://a2a-protocol.org/errors/content-type-not-supported" => {
409            jsonrpc::CONTENT_TYPE_NOT_SUPPORTED
410        }
411        "https://a2a-protocol.org/errors/invalid-agent-response" => jsonrpc::INVALID_AGENT_RESPONSE,
412        "https://a2a-protocol.org/errors/extended-agent-card-not-configured" => {
413            jsonrpc::EXTENDED_AGENT_CARD_NOT_CONFIGURED
414        }
415        "https://a2a-protocol.org/errors/extension-support-required" => {
416            jsonrpc::EXTENSION_SUPPORT_REQUIRED
417        }
418        "https://a2a-protocol.org/errors/version-not-supported" => jsonrpc::VERSION_NOT_SUPPORTED,
419        _ => jsonrpc::INTERNAL_ERROR,
420    }
421}
422
423fn problem_reason(type_url: &str) -> &'static str {
424    match problem_code(type_url) {
425        jsonrpc::TASK_NOT_FOUND => "TASK_NOT_FOUND",
426        jsonrpc::TASK_NOT_CANCELABLE => "TASK_NOT_CANCELABLE",
427        jsonrpc::PUSH_NOTIFICATION_NOT_SUPPORTED => "PUSH_NOTIFICATION_NOT_SUPPORTED",
428        jsonrpc::UNSUPPORTED_OPERATION => "UNSUPPORTED_OPERATION",
429        jsonrpc::CONTENT_TYPE_NOT_SUPPORTED => "CONTENT_TYPE_NOT_SUPPORTED",
430        jsonrpc::INVALID_AGENT_RESPONSE => "INVALID_AGENT_RESPONSE",
431        jsonrpc::EXTENDED_AGENT_CARD_NOT_CONFIGURED => "EXTENDED_AGENT_CARD_NOT_CONFIGURED",
432        jsonrpc::EXTENSION_SUPPORT_REQUIRED => "EXTENSION_SUPPORT_REQUIRED",
433        jsonrpc::VERSION_NOT_SUPPORTED => "VERSION_NOT_SUPPORTED",
434        jsonrpc::PARSE_ERROR => "PARSE_ERROR",
435        jsonrpc::INVALID_REQUEST => "INVALID_REQUEST",
436        jsonrpc::METHOD_NOT_FOUND => "METHOD_NOT_FOUND",
437        jsonrpc::INVALID_PARAMS => "INVALID_PARAMS",
438        _ => "INTERNAL",
439    }
440}
441
442fn reason_code(reason: &str) -> i32 {
443    match reason {
444        "TASK_NOT_FOUND" => jsonrpc::TASK_NOT_FOUND,
445        "TASK_NOT_CANCELABLE" => jsonrpc::TASK_NOT_CANCELABLE,
446        "PUSH_NOTIFICATION_NOT_SUPPORTED" => jsonrpc::PUSH_NOTIFICATION_NOT_SUPPORTED,
447        "UNSUPPORTED_OPERATION" => jsonrpc::UNSUPPORTED_OPERATION,
448        "CONTENT_TYPE_NOT_SUPPORTED" => jsonrpc::CONTENT_TYPE_NOT_SUPPORTED,
449        "INVALID_AGENT_RESPONSE" => jsonrpc::INVALID_AGENT_RESPONSE,
450        "EXTENDED_AGENT_CARD_NOT_CONFIGURED" => jsonrpc::EXTENDED_AGENT_CARD_NOT_CONFIGURED,
451        "EXTENSION_SUPPORT_REQUIRED" => jsonrpc::EXTENSION_SUPPORT_REQUIRED,
452        "VERSION_NOT_SUPPORTED" => jsonrpc::VERSION_NOT_SUPPORTED,
453        "PARSE_ERROR" => jsonrpc::PARSE_ERROR,
454        "INVALID_REQUEST" => jsonrpc::INVALID_REQUEST,
455        "METHOD_NOT_FOUND" => jsonrpc::METHOD_NOT_FOUND,
456        "INVALID_PARAMS" => jsonrpc::INVALID_PARAMS,
457        _ => jsonrpc::INTERNAL_ERROR,
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::{A2AError, ERROR_INFO_DOMAIN, ERROR_INFO_TYPE_URL};
464
465    #[test]
466    fn jsonrpc_error_uses_structured_error_info_object() {
467        let error = A2AError::TaskNotFound("task-1".to_owned()).to_jsonrpc_error();
468
469        assert_eq!(error.code, crate::jsonrpc::TASK_NOT_FOUND);
470        assert_eq!(
471            error.data,
472            Some(serde_json::json!({
473                "@type": ERROR_INFO_TYPE_URL,
474                "reason": "TASK_NOT_FOUND",
475                "domain": ERROR_INFO_DOMAIN,
476                "metadata": {
477                    "taskId": "task-1",
478                }
479            }))
480        );
481    }
482
483    #[test]
484    fn problem_details_round_trip_to_a2a_error() {
485        let error = A2AError::ExtensionSupportRequired("missing extension".to_owned());
486        let problem = error.to_problem_details();
487
488        assert_eq!(
489            A2AError::from_problem_details(&problem).to_string(),
490            error.to_string()
491        );
492    }
493}