Skip to main content

turul_a2a/
error.rs

1//! Server-level A2A error types with HTTP and JSON-RPC mapping.
2//!
3//! Each variant maps to an exact HTTP status code, JSON-RPC error code,
4//! and google.rpc.ErrorInfo reason string using wire constants from
5//! `turul_a2a_types::wire::errors`.
6
7use serde_json::{Value, json};
8use turul_a2a_types::wire::errors;
9
10/// Server-level error for A2A operations.
11///
12/// Each variant corresponds to an A2A-specific error type from the spec
13/// with exact HTTP status, JSON-RPC code, and ErrorInfo reason mappings.
14#[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    /// HTTP status code per spec Section 5.4.
53    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    /// JSON-RPC error code per spec Section 5.4 (-32001 to -32009).
70    /// Non-A2A errors use standard JSON-RPC codes.
71    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, // Invalid params
85            Self::Internal(_) => -32603,           // Internal error
86        }
87    }
88
89    /// ErrorInfo reason string (UPPER_SNAKE_CASE, no "Error" suffix).
90    /// Returns `None` for non-A2A errors (InvalidRequest, Internal).
91    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    /// Build the google.rpc.ErrorInfo JSON object for this error.
113    /// Returns `None` for non-A2A errors.
114    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    /// Build the HTTP error response body per AIP-193.
125    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    /// Build the JSON-RPC error object.
141    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
159/// Map A2aError onto turul-rpc's `JsonRpcErrorObject` (code/message/data only).
160/// The full JSON-RPC envelope (`jsonrpc`/`id`/`error`) is built by the
161/// dispatcher or by `to_jsonrpc_error` above for non-dispatcher paths.
162impl 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            // CAS loser from atomic-store terminal-CAS:
181            // maps to the same wire error as TerminalState — HTTP 409 /
182            // JSON-RPC -32002. Carry the task_id through for better
183            // diagnostics at the wire layer.
184            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    // =========================================================
200    // Error model tests — exact HTTP/JSON-RPC/ErrorInfo mapping
201    // =========================================================
202
203    #[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        // Internal error has no ErrorInfo
350        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        // All unique
390        let unique: std::collections::HashSet<_> = codes.iter().collect();
391        assert_eq!(unique.len(), 9, "All 9 A2A error codes must be unique");
392    }
393}