use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use a2a_protocol_types::proto as apb;
use a2a_protocol_types::proto::convert::ConvertError;
use tokio::sync::mpsc;
use tonic::transport::Channel;
pub use crate::config::GrpcBareAddressScheme;
use crate::error::{ClientError, ClientResult};
use crate::streaming::EventStream;
use crate::transport::Transport;
#[cfg(feature = "grpc-tls")]
pub use tonic::transport::{Certificate, ClientTlsConfig, Identity};
mod proto {
#![allow(
clippy::all,
clippy::pedantic,
clippy::nursery,
missing_docs,
unused_qualifications
)]
tonic::include_proto!("lf.a2a.v1");
}
use proto::a2a_service_client::A2aServiceClient;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GrpcTransportConfig {
pub timeout: Duration,
pub connect_timeout: Duration,
pub max_message_size: usize,
pub stream_channel_capacity: usize,
pub bare_address_scheme: GrpcBareAddressScheme,
#[cfg(feature = "grpc-tls")]
pub tls_config: Option<tonic::transport::ClientTlsConfig>,
}
impl Default for GrpcTransportConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
connect_timeout: Duration::from_secs(10),
max_message_size: 4 * 1024 * 1024,
stream_channel_capacity: 64,
bare_address_scheme: GrpcBareAddressScheme::default(),
#[cfg(feature = "grpc-tls")]
tls_config: None,
}
}
}
impl GrpcTransportConfig {
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub const fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
#[must_use]
pub const fn with_max_message_size(mut self, size: usize) -> Self {
self.max_message_size = size;
self
}
#[must_use]
pub const fn with_stream_channel_capacity(mut self, capacity: usize) -> Self {
self.stream_channel_capacity = capacity;
self
}
#[must_use]
pub const fn with_bare_address_scheme(mut self, scheme: GrpcBareAddressScheme) -> Self {
self.bare_address_scheme = scheme;
self
}
#[cfg(feature = "grpc-tls")]
#[must_use]
pub fn with_tls_config(mut self, tls: tonic::transport::ClientTlsConfig) -> Self {
self.tls_config = Some(tls);
self
}
}
pub(crate) fn normalize_endpoint(
endpoint: &str,
policy: GrpcBareAddressScheme,
) -> ClientResult<String> {
if endpoint.is_empty() {
return Err(ClientError::InvalidEndpoint(
"gRPC address must not be empty".into(),
));
}
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
return Ok(endpoint.to_owned());
}
if endpoint.contains("://") {
return Err(ClientError::InvalidEndpoint(format!(
"gRPC address must be `host:port` or an http(s) URL, got a `{}` URL: {endpoint}",
endpoint.split("://").next().unwrap_or_default()
)));
}
let authority: hyper::http::uri::Authority = endpoint.parse().map_err(|e| {
ClientError::InvalidEndpoint(format!(
"gRPC address must be `host:port` or an http(s) URL: {endpoint}: {e}"
))
})?;
if endpoint.contains('@') {
return Err(ClientError::InvalidEndpoint(format!(
"gRPC address must not carry userinfo: {endpoint}"
)));
}
let scheme = match policy {
GrpcBareAddressScheme::Http => "http",
GrpcBareAddressScheme::Https => "https",
GrpcBareAddressScheme::HttpsExceptLoopback => {
if is_loopback_host(authority.host()) {
"http"
} else {
"https"
}
}
};
Ok(format!("{scheme}://{authority}"))
}
fn is_loopback_host(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
let bare = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
bare.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
#[derive(Clone, Debug)]
pub struct GrpcTransport {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
channel: Channel,
endpoint: String,
config: GrpcTransportConfig,
stream_connect_timeout: Option<Duration>,
}
impl GrpcTransport {
pub async fn connect(endpoint: impl Into<String>) -> ClientResult<Self> {
Self::connect_with_config(endpoint, GrpcTransportConfig::default()).await
}
pub async fn connect_with_config(
endpoint: impl Into<String>,
config: GrpcTransportConfig,
) -> ClientResult<Self> {
let raw = endpoint.into();
let endpoint_str = normalize_endpoint(&raw, config.bare_address_scheme)?;
let tls_by_policy = !raw.contains("://") && endpoint_str.starts_with("https://");
let endpoint = tonic::transport::Channel::from_shared(endpoint_str.clone())
.map_err(|e| ClientError::InvalidEndpoint(format!("invalid gRPC endpoint: {e}")))?
.connect_timeout(config.connect_timeout)
.timeout(config.timeout)
.tcp_keepalive(Some(super::connector::TCP_KEEPALIVE_TIME))
.tcp_keepalive_interval(Some(super::connector::TCP_KEEPALIVE_INTERVAL))
.tcp_keepalive_retries(Some(super::connector::TCP_KEEPALIVE_RETRIES));
let endpoint = Self::apply_tls(endpoint, &endpoint_str, &config)?;
let channel = endpoint.connect().await.map_err(|e| {
if tls_by_policy {
ClientError::Transport(format!(
"gRPC connect failed: {e} (`{raw}` was dialled with TLS by \
{:?}; if this peer speaks plaintext, set \
GrpcBareAddressScheme::Http)",
config.bare_address_scheme
))
} else {
ClientError::Transport(format!("gRPC connect failed: {e}"))
}
})?;
Ok(Self {
inner: Arc::new(Inner {
channel,
endpoint: endpoint_str,
config,
stream_connect_timeout: None,
}),
})
}
fn apply_tls(
endpoint: tonic::transport::Endpoint,
endpoint_str: &str,
config: &GrpcTransportConfig,
) -> ClientResult<tonic::transport::Endpoint> {
if !endpoint_str.starts_with("https://") {
return Ok(endpoint);
}
#[cfg(feature = "grpc-tls")]
{
let tls = config
.tls_config
.clone()
.unwrap_or_else(|| tonic::transport::ClientTlsConfig::new().with_enabled_roots());
endpoint.tls_config(tls).map_err(|e| {
ClientError::Transport(format!("gRPC TLS configuration rejected: {e}"))
})
}
#[cfg(not(feature = "grpc-tls"))]
{
let _ = (endpoint, config);
Err(ClientError::Transport(format!(
"gRPC over TLS needs the `grpc-tls` feature of a2a-protocol-client; \
this build has no TLS connector for {endpoint_str}"
)))
}
}
#[must_use]
pub fn with_stream_connect_timeout(mut self, timeout: Duration) -> Self {
let inner = Arc::new(Inner {
channel: self.inner.channel.clone(),
endpoint: self.inner.endpoint.clone(),
config: self.inner.config.clone(),
stream_connect_timeout: Some(timeout),
});
self.inner = inner;
self
}
fn stream_open_bound(&self) -> Duration {
self.inner
.stream_connect_timeout
.unwrap_or(self.inner.config.timeout)
}
#[must_use]
pub fn endpoint(&self) -> &str {
&self.inner.endpoint
}
fn client(&self) -> A2aServiceClient<Channel> {
A2aServiceClient::new(self.inner.channel.clone())
.max_decoding_message_size(self.inner.config.max_message_size)
.max_encoding_message_size(self.inner.config.max_message_size)
}
fn request<T>(
&self,
message: T,
extra_headers: &HashMap<String, String>,
with_deadline: bool,
) -> ClientResult<tonic::Request<T>> {
let mut req = tonic::Request::new(message);
if with_deadline {
req.set_timeout(self.inner.config.timeout);
}
Self::add_metadata(&mut req, extra_headers)?;
Ok(req)
}
fn add_metadata<T>(
req: &mut tonic::Request<T>,
extra_headers: &HashMap<String, String>,
) -> ClientResult<()> {
let md = req.metadata_mut();
md.insert(
"a2a-version",
a2a_protocol_types::A2A_VERSION
.parse()
.unwrap_or_else(|_| tonic::metadata::MetadataValue::from_static("")),
);
for (k, v) in extra_headers {
let key = k.parse::<tonic::metadata::MetadataKey<_>>().map_err(|e| {
ClientError::Transport(format!("invalid gRPC metadata key {k:?}: {e}"))
})?;
let val = v
.parse::<tonic::metadata::MetadataValue<_>>()
.map_err(|_| {
ClientError::Transport(format!("invalid gRPC metadata value for key {k:?}"))
})?;
md.insert(key, val);
}
Ok(())
}
fn parse_params<T: serde::de::DeserializeOwned>(params: serde_json::Value) -> ClientResult<T> {
serde_json::from_value(params).map_err(ClientError::Serialization)
}
fn to_json<T: serde::Serialize>(value: &T) -> ClientResult<serde_json::Value> {
serde_json::to_value(value).map_err(ClientError::Serialization)
}
fn status_to_error(status: &tonic::Status) -> ClientError {
if is_truncation(status) {
return ClientError::HttpClient(format!("gRPC {}", status.message()));
}
match status.code() {
tonic::Code::DeadlineExceeded => {
ClientError::Timeout(format!("gRPC deadline exceeded: {}", status.message()))
}
tonic::Code::Unauthenticated => ClientError::UnexpectedStatus {
status: 401,
body: status.message().to_owned(),
retry_after: None,
},
tonic::Code::PermissionDenied => ClientError::UnexpectedStatus {
status: 403,
body: status.message().to_owned(),
retry_after: None,
},
tonic::Code::Unavailable => {
ClientError::HttpClient(format!("gRPC unavailable: {}", status.message()))
}
tonic::Code::ResourceExhausted => ClientError::UnexpectedStatus {
status: 429,
body: status.message().to_owned(),
retry_after: None,
},
_ => {
use tonic_types::StatusExt as _;
let code = status
.get_details_error_info()
.and_then(|info| a2a_protocol_types::ErrorCode::from_a2a_reason(&info.reason))
.unwrap_or_else(|| grpc_code_to_error_code(status.code()));
let message = if status.code() == tonic::Code::Cancelled {
format!("gRPC call cancelled by the peer: {}", status.message())
} else {
status.message().to_owned()
};
ClientError::Protocol(a2a_protocol_types::A2aError::new(code, message))
}
}
}
async fn execute_unary(
&self,
method: &str,
params: serde_json::Value,
extra_headers: &HashMap<String, String>,
) -> ClientResult<serde_json::Value> {
trace_info!(
method,
endpoint = %self.inner.endpoint,
"sending gRPC request"
);
let mut client = self.client();
tokio::time::timeout(
self.inner.config.timeout,
self.dispatch_unary(&mut client, method, params, extra_headers),
)
.await
.map_err(|_| {
trace_error!(method, "gRPC request timed out");
ClientError::Timeout("gRPC request timed out".into())
})?
}
#[allow(clippy::too_many_lines)]
async fn dispatch_unary(
&self,
client: &mut A2aServiceClient<Channel>,
method: &str,
params: serde_json::Value,
extra_headers: &HashMap<String, String>,
) -> ClientResult<serde_json::Value> {
match method {
"SendMessage" => {
let p: a2a_protocol_types::params::MessageSendParams = Self::parse_params(params)?;
let req = apb::SendMessageRequest::try_from(p).map_err(convert_error)?;
let resp = client
.send_message(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
let domain: a2a_protocol_types::responses::SendMessageResponse =
resp.into_inner().try_into().map_err(convert_error)?;
Self::to_json(&domain)
}
"GetTask" => {
let p: a2a_protocol_types::params::TaskQueryParams = Self::parse_params(params)?;
let req = apb::GetTaskRequest::try_from(p).map_err(convert_error)?;
let resp = client
.get_task(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
let domain: a2a_protocol_types::task::Task =
resp.into_inner().try_into().map_err(convert_error)?;
Self::to_json(&domain)
}
"ListTasks" => {
let p: a2a_protocol_types::params::ListTasksParams = Self::parse_params(params)?;
let req = apb::ListTasksRequest::try_from(p).map_err(convert_error)?;
let resp = client
.list_tasks(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
let domain: a2a_protocol_types::responses::TaskListResponse =
resp.into_inner().try_into().map_err(convert_error)?;
Self::to_json(&domain)
}
"CancelTask" => {
let p: a2a_protocol_types::params::CancelTaskParams = Self::parse_params(params)?;
let req = apb::CancelTaskRequest::try_from(p).map_err(convert_error)?;
let resp = client
.cancel_task(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
let domain: a2a_protocol_types::task::Task =
resp.into_inner().try_into().map_err(convert_error)?;
Self::to_json(&domain)
}
"CreateTaskPushNotificationConfig" => {
let p: a2a_protocol_types::push::TaskPushNotificationConfig =
Self::parse_params(params)?;
let req = apb::TaskPushNotificationConfig::from(p);
let resp = client
.create_task_push_notification_config(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
let domain: a2a_protocol_types::push::TaskPushNotificationConfig =
resp.into_inner().into();
Self::to_json(&domain)
}
"GetTaskPushNotificationConfig" => {
let p: a2a_protocol_types::params::GetPushConfigParams =
Self::parse_params(params)?;
let req = apb::GetTaskPushNotificationConfigRequest::from(p);
let resp = client
.get_task_push_notification_config(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
let domain: a2a_protocol_types::push::TaskPushNotificationConfig =
resp.into_inner().into();
Self::to_json(&domain)
}
"ListTaskPushNotificationConfigs" => {
let p: a2a_protocol_types::params::ListPushConfigsParams =
Self::parse_params(params)?;
let req = apb::ListTaskPushNotificationConfigsRequest::try_from(p)
.map_err(convert_error)?;
let resp = client
.list_task_push_notification_configs(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
let domain: a2a_protocol_types::responses::ListPushConfigsResponse =
resp.into_inner().into();
Self::to_json(&domain)
}
"DeleteTaskPushNotificationConfig" => {
let p: a2a_protocol_types::params::DeletePushConfigParams =
Self::parse_params(params)?;
let req = apb::DeleteTaskPushNotificationConfigRequest::from(p);
client
.delete_task_push_notification_config(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
Ok(serde_json::json!({}))
}
"GetExtendedAgentCard" => {
let params = if params.is_null() {
serde_json::json!({})
} else {
params
};
let p: a2a_protocol_types::params::GetExtendedAgentCardParams =
Self::parse_params(params)?;
let req = apb::GetExtendedAgentCardRequest::from(p);
let resp = client
.get_extended_agent_card(self.request(req, extra_headers, true)?)
.await
.map_err(|s| Self::status_to_error(&s))?;
let domain: a2a_protocol_types::agent_card::AgentCard =
resp.into_inner().try_into().map_err(convert_error)?;
Self::to_json(&domain)
}
other => Err(ClientError::Protocol(a2a_protocol_types::A2aError::new(
a2a_protocol_types::ErrorCode::MethodNotFound,
format!("unknown gRPC method: {other}"),
))),
}
}
async fn execute_streaming(
&self,
method: &str,
params: serde_json::Value,
extra_headers: &HashMap<String, String>,
) -> ClientResult<EventStream> {
trace_info!(
method,
endpoint = %self.inner.endpoint,
"opening gRPC stream"
);
let mut client = self.client();
let stream = tokio::time::timeout(self.stream_open_bound(), async {
match method {
"SendStreamingMessage" => {
let p: a2a_protocol_types::params::MessageSendParams =
Self::parse_params(params)?;
let req = apb::SendMessageRequest::try_from(p).map_err(convert_error)?;
client
.send_streaming_message(self.request(req, extra_headers, false)?)
.await
.map(tonic::Response::into_inner)
.map_err(|s| Self::status_to_error(&s))
}
"SubscribeToTask" => {
let p: a2a_protocol_types::params::TaskIdParams = Self::parse_params(params)?;
let req = apb::SubscribeToTaskRequest::from(p);
client
.subscribe_to_task(self.request(req, extra_headers, false)?)
.await
.map(tonic::Response::into_inner)
.map_err(|s| Self::status_to_error(&s))
}
other => Err(ClientError::Protocol(a2a_protocol_types::A2aError::new(
a2a_protocol_types::ErrorCode::MethodNotFound,
format!("unknown streaming gRPC method: {other}"),
))),
}
})
.await
.map_err(|_| {
trace_error!(method, "gRPC stream connect timed out");
ClientError::Timeout("gRPC stream connect timed out".into())
})??;
let cap = self.inner.config.stream_channel_capacity;
let (tx, rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(cap);
let task_handle = tokio::spawn(async move {
grpc_stream_reader_task(stream, tx).await;
});
Ok(
EventStream::with_status(rx, task_handle.abort_handle(), 200)
.with_first_event_timeout(crate::config::DEFAULT_STREAM_FIRST_EVENT_TIMEOUT),
)
}
}
impl Transport for GrpcTransport {
fn send_request<'a>(
&'a self,
method: &'a str,
params: serde_json::Value,
extra_headers: &'a HashMap<String, String>,
) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
Box::pin(self.execute_unary(method, params, extra_headers))
}
fn send_streaming_request<'a>(
&'a self,
method: &'a str,
params: serde_json::Value,
extra_headers: &'a HashMap<String, String>,
) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
Box::pin(self.execute_streaming(method, params, extra_headers))
}
}
async fn grpc_stream_reader_task<S>(
mut stream: S,
tx: mpsc::Sender<crate::streaming::event_stream::BodyChunk>,
) where
S: tonic::codegen::tokio_stream::Stream<Item = Result<apb::StreamResponse, tonic::Status>>
+ Unpin,
{
use tonic::codegen::tokio_stream::StreamExt;
loop {
match stream.next().await {
Some(Ok(pb_event)) => {
let event: a2a_protocol_types::events::StreamResponse =
match pb_event.try_into().map_err(convert_error) {
Ok(e) => e,
Err(err) => {
let _ = tx.send(Err(err)).await;
break;
}
};
let json_str = match serde_json::to_string(&event) {
Ok(s) => s,
Err(e) => {
let _ = tx.send(Err(ClientError::Serialization(e))).await;
break;
}
};
let envelope =
format!("data: {{\"jsonrpc\":\"2.0\",\"id\":null,\"result\":{json_str}}}\n\n");
if tx
.send(Ok(hyper::body::Bytes::from(envelope)))
.await
.is_err()
{
break;
}
}
Some(Err(status)) if is_truncation(&status) => break,
Some(Err(status)) => {
let _ = tx.send(Err(GrpcTransport::status_to_error(&status))).await;
break;
}
None => break,
}
}
}
fn is_truncation(status: &tonic::Status) -> bool {
status.code() == tonic::Code::Unknown
&& status
.message()
.starts_with("protocol error: missing grpc-status trailer")
}
#[allow(clippy::needless_pass_by_value)]
fn convert_error(err: ConvertError) -> ClientError {
ClientError::Transport(format!("protobuf conversion failed: {err}"))
}
const fn grpc_code_to_error_code(code: tonic::Code) -> a2a_protocol_types::ErrorCode {
match code {
tonic::Code::NotFound => a2a_protocol_types::ErrorCode::TaskNotFound,
tonic::Code::InvalidArgument => a2a_protocol_types::ErrorCode::InvalidParams,
tonic::Code::Unimplemented => a2a_protocol_types::ErrorCode::MethodNotFound,
tonic::Code::FailedPrecondition => a2a_protocol_types::ErrorCode::TaskNotCancelable,
_ => a2a_protocol_types::ErrorCode::InternalError,
}
}
#[cfg(test)]
mod tests {
use super::*;
use a2a_protocol_types::events::TaskStatusUpdateEvent;
use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
use GrpcBareAddressScheme as Scheme;
fn norm(endpoint: &str, policy: Scheme) -> ClientResult<String> {
normalize_endpoint(endpoint, policy)
}
#[test]
fn normalize_rejects_empty() {
assert!(matches!(
norm("", Scheme::default()),
Err(ClientError::InvalidEndpoint(_))
));
}
#[test]
fn normalize_rejects_other_schemes_by_name() {
for bad in [
"ftp://example.com",
"ws://example.com:1",
"grpc://example.com:443",
] {
let err = norm(bad, Scheme::default()).unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, ClientError::InvalidEndpoint(_)) && msg.contains(bad),
"{bad}: expected InvalidEndpoint naming the input, got {msg}"
);
}
}
#[test]
fn normalize_passes_http_and_https_urls_through_unchanged() {
for url in [
"http://localhost:50051",
"https://agent.example.com:443",
"http://10.0.0.5:9",
] {
for policy in [Scheme::HttpsExceptLoopback, Scheme::Https, Scheme::Http] {
assert_eq!(
norm(url, policy).unwrap(),
url,
"policy must not touch a URL"
);
}
}
}
#[test]
fn normalize_bare_address_default_is_tls_for_a_remote_host() {
assert_eq!(
norm("grpc.example.com:443", Scheme::default()).unwrap(),
"https://grpc.example.com:443"
);
assert_eq!(
norm("grpc.example.com", Scheme::default()).unwrap(),
"https://grpc.example.com",
"a target without a port keeps the scheme's default port"
);
}
#[test]
fn normalize_bare_address_default_is_plaintext_for_loopback() {
for local in [
"localhost:50051",
"LOCALHOST:50051",
"127.0.0.1:9998",
"127.9.8.7:1",
"[::1]:50051",
] {
let got = norm(local, Scheme::default()).unwrap();
assert_eq!(got, format!("http://{local}"), "{local} is loopback");
}
for remote in [
"10.0.0.5:50051",
"[fe80::1]:50051",
"0.0.0.0:50051",
"127.example.com:1",
] {
let got = norm(remote, Scheme::default()).unwrap();
assert_eq!(got, format!("https://{remote}"), "{remote} is not loopback");
}
}
#[test]
fn normalize_explicit_policies_override_the_loopback_rule() {
assert_eq!(
norm("localhost:1", Scheme::Https).unwrap(),
"https://localhost:1"
);
assert_eq!(
norm("agent:50051", Scheme::Http).unwrap(),
"http://agent:50051",
"a Compose service name on a private network"
);
}
#[test]
fn normalize_rejects_paths_and_userinfo() {
for bad in [
"agent:50051/a2a",
"user:pw@agent:50051",
"agent:50051?x=1",
"a b:1",
] {
assert!(
matches!(
norm(bad, Scheme::default()),
Err(ClientError::InvalidEndpoint(_))
),
"{bad} is not a gRPC target"
);
}
}
#[test]
fn config_default_timeout() {
let cfg = GrpcTransportConfig::default();
assert_eq!(cfg.timeout, Duration::from_secs(30));
}
#[test]
fn config_builder() {
let cfg = GrpcTransportConfig::default()
.with_timeout(Duration::from_secs(60))
.with_max_message_size(8 * 1024 * 1024)
.with_stream_channel_capacity(128);
assert_eq!(cfg.timeout, Duration::from_secs(60));
assert_eq!(cfg.max_message_size, 8 * 1024 * 1024);
assert_eq!(cfg.stream_channel_capacity, 128);
}
#[cfg(feature = "grpc-tls")]
#[test]
fn with_tls_config_stores_it_and_keeps_the_rest() {
let cfg = GrpcTransportConfig::default()
.with_timeout(Duration::from_secs(60))
.with_tls_config(
tonic::transport::ClientTlsConfig::new().domain_name("agent.internal"),
);
assert!(cfg.tls_config.is_some(), "the TLS config must be stored");
assert_eq!(
cfg.timeout,
Duration::from_secs(60),
"fields set before with_tls_config must not be reset"
);
}
#[test]
fn convert_error_maps_to_non_retryable_transport() {
let err = convert_error(ConvertError {
field: "part.raw",
reason: "invalid base64".into(),
});
assert!(
matches!(err, ClientError::Transport(_)),
"conversion failures must be non-retryable: {err:?}"
);
assert!(!err.is_retryable());
}
#[test]
fn grpc_code_not_found_maps_to_task_not_found() {
assert_eq!(
grpc_code_to_error_code(tonic::Code::NotFound),
a2a_protocol_types::ErrorCode::TaskNotFound,
);
}
#[test]
fn grpc_code_invalid_argument_maps_to_invalid_params() {
assert_eq!(
grpc_code_to_error_code(tonic::Code::InvalidArgument),
a2a_protocol_types::ErrorCode::InvalidParams,
);
}
#[test]
fn grpc_code_unimplemented_maps_to_method_not_found() {
assert_eq!(
grpc_code_to_error_code(tonic::Code::Unimplemented),
a2a_protocol_types::ErrorCode::MethodNotFound,
);
}
#[test]
fn grpc_code_failed_precondition_maps_to_task_not_cancelable() {
assert_eq!(
grpc_code_to_error_code(tonic::Code::FailedPrecondition),
a2a_protocol_types::ErrorCode::TaskNotCancelable,
);
}
#[test]
fn grpc_code_deadline_exceeded_maps_to_internal() {
assert_eq!(
grpc_code_to_error_code(tonic::Code::DeadlineExceeded),
a2a_protocol_types::ErrorCode::InternalError,
);
}
#[test]
fn grpc_code_cancelled_maps_to_internal() {
assert_eq!(
grpc_code_to_error_code(tonic::Code::Cancelled),
a2a_protocol_types::ErrorCode::InternalError,
);
}
#[test]
fn grpc_code_unknown_maps_to_internal() {
assert_eq!(
grpc_code_to_error_code(tonic::Code::Unknown),
a2a_protocol_types::ErrorCode::InternalError,
);
}
#[test]
fn add_metadata_injects_a2a_version() {
let mut req = tonic::Request::new(());
let headers = HashMap::new();
GrpcTransport::add_metadata(&mut req, &headers).expect("valid headers");
let md = req.metadata();
let version_value = md
.get("a2a-version")
.expect("a2a-version header should be present");
assert_eq!(
version_value.to_str().unwrap(),
a2a_protocol_types::A2A_VERSION,
);
}
#[test]
fn add_metadata_injects_extra_headers() {
let mut req = tonic::Request::new(());
let mut headers = HashMap::new();
headers.insert("x-custom".to_string(), "value123".to_string());
GrpcTransport::add_metadata(&mut req, &headers).expect("valid headers");
let md = req.metadata();
assert_eq!(md.get("x-custom").unwrap().to_str().unwrap(), "value123",);
}
#[test]
fn add_metadata_fails_closed_on_invalid_header() {
let mut req = tonic::Request::new(());
let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer bad\nvalue".to_string());
let result = GrpcTransport::add_metadata(&mut req, &headers);
assert!(
matches!(result, Err(ClientError::Transport(_))),
"invalid metadata must fail closed, got: {result:?}"
);
if let Err(ClientError::Transport(msg)) = result {
assert!(!msg.contains("Bearer bad"), "value leaked in error: {msg}");
}
}
#[test]
fn resource_exhausted_maps_to_retryable_429() {
let status = tonic::Status::resource_exhausted("slow down");
let err = GrpcTransport::status_to_error(&status);
assert!(
matches!(err, ClientError::UnexpectedStatus { status: 429, .. }),
"ResourceExhausted should map to 429, got {err:?}"
);
assert!(
err.is_retryable(),
"gRPC ResourceExhausted must be retryable"
);
}
#[test]
fn only_the_missing_trailer_status_is_a_truncation() {
let truncated = tonic::Status::unknown(
"protocol error: missing grpc-status trailer, stream was terminated without a final status",
);
assert!(is_truncation(&truncated));
assert!(matches!(
GrpcTransport::status_to_error(&truncated),
ClientError::HttpClient(_)
));
let peer_unknown = tonic::Status::unknown("the agent did not say why");
assert!(!is_truncation(&peer_unknown));
assert!(matches!(
GrpcTransport::status_to_error(&peer_unknown),
ClientError::Protocol(_)
));
let same_words = tonic::Status::internal(
"protocol error: missing grpc-status trailer, stream was terminated without a final status",
);
assert!(!is_truncation(&same_words));
}
#[test]
fn status_to_error_deadline_exceeded_is_timeout() {
let status = tonic::Status::deadline_exceeded("test deadline");
let err = GrpcTransport::status_to_error(&status);
assert!(
matches!(err, ClientError::Timeout(_)),
"DeadlineExceeded should map to Timeout, got: {err:?}"
);
}
#[test]
fn status_to_error_cancelled_is_a_non_retryable_protocol_error() {
let status = tonic::Status::cancelled("test cancel");
let err = GrpcTransport::status_to_error(&status);
match &err {
ClientError::Protocol(e) => {
assert_eq!(e.code, a2a_protocol_types::ErrorCode::InternalError);
assert!(e.message.contains("cancelled by the peer"), "{e:?}");
assert!(e.message.contains("test cancel"), "{e:?}");
}
other => panic!("Cancelled should map to Protocol, got: {other:?}"),
}
assert!(!err.is_retryable());
}
#[test]
fn status_to_error_cancelled_keeps_an_error_info_reason() {
use tonic_types::StatusExt as _;
let mut details = tonic_types::ErrorDetails::new();
details.set_error_info(
"TASK_NOT_FOUND",
a2a_protocol_types::error::A2A_ERROR_DOMAIN,
std::collections::HashMap::<String, String>::new(),
);
let status = tonic::Status::with_error_details(tonic::Code::Cancelled, "gone", details);
match GrpcTransport::status_to_error(&status) {
ClientError::Protocol(e) => {
assert_eq!(e.code, a2a_protocol_types::ErrorCode::TaskNotFound);
}
other => panic!("expected Protocol, got: {other:?}"),
}
}
#[test]
fn status_to_error_unauthenticated_is_401() {
let err = GrpcTransport::status_to_error(&tonic::Status::unauthenticated("bad token"));
match &err {
ClientError::UnexpectedStatus {
status,
body,
retry_after,
} => {
assert_eq!(*status, 401);
assert_eq!(body, "bad token");
assert!(retry_after.is_none());
}
other => panic!("expected a 401, got: {other:?}"),
}
assert!(!err.is_retryable());
}
#[test]
fn status_to_error_permission_denied_is_403() {
let err = GrpcTransport::status_to_error(&tonic::Status::permission_denied("no"));
match &err {
ClientError::UnexpectedStatus { status, body, .. } => {
assert_eq!(*status, 403);
assert_eq!(body, "no");
}
other => panic!("expected a 403, got: {other:?}"),
}
assert!(!err.is_retryable());
}
#[test]
fn status_to_error_unavailable_is_http_client() {
let status = tonic::Status::unavailable("test unavailable");
let err = GrpcTransport::status_to_error(&status);
assert!(
matches!(err, ClientError::HttpClient(_)),
"Unavailable should map to HttpClient, got: {err:?}"
);
}
#[test]
fn status_to_error_other_is_protocol() {
let status = tonic::Status::internal("test internal");
let err = GrpcTransport::status_to_error(&status);
assert!(
matches!(err, ClientError::Protocol(_)),
"other codes should map to Protocol, got: {err:?}"
);
}
#[test]
fn status_to_error_prefers_error_info_reason() {
use tonic_types::StatusExt as _;
let mut details = tonic_types::ErrorDetails::new();
details.set_error_info(
"TASK_NOT_CANCELABLE",
"a2a-protocol.org",
std::collections::HashMap::<String, String>::new(),
);
let status = tonic::Status::with_error_details(
tonic::Code::FailedPrecondition,
"task done",
details,
);
let err = GrpcTransport::status_to_error(&status);
match err {
ClientError::Protocol(a2a) => assert_eq!(
a2a.code,
a2a_protocol_types::ErrorCode::TaskNotCancelable,
"ErrorInfo reason must resolve the exact A2A code"
),
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[test]
fn status_to_error_unknown_reason_falls_back_to_code() {
use tonic_types::StatusExt as _;
let mut details = tonic_types::ErrorDetails::new();
details.set_error_info(
"SOMETHING_NOVEL",
"a2a-protocol.org",
std::collections::HashMap::<String, String>::new(),
);
let status = tonic::Status::with_error_details(tonic::Code::NotFound, "missing", details);
let err = GrpcTransport::status_to_error(&status);
match err {
ClientError::Protocol(a2a) => assert_eq!(
a2a.code,
a2a_protocol_types::ErrorCode::TaskNotFound,
"unknown reason must fall back to code-based mapping"
),
other => panic!("expected Protocol error, got: {other:?}"),
}
}
fn status_update_event() -> apb::StreamResponse {
let event = TaskStatusUpdateEvent {
task_id: TaskId("t-1".into()),
context_id: ContextId("c-1".into()),
status: TaskStatus {
state: TaskState::Working,
message: None,
timestamp: None,
},
metadata: None,
};
apb::StreamResponse {
payload: Some(apb::stream_response::Payload::StatusUpdate(
event.try_into().unwrap(),
)),
}
}
#[tokio::test]
async fn grpc_stream_reader_task_forwards_typed_event_as_sse() {
let payloads = vec![Ok(status_update_event())];
let stream = tonic::codegen::tokio_stream::iter(payloads);
let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
grpc_stream_reader_task(stream, tx).await;
let first = rx.recv().await.expect("expected one chunk");
let bytes = first.expect("expected Ok chunk");
let text = std::str::from_utf8(&bytes).expect("utf8");
assert!(
text.starts_with("data: "),
"chunk must be SSE-framed: {text}"
);
assert!(
text.contains("\"jsonrpc\":\"2.0\""),
"chunk must be JSON-RPC envelope: {text}"
);
assert!(
text.contains("\"statusUpdate\""),
"typed event must serialize as the domain union: {text}"
);
assert!(
text.contains("TASK_STATE_WORKING"),
"state must use canonical wire encoding: {text}"
);
assert!(rx.recv().await.is_none());
}
#[tokio::test]
async fn grpc_stream_reader_task_forwards_multiple_payloads() {
let payloads = vec![
Ok(status_update_event()),
Ok(status_update_event()),
Ok(status_update_event()),
];
let stream = tonic::codegen::tokio_stream::iter(payloads);
let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
grpc_stream_reader_task(stream, tx).await;
let mut received = 0;
while let Some(item) = rx.recv().await {
assert!(item.is_ok());
received += 1;
}
assert_eq!(received, 3, "all three payloads must be forwarded");
}
#[tokio::test]
async fn grpc_stream_reader_task_maps_status_error_to_protocol_error() {
let payloads: Vec<Result<apb::StreamResponse, tonic::Status>> =
vec![Err(tonic::Status::not_found("missing"))];
let stream = tonic::codegen::tokio_stream::iter(payloads);
let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
grpc_stream_reader_task(stream, tx).await;
let chunk = rx.recv().await.expect("expected an error chunk");
match chunk {
Err(ClientError::Protocol(a2a)) => {
assert_eq!(a2a.code, a2a_protocol_types::ErrorCode::TaskNotFound);
assert!(a2a.message.contains("missing"));
}
other => panic!("expected Protocol(TaskNotFound), got {other:?}"),
}
}
#[tokio::test]
async fn grpc_stream_reader_task_rejects_empty_payload() {
let payloads = vec![Ok(apb::StreamResponse { payload: None })];
let stream = tonic::codegen::tokio_stream::iter(payloads);
let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
grpc_stream_reader_task(stream, tx).await;
let chunk = rx.recv().await.expect("expected an error chunk");
match chunk {
Err(ClientError::Transport(msg)) => {
assert!(
msg.contains("streamResponse.payload"),
"msg should name the field: {msg}"
);
}
other => panic!("expected Transport error, got {other:?}"),
}
}
#[tokio::test]
async fn grpc_transport_endpoint_returns_input_url() {
let endpoint_str = "http://localhost:50055".to_string();
let channel = tonic::transport::Channel::from_shared(endpoint_str.clone())
.expect("valid endpoint")
.connect_lazy();
let transport = GrpcTransport {
inner: Arc::new(Inner {
channel,
endpoint: endpoint_str.clone(),
config: GrpcTransportConfig::default(),
stream_connect_timeout: None,
}),
};
assert_eq!(transport.endpoint(), endpoint_str);
}
#[tokio::test]
async fn the_stream_open_bound_follows_stream_connect_timeout_when_set() {
let endpoint = "http://example.com:1234".to_string();
let mk = || {
let channel = tonic::transport::Channel::from_shared(endpoint.clone())
.expect("valid endpoint")
.connect_lazy();
GrpcTransport {
inner: Arc::new(Inner {
channel,
endpoint: endpoint.clone(),
config: GrpcTransportConfig::default().with_timeout(Duration::from_secs(30)),
stream_connect_timeout: None,
}),
}
};
assert_eq!(
mk().stream_open_bound(),
Duration::from_secs(30),
"unset must fall back to the unary timeout, so a caller who \
constructs this transport directly sees no change"
);
assert_eq!(
mk().with_stream_connect_timeout(Duration::from_secs(3))
.stream_open_bound(),
Duration::from_secs(3),
"and a set stream_connect_timeout must win over the unary timeout"
);
}
#[tokio::test]
async fn grpc_transport_endpoint_preserves_distinct_urls() {
let a = "http://example.com:1234".to_string();
let b = "https://other.test:9000".to_string();
let mk = |s: String| {
let ch = tonic::transport::Channel::from_shared(s.clone())
.unwrap()
.connect_lazy();
GrpcTransport {
inner: Arc::new(Inner {
channel: ch,
endpoint: s,
config: GrpcTransportConfig::default(),
stream_connect_timeout: None,
}),
}
};
let ta = mk(a.clone());
let tb = mk(b.clone());
assert_eq!(ta.endpoint(), a);
assert_eq!(tb.endpoint(), b);
assert_ne!(ta.endpoint(), tb.endpoint());
}
}