use connectrpc::{ConnectError, ErrorCode};
use super::jsonrpc_wire::{JsonRpcError, a2a_to_jsonrpc, error_code, jsonrpc_to_a2a};
use crate::domain::A2AError;
pub const A2A_ERROR_DETAIL_TYPE: &str = "a2a_rs.JsonRpcError";
pub fn connect_code(a2a_code: i32) -> ErrorCode {
match a2a_code {
error_code::TASK_NOT_FOUND => ErrorCode::NotFound,
error_code::PARSE_ERROR | error_code::INVALID_REQUEST | error_code::INVALID_PARAMS => {
ErrorCode::InvalidArgument
}
error_code::METHOD_NOT_FOUND
| error_code::UNSUPPORTED_OPERATION
| error_code::PUSH_NOTIFICATION_NOT_SUPPORTED
| error_code::CONTENT_TYPE_NOT_SUPPORTED => ErrorCode::Unimplemented,
error_code::TASK_NOT_CANCELABLE | error_code::EXTENDED_CARD_NOT_CONFIGURED => {
ErrorCode::FailedPrecondition
}
error_code::VERSION_CONFLICT => ErrorCode::Aborted,
error_code::CONTEXT_ACCESS_DENIED => ErrorCode::PermissionDenied,
_ => ErrorCode::Internal,
}
}
pub fn to_connect_error(err: &A2AError) -> ConnectError {
let wire = a2a_to_jsonrpc(err);
let detail = connectrpc::error::ErrorDetail {
type_url: A2A_ERROR_DETAIL_TYPE.to_string(),
value: None,
debug: serde_json::to_value(&wire).ok(),
};
ConnectError::new(connect_code(wire.code), wire.message).with_detail(detail)
}
pub fn from_connect_error(err: ConnectError) -> A2AError {
if let Some(wire) = a2a_detail(&err) {
return jsonrpc_to_a2a(&wire);
}
let code = match err.code {
ErrorCode::NotFound => error_code::TASK_NOT_FOUND,
ErrorCode::Unimplemented => error_code::METHOD_NOT_FOUND,
ErrorCode::InvalidArgument => error_code::INVALID_PARAMS,
ErrorCode::FailedPrecondition => error_code::EXTENDED_CARD_NOT_CONFIGURED,
ErrorCode::PermissionDenied => error_code::CONTEXT_ACCESS_DENIED,
_ => error_code::INTERNAL_ERROR,
};
A2AError::JsonRpc {
code,
message: err.message.unwrap_or_default(),
data: None,
}
}
fn a2a_detail(err: &ConnectError) -> Option<JsonRpcError> {
err.details
.iter()
.filter(|d| d.type_url == A2A_ERROR_DETAIL_TYPE)
.find_map(|d| serde_json::from_value(d.debug.clone()?).ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_spec_error_round_trips() {
let cases = vec![
A2AError::InvalidRequest("r".into()),
A2AError::InvalidParams("p".into()),
A2AError::MethodNotFound("m".into()),
A2AError::Internal("i".into()),
A2AError::TaskNotFound("t".into()),
A2AError::TaskNotCancelable("c".into()),
A2AError::PushNotificationNotSupported,
A2AError::UnsupportedOperation("u".into()),
A2AError::ContentTypeNotSupported("ct".into()),
A2AError::InvalidAgentResponse("a".into()),
A2AError::AuthenticatedExtendedCardNotConfigured,
A2AError::DatabaseError("d".into()),
A2AError::VersionConflict {
id: "t1".into(),
expected: 3,
actual: 4,
},
A2AError::ContextAccessDenied {
context_id: "ctx-9".into(),
},
];
for err in cases {
let back = from_connect_error(to_connect_error(&err));
assert_eq!(
back.reason_code(),
err.reason_code(),
"{err:?} came back as {back:?}"
);
assert_eq!(
super::super::jsonrpc_wire::a2a_error_code(&back),
super::super::jsonrpc_wire::a2a_error_code(&err)
);
}
}
#[test]
fn structured_payloads_survive() {
let conflict = A2AError::VersionConflict {
id: "t1".into(),
expected: 3,
actual: 4,
};
assert!(matches!(
from_connect_error(to_connect_error(&conflict)),
A2AError::VersionConflict { id, expected: 3, actual: 4 } if id == "t1"
));
let denied = A2AError::ContextAccessDenied {
context_id: "ctx-9".into(),
};
assert!(matches!(
from_connect_error(to_connect_error(&denied)),
A2AError::ContextAccessDenied { context_id } if context_id == "ctx-9"
));
}
#[test]
fn a_refusal_is_not_an_internal_error() {
let push = to_connect_error(&A2AError::PushNotificationNotSupported);
assert_eq!(push.code, ErrorCode::Unimplemented);
assert!(matches!(
from_connect_error(push),
A2AError::PushNotificationNotSupported
));
let unsupported = to_connect_error(&A2AError::UnsupportedOperation("no".into()));
let method = to_connect_error(&A2AError::MethodNotFound("no".into()));
assert_eq!(unsupported.code, method.code, "same Connect category");
assert!(matches!(
from_connect_error(unsupported),
A2AError::UnsupportedOperation(_)
));
assert!(matches!(
from_connect_error(method),
A2AError::MethodNotFound(_)
));
}
#[test]
fn a_validation_error_names_the_field_on_the_wire() {
let err = to_connect_error(&A2AError::ValidationError {
field: "history_length".into(),
message: "too large".into(),
});
assert_eq!(err.code, ErrorCode::InvalidArgument);
assert_eq!(err.message.as_deref(), Some("history_length: too large"));
assert!(matches!(
from_connect_error(err),
A2AError::InvalidParams(m) if m == "history_length: too large"
));
}
#[test]
fn a_foreign_error_falls_back_to_the_category() {
let err = ConnectError::new(ErrorCode::NotFound, "gone");
assert!(matches!(
from_connect_error(err),
A2AError::JsonRpc { code, message, .. }
if code == error_code::TASK_NOT_FOUND && message == "gone"
));
let err = ConnectError::new(ErrorCode::Unavailable, "down");
assert!(matches!(
from_connect_error(err),
A2AError::JsonRpc { code, .. } if code == error_code::INTERNAL_ERROR
));
}
#[test]
fn the_detail_is_the_json_rpc_error_object() {
let err = to_connect_error(&A2AError::PushNotificationNotSupported);
let detail = &err.details[0];
assert_eq!(detail.type_url, A2A_ERROR_DETAIL_TYPE);
let debug = detail.debug.as_ref().unwrap();
assert_eq!(debug["code"], error_code::PUSH_NOTIFICATION_NOT_SUPPORTED);
assert_eq!(
debug["data"][0]["reason"],
"PUSH_NOTIFICATION_NOT_SUPPORTED"
);
}
}