use std::time::Duration;
use a2a_lf::error_code;
use reqwest::header::HeaderMap;
use serde::de::DeserializeOwned;
use super::config::{A2aConfigError, A2aUrlError};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum A2aError {
#[error("invalid A2A agent configuration: {0}")]
Config(#[from] A2aConfigError),
#[error("could not connect to the A2A agent")]
Connect,
#[error("the A2A agent exchange failed: {0}")]
Transport(&'static str),
#[error("the A2A agent answered {operation} with HTTP {status}")]
HttpStatus {
operation: &'static str,
status: reqwest::StatusCode,
reply: Box<A2aHttpReply>,
},
#[error(
"the A2A agent answered with a redirect, which is not followed because it would send \
credentials to an unvalidated origin"
)]
RedirectRefused,
#[error("the A2A agent's reply exceeded the {limit} byte limit")]
ResponseTooLarge { limit: usize },
#[error("timed out after {0:?} waiting for the A2A agent")]
Timeout(Duration),
#[error("an event in the A2A agent's stream exceeded the {limit} byte limit")]
EventTooLarge { limit: usize },
#[error("the A2A agent's event stream sent nothing for {0:?}")]
StreamIdle(Duration),
#[error("the A2A agent answered {method} without an event stream")]
NotAStream { method: &'static str },
#[error("the A2A agent card is invalid: {0}")]
InvalidCard(&'static str),
#[error(
"the A2A agent card offers no JSONRPC or HTTP+JSON interface at protocol version 1.x, \
which are the only bindings and version this client speaks"
)]
NoCompatibleInterface,
#[error("the A2A agent card's interface URL is unusable: {0}")]
InvalidEndpoint(A2aUrlError),
#[error(
"refusing to send configured headers to the A2A agent's interface over plaintext \
http; the card must name https or a loopback host, or set \
allow_plaintext_credentials"
)]
PlaintextEndpoint,
#[error(
"refusing to send a push config's token or authentication to the A2A agent's interface \
over plaintext http; the card must name https or a loopback host, or set \
allow_plaintext_credentials"
)]
PlaintextPushCredentials,
#[error("{method} names an id that is empty, `.` or `..`")]
InvalidId { method: &'static str },
#[error("the A2A agent's reply to {method} could not be decoded")]
Decode { method: &'static str },
#[error("the A2A agent answered {method} with a reply for a different request id")]
MismatchedReplyId { method: &'static str },
#[error("the A2A agent answered {method} about a different task")]
MismatchedTask { method: &'static str },
#[error("the A2A agent answered {method} with {error}")]
Rpc {
method: &'static str,
error: A2aRpcError,
},
#[error(
"an A2A extension URI is empty or contains a comma, whitespace, or a character an HTTP \
header cannot carry"
)]
InvalidExtensionUri,
#[error(
"the A2A agent may have received the message but never answered; it may already be \
working on it, so it must not be resent automatically"
)]
SendIndeterminate,
}
#[derive(Clone, Default)]
pub struct A2aHttpReply {
headers: HeaderMap,
body: Vec<u8>,
truncated: bool,
}
impl A2aHttpReply {
pub const MAX_BODY: usize = 64 * 1024;
pub fn new(headers: HeaderMap, mut body: Vec<u8>) -> Self {
let truncated = body.len() > Self::MAX_BODY;
body.truncate(Self::MAX_BODY);
Self {
headers,
body,
truncated,
}
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn header(&self, name: &str) -> Option<&str> {
let mut values = self.headers.get_all(name).iter();
let value = values.next()?;
if values.next().is_some() {
return None;
}
value.to_str().ok()
}
pub fn body(&self) -> &[u8] {
&self.body
}
pub fn is_truncated(&self) -> bool {
self.truncated
}
pub fn json<T: DeserializeOwned>(&self) -> Option<T> {
if self.truncated {
return None;
}
serde_json::from_slice(&self.body).ok()
}
}
impl std::fmt::Debug for A2aHttpReply {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("A2aHttpReply")
.field("headers", &self.headers.len())
.field("body_bytes", &self.body.len())
.field("truncated", &self.truncated)
.finish()
}
}
#[cfg(test)]
mod reply_tests {
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::{Value, json};
use super::A2aHttpReply;
#[test]
fn a_reply_reads_its_header_and_its_json_body() {
let mut headers = HeaderMap::new();
headers.insert("dial-error", HeaderValue::from_static("workflow-denied"));
let reply = A2aHttpReply::new(headers, br#"{"message":"closed"}"#.to_vec());
assert_eq!(reply.header("Dial-Error"), Some("workflow-denied"));
assert_eq!(reply.header("absent"), None);
assert_eq!(reply.json::<Value>(), Some(json!({"message": "closed"})));
assert!(!reply.is_truncated());
}
#[test]
fn header_refuses_multiple_or_non_ascii_values() {
let mut headers = HeaderMap::new();
headers.append("x-many", HeaderValue::from_static("first"));
headers.append("x-many", HeaderValue::from_static("second"));
headers.insert("x-binary", HeaderValue::from_bytes(&[0x80]).unwrap());
let reply = A2aHttpReply::new(headers, Vec::new());
assert_eq!(reply.header("X-Many"), None);
assert_eq!(reply.headers().get_all("x-many").iter().count(), 2);
assert_eq!(reply.header("x-binary"), None);
}
#[test]
fn a_long_body_is_cut_and_then_is_not_json() {
let body = format!("\"{}\"", "x".repeat(A2aHttpReply::MAX_BODY));
let reply = A2aHttpReply::new(HeaderMap::new(), body.into_bytes());
assert_eq!(reply.body().len(), A2aHttpReply::MAX_BODY);
assert!(reply.is_truncated());
assert_eq!(reply.json::<Value>(), None);
}
#[test]
fn debug_shows_sizes_and_none_of_what_was_sent() {
let mut headers = HeaderMap::new();
headers.insert("x-note", HeaderValue::from_static("server words"));
let reply = A2aHttpReply::new(headers, b"server body".to_vec());
let shown = format!("{reply:?}");
assert!(!shown.contains("server"), "{shown}");
assert!(shown.contains("body_bytes: 11"), "{shown}");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum A2aRpcError {
#[error("TaskNotFoundError (-32001)")]
TaskNotFound,
#[error("TaskNotCancelableError (-32002)")]
TaskNotCancelable,
#[error("PushNotificationNotSupportedError (-32003)")]
PushNotificationNotSupported,
#[error("UnsupportedOperationError (-32004)")]
UnsupportedOperation,
#[error("ContentTypeNotSupportedError (-32005)")]
ContentTypeNotSupported,
#[error("InvalidAgentResponseError (-32006)")]
InvalidAgentResponse,
#[error("ExtendedAgentCardNotConfiguredError (-32007)")]
ExtendedAgentCardNotConfigured,
#[error("ExtensionSupportRequiredError (-32008)")]
ExtensionSupportRequired,
#[error("VersionNotSupportedError (-32009)")]
VersionNotSupported,
#[error("JSON parse error (-32700)")]
ParseError,
#[error("invalid request (-32600)")]
InvalidRequest,
#[error("method not found (-32601)")]
MethodNotFound,
#[error("invalid params (-32602)")]
InvalidParams,
#[error("internal error (-32603)")]
InternalError,
#[error("JSON-RPC error {0}")]
Other(i32),
}
impl A2aRpcError {
pub fn from_code(code: i32) -> Self {
match code {
error_code::TASK_NOT_FOUND => Self::TaskNotFound,
error_code::TASK_NOT_CANCELABLE => Self::TaskNotCancelable,
error_code::PUSH_NOTIFICATION_NOT_SUPPORTED => Self::PushNotificationNotSupported,
error_code::UNSUPPORTED_OPERATION => Self::UnsupportedOperation,
error_code::CONTENT_TYPE_NOT_SUPPORTED => Self::ContentTypeNotSupported,
error_code::INVALID_AGENT_RESPONSE => Self::InvalidAgentResponse,
error_code::EXTENDED_CARD_NOT_CONFIGURED => Self::ExtendedAgentCardNotConfigured,
error_code::EXTENSION_SUPPORT_REQUIRED => Self::ExtensionSupportRequired,
error_code::VERSION_NOT_SUPPORTED => Self::VersionNotSupported,
error_code::PARSE_ERROR => Self::ParseError,
error_code::INVALID_REQUEST => Self::InvalidRequest,
error_code::METHOD_NOT_FOUND => Self::MethodNotFound,
error_code::INVALID_PARAMS => Self::InvalidParams,
error_code::INTERNAL_ERROR => Self::InternalError,
other => Self::Other(other),
}
}
pub fn code(self) -> i32 {
match self {
Self::TaskNotFound => error_code::TASK_NOT_FOUND,
Self::TaskNotCancelable => error_code::TASK_NOT_CANCELABLE,
Self::PushNotificationNotSupported => error_code::PUSH_NOTIFICATION_NOT_SUPPORTED,
Self::UnsupportedOperation => error_code::UNSUPPORTED_OPERATION,
Self::ContentTypeNotSupported => error_code::CONTENT_TYPE_NOT_SUPPORTED,
Self::InvalidAgentResponse => error_code::INVALID_AGENT_RESPONSE,
Self::ExtendedAgentCardNotConfigured => error_code::EXTENDED_CARD_NOT_CONFIGURED,
Self::ExtensionSupportRequired => error_code::EXTENSION_SUPPORT_REQUIRED,
Self::VersionNotSupported => error_code::VERSION_NOT_SUPPORTED,
Self::ParseError => error_code::PARSE_ERROR,
Self::InvalidRequest => error_code::INVALID_REQUEST,
Self::MethodNotFound => error_code::METHOD_NOT_FOUND,
Self::InvalidParams => error_code::INVALID_PARAMS,
Self::InternalError => error_code::INTERNAL_ERROR,
Self::Other(code) => code,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_code_round_trips() {
for code in (-32009..=-32001).chain([-32700, -32600, -32601, -32602, -32603, -1, 7]) {
assert_eq!(A2aRpcError::from_code(code).code(), code);
}
assert_eq!(A2aRpcError::from_code(-32001), A2aRpcError::TaskNotFound);
assert_eq!(A2aRpcError::from_code(42), A2aRpcError::Other(42));
}
}