use std::future::Future;
use std::pin::Pin;
use a2a_protocol_client::ClientBuilder;
use a2a_protocol_client::error::ClientError;
use a2a_protocol_server::builder::RequestHandlerBuilder;
use a2a_protocol_server::handler::SendMessageResult;
use a2a_protocol_server::{AgentExecutor, EventQueueWriter, RequestContext};
use a2a_protocol_types::error::{A2aError, A2aResult, ErrorCode};
use a2a_protocol_types::failure::{FailureClass, error_class};
use a2a_protocol_types::message::Message;
use a2a_protocol_types::params::{MessageSendParams, TaskQueryParams};
use a2a_protocol_types::responses::SendMessageResponse;
use a2a_protocol_types::task::TaskState;
fn convert(e: ClientError) -> A2aError {
A2aError::from(e)
}
#[test]
fn a_downstream_protocol_error_passes_through_unchanged() {
let downstream = A2aError::with_data(
ErrorCode::TaskNotFound,
"Task not found: t-9",
serde_json::json!([{"reason": "TASK_NOT_FOUND"}]),
);
let got = convert(ClientError::Protocol(downstream.clone()));
assert_eq!(got.code, downstream.code);
assert_eq!(got.message, downstream.message);
assert_eq!(got.data, downstream.data);
assert_eq!(error_class(&got), FailureClass::InvalidRequest);
}
#[test]
fn every_local_failure_maps_to_a_code_and_class_and_keeps_its_text() {
let serde_err = serde_json::from_str::<u8>("nope").expect_err("not a u8");
let cases: Vec<(ClientError, ErrorCode, FailureClass, &str)> = vec![
(
ClientError::Timeout("request timed out after 30s".into()),
ErrorCode::InternalError,
FailureClass::Transient,
"request timed out after 30s",
),
(
ClientError::HttpClient("connection refused".into()),
ErrorCode::InternalError,
FailureClass::Transient,
"connection refused",
),
(
ClientError::TooManyPendingRequests { limit: 8 },
ErrorCode::InternalError,
FailureClass::Transient,
"limit 8",
),
(
ClientError::UnexpectedStatus {
status: 503,
body: "overloaded".into(),
retry_after: None,
},
ErrorCode::InternalError,
FailureClass::Transient,
"503: overloaded",
),
(
ClientError::UnexpectedStatus {
status: 404,
body: "no such route".into(),
retry_after: None,
},
ErrorCode::InternalError,
FailureClass::Internal,
"404: no such route",
),
(
ClientError::Serialization(serde_err),
ErrorCode::InvalidAgentResponse,
FailureClass::Internal,
"expected ident",
),
(
ClientError::Transport("no transport for binding FOO".into()),
ErrorCode::InternalError,
FailureClass::Internal,
"no transport for binding FOO",
),
(
ClientError::InvalidEndpoint("not a url".into()),
ErrorCode::InternalError,
FailureClass::Internal,
"not a url",
),
(
ClientError::ProtocolBindingMismatch("got HTML".into()),
ErrorCode::InternalError,
FailureClass::Internal,
"got HTML",
),
(
ClientError::AuthRequired {
task_id: "t-1".into(),
},
ErrorCode::InternalError,
FailureClass::Internal,
"t-1",
),
];
for (err, code, class, text) in cases {
let shown = err.to_string();
let got = convert(err);
assert_eq!(got.code, code, "{shown}");
assert_eq!(error_class(&got), class, "{shown}");
assert!(
got.message.contains(text) && got.message.contains(&shown),
"{shown}: message {:?} lost the client's text",
got.message
);
}
}
#[test]
fn transient_exactly_when_the_client_would_retry() {
for status in [400_u16, 401, 403, 404, 408, 429, 500, 502, 503, 504] {
let err = ClientError::UnexpectedStatus {
status,
body: String::new(),
retry_after: None,
};
let retryable = err.is_retryable();
let got = convert(err);
assert_eq!(
error_class(&got).is_retryable(),
retryable,
"status {status}"
);
}
}
struct DelegatingExecutor {
downstream: String,
}
impl AgentExecutor for DelegatingExecutor {
fn execute<'a>(
&'a self,
_ctx: &'a RequestContext,
_queue: &'a dyn EventQueueWriter,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
let client = ClientBuilder::new(self.downstream.as_str()).build()?;
let _task = client
.get_task(TaskQueryParams {
tenant: None,
id: "t-1".into(),
history_length: None,
})
.await?;
Ok(())
})
}
}
#[tokio::test]
async fn a_refused_delegation_fails_the_task_as_transient() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
drop(listener);
let handler = RequestHandlerBuilder::new(DelegatingExecutor {
downstream: format!("http://127.0.0.1:{port}"),
})
.build()
.expect("handler");
let result = handler
.on_send_message(
MessageSendParams::new(Message::user_text("m1", "go")),
false,
None,
)
.await
.expect("a failed task is a task");
let SendMessageResult::Response(SendMessageResponse::Task(task)) = result else {
panic!("expected a task, got {result:?}");
};
assert_eq!(task.status.state, TaskState::Failed);
assert_eq!(task.failure_class(), Some(FailureClass::Transient));
let why = task.status.message.as_ref().expect("status message");
let text = why.text().unwrap_or_default();
assert!(
text.contains(&port.to_string()) || text.to_lowercase().contains("connect"),
"the client's own error text must reach the caller, got {text:?}"
);
}