use crate::client::A2aClient;
use crate::config::{BINDING_GRPC, BINDING_HTTP_JSON, BINDING_JSONRPC, ClientConfig};
use crate::error::{ClientError, ClientResult};
use crate::retry::RetryTransport;
use crate::transport::{JsonRpcTransport, RestTransport, Transport};
use super::ClientBuilder;
use super::selection::canonical_binding;
impl ClientBuilder {
pub fn build(mut self) -> ClientResult<A2aClient> {
if self.config.request_timeout.is_zero() {
return Err(ClientError::Transport(
"request_timeout must be non-zero".into(),
));
}
if self.config.stream_connect_timeout.is_zero() {
return Err(ClientError::Transport(
"stream_connect_timeout must be non-zero".into(),
));
}
if self.config.connection_timeout.is_zero() {
return Err(ClientError::Transport(
"connection_timeout must be non-zero".into(),
));
}
let transport: Box<dyn Transport> = if let Some(t) = self.transport_override.take() {
t
} else {
let binding = self
.preferred_binding
.clone()
.unwrap_or_else(|| BINDING_JSONRPC.into());
match http_transport(&self.config, &binding, &self.endpoint) {
Ok(t) => t,
Err(first) => self.fall_back(&binding, first)?,
}
};
let transport: Box<dyn Transport> = if let Some(policy) = self.retry_policy {
Box::new(
RetryTransport::new(transport, policy)
.with_peer_honouring_idempotency(self.peer_honours_idempotency),
)
} else {
transport
};
Ok(A2aClient::new(transport, self.interceptors, self.config))
}
fn fall_back(&mut self, binding: &str, first: ClientError) -> ClientResult<Box<dyn Transport>> {
if self.fallback_interfaces.is_empty() {
return Err(first);
}
let mut failures = vec![format!("{binding} at {}: {first}", self.endpoint)];
let tenant_from_card = self
.chosen_interface()
.is_some_and(|i| i.tenant == self.config.tenant);
for iface in std::mem::take(&mut self.fallback_interfaces) {
match http_transport(&self.config, &iface.protocol_binding, &iface.url) {
Ok(t) => {
trace_warn!(
chosen = %binding,
used = %iface.protocol_binding,
url = %iface.url,
"chosen interface could not be built; using the next one on the card"
);
if tenant_from_card {
self.config.tenant = iface.tenant;
}
self.endpoint = iface.url;
self.preferred_binding = Some(iface.protocol_binding);
return Ok(t);
}
Err(e) => {
failures.push(format!("{} at {}: {e}", iface.protocol_binding, iface.url));
}
}
}
Err(ClientError::Transport(format!(
"no interface on the agent card could be built: {}",
failures.join("; ")
)))
}
#[cfg(feature = "grpc")]
pub async fn build_grpc(self) -> ClientResult<A2aClient> {
use crate::transport::grpc::{GrpcTransport, GrpcTransportConfig};
if self.config.request_timeout.is_zero() {
return Err(ClientError::Transport(
"request_timeout must be non-zero".into(),
));
}
if self.config.stream_connect_timeout.is_zero() {
return Err(ClientError::Transport(
"stream_connect_timeout must be non-zero".into(),
));
}
let transport: Box<dyn Transport> = if let Some(t) = self.transport_override {
t
} else {
let grpc_config = GrpcTransportConfig::default()
.with_timeout(self.config.request_timeout)
.with_connect_timeout(self.config.connection_timeout)
.with_max_message_size(self.config.max_response_size)
.with_bare_address_scheme(self.grpc_bare_address_scheme);
#[cfg(feature = "grpc-tls")]
let grpc_config = match self.grpc_tls_config {
Some(tls) => grpc_config.with_tls_config(tls),
None => grpc_config,
};
let t = GrpcTransport::connect_with_config(&self.endpoint, grpc_config)
.await?
.with_stream_connect_timeout(self.config.stream_connect_timeout);
Box::new(t)
};
let transport: Box<dyn Transport> = if let Some(policy) = self.retry_policy {
Box::new(
RetryTransport::new(transport, policy)
.with_peer_honouring_idempotency(self.peer_honours_idempotency),
)
} else {
transport
};
Ok(A2aClient::new(transport, self.interceptors, self.config))
}
}
#[cfg(feature = "grpc")]
const GRPC_NOT_SYNC: &str = "gRPC transport requires async connect; \
use ClientBuilder::build_grpc() or \
with_custom_transport(GrpcTransport::connect(...))";
#[cfg(not(feature = "grpc"))]
const GRPC_NOT_SYNC: &str = "gRPC transport requires the `grpc` feature flag";
fn http_transport(
config: &ClientConfig,
binding: &str,
endpoint: &str,
) -> ClientResult<Box<dyn Transport>> {
match canonical_binding(binding) {
Some(BINDING_JSONRPC) => Ok(Box::new(
JsonRpcTransport::with_all_timeouts(
endpoint,
config.request_timeout,
config.stream_connect_timeout,
config.connection_timeout,
)?
.with_max_response_size(config.max_response_size),
)),
Some(BINDING_HTTP_JSON) => Ok(Box::new(
RestTransport::with_all_timeouts(
endpoint,
config.request_timeout,
config.stream_connect_timeout,
config.connection_timeout,
)?
.with_max_response_size(config.max_response_size),
)),
Some(BINDING_GRPC) => Err(ClientError::Transport(GRPC_NOT_SYNC.into())),
_ => Err(ClientError::Transport(format!(
"unknown protocol binding: {binding}"
))),
}
}
#[cfg(test)]
mod tests {
use super::super::*;
use crate::config::{BINDING_GRPC, BINDING_HTTP_JSON, BINDING_REST};
use std::time::Duration;
#[test]
fn builder_defaults_to_jsonrpc() {
let client = ClientBuilder::new("http://localhost:8080")
.build()
.expect("build");
let _ = client;
}
#[test]
fn builder_rest_transport() {
let client = ClientBuilder::new("http://localhost:8080")
.with_protocol_binding(BINDING_REST)
.build()
.expect("build");
let _ = client;
}
#[test]
fn builder_accepts_spec_http_json_binding() {
let client = ClientBuilder::new("http://localhost:8080")
.with_protocol_binding(BINDING_HTTP_JSON)
.build()
.expect("HTTP+JSON binding should resolve to the REST transport");
let _ = client;
}
#[test]
fn builder_grpc_sync_build_returns_error() {
let result = ClientBuilder::new("http://localhost:8080")
.with_protocol_binding(BINDING_GRPC)
.build();
assert!(result.is_err());
}
#[test]
fn builder_invalid_url_returns_error() {
let result = ClientBuilder::new("not-a-url").build();
assert!(result.is_err());
}
#[test]
fn builder_zero_request_timeout_errors() {
let result = ClientBuilder::new("http://localhost:8080")
.with_timeout(Duration::ZERO)
.build();
assert!(result.is_err());
}
#[test]
fn builder_zero_stream_timeout_errors() {
let result = ClientBuilder::new("http://localhost:8080")
.with_stream_connect_timeout(Duration::ZERO)
.build();
assert!(result.is_err());
}
#[cfg(feature = "grpc")]
#[tokio::test]
async fn build_grpc_zero_stream_timeout_errors() {
let result = ClientBuilder::new("http://127.0.0.1:1")
.with_stream_connect_timeout(Duration::ZERO)
.build_grpc()
.await;
let err = result.expect_err("a zero stream_connect_timeout is invalid");
assert!(
err.to_string().contains("stream_connect_timeout"),
"the error must name the knob that was wrong, not the dial that \
never should have been attempted: {err}"
);
}
#[test]
fn builder_zero_connection_timeout_errors() {
let result = ClientBuilder::new("http://localhost:8080")
.with_connection_timeout(Duration::ZERO)
.build();
assert!(result.is_err());
}
#[test]
fn builder_unknown_binding_errors() {
let result = ClientBuilder::new("http://localhost:8080")
.with_protocol_binding("UNKNOWN_PROTOCOL")
.build();
assert!(result.is_err());
}
#[test]
fn builder_rest_with_retry_policy() {
use crate::retry::RetryPolicy;
let client = ClientBuilder::new("http://localhost:8080")
.with_protocol_binding(BINDING_REST)
.with_retry_policy(RetryPolicy::default())
.build()
.expect("build");
let _ = client;
}
#[test]
fn builder_jsonrpc_with_retry_policy() {
use crate::retry::RetryPolicy;
let client = ClientBuilder::new("http://localhost:8080")
.with_retry_policy(RetryPolicy::default())
.build()
.expect("build");
let _ = client;
}
#[test]
fn builder_from_card_rejects_incompatible_binding() {
use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
let card = AgentCard {
url: None,
name: "test".into(),
version: "1.0".into(),
description: "Test agent".into(),
supported_interfaces: vec![AgentInterface {
url: "http://localhost:9090".into(),
protocol_binding: "UNKNOWN".into(),
protocol_version: "1.0.0".into(),
tenant: None,
}],
provider: None,
icon_url: None,
documentation_url: None,
capabilities: AgentCapabilities::none(),
security_schemes: None,
security_requirements: None,
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
signatures: None,
};
let result = ClientBuilder::from_card(&card).unwrap().build();
assert!(result.is_err(), "unknown binding should fail");
}
}