a2a_protocol_client/builder/
transport_factory.rs1use crate::client::A2aClient;
13use crate::config::{BINDING_GRPC, BINDING_HTTP_JSON, BINDING_JSONRPC, ClientConfig};
14use crate::error::{ClientError, ClientResult};
15use crate::retry::RetryTransport;
16use crate::transport::{JsonRpcTransport, RestTransport, Transport};
17
18use super::ClientBuilder;
19use super::selection::canonical_binding;
20
21impl ClientBuilder {
22 pub fn build(mut self) -> ClientResult<A2aClient> {
40 if self.config.request_timeout.is_zero() {
41 return Err(ClientError::Transport(
42 "request_timeout must be non-zero".into(),
43 ));
44 }
45 if self.config.stream_connect_timeout.is_zero() {
46 return Err(ClientError::Transport(
47 "stream_connect_timeout must be non-zero".into(),
48 ));
49 }
50 if self.config.connection_timeout.is_zero() {
51 return Err(ClientError::Transport(
52 "connection_timeout must be non-zero".into(),
53 ));
54 }
55
56 let transport: Box<dyn Transport> = if let Some(t) = self.transport_override.take() {
57 t
58 } else {
59 let binding = self
60 .preferred_binding
61 .clone()
62 .unwrap_or_else(|| BINDING_JSONRPC.into());
63 match http_transport(&self.config, &binding, &self.endpoint) {
64 Ok(t) => t,
65 Err(first) => self.fall_back(&binding, first)?,
66 }
67 };
68
69 let transport: Box<dyn Transport> = if let Some(policy) = self.retry_policy {
71 Box::new(
72 RetryTransport::new(transport, policy)
73 .with_peer_honouring_idempotency(self.peer_honours_idempotency),
74 )
75 } else {
76 transport
77 };
78
79 Ok(A2aClient::new(transport, self.interceptors, self.config))
80 }
81
82 fn fall_back(&mut self, binding: &str, first: ClientError) -> ClientResult<Box<dyn Transport>> {
85 if self.fallback_interfaces.is_empty() {
86 return Err(first);
87 }
88 let mut failures = vec![format!("{binding} at {}: {first}", self.endpoint)];
89 let tenant_from_card = self
92 .chosen_interface()
93 .is_some_and(|i| i.tenant == self.config.tenant);
94 for iface in std::mem::take(&mut self.fallback_interfaces) {
95 match http_transport(&self.config, &iface.protocol_binding, &iface.url) {
96 Ok(t) => {
97 trace_warn!(
98 chosen = %binding,
99 used = %iface.protocol_binding,
100 url = %iface.url,
101 "chosen interface could not be built; using the next one on the card"
102 );
103 if tenant_from_card {
104 self.config.tenant = iface.tenant;
105 }
106 self.endpoint = iface.url;
107 self.preferred_binding = Some(iface.protocol_binding);
108 return Ok(t);
109 }
110 Err(e) => {
111 failures.push(format!("{} at {}: {e}", iface.protocol_binding, iface.url));
112 }
113 }
114 }
115 Err(ClientError::Transport(format!(
116 "no interface on the agent card could be built: {}",
117 failures.join("; ")
118 )))
119 }
120
121 #[cfg(feature = "grpc")]
131 pub async fn build_grpc(self) -> ClientResult<A2aClient> {
132 use crate::transport::grpc::{GrpcTransport, GrpcTransportConfig};
133
134 if self.config.request_timeout.is_zero() {
135 return Err(ClientError::Transport(
136 "request_timeout must be non-zero".into(),
137 ));
138 }
139 if self.config.stream_connect_timeout.is_zero() {
144 return Err(ClientError::Transport(
145 "stream_connect_timeout must be non-zero".into(),
146 ));
147 }
148
149 let transport: Box<dyn Transport> = if let Some(t) = self.transport_override {
150 t
151 } else {
152 let grpc_config = GrpcTransportConfig::default()
153 .with_timeout(self.config.request_timeout)
154 .with_connect_timeout(self.config.connection_timeout)
155 .with_max_message_size(self.config.max_response_size)
159 .with_bare_address_scheme(self.grpc_bare_address_scheme);
160 #[cfg(feature = "grpc-tls")]
161 let grpc_config = match self.grpc_tls_config {
162 Some(tls) => grpc_config.with_tls_config(tls),
163 None => grpc_config,
164 };
165 let t = GrpcTransport::connect_with_config(&self.endpoint, grpc_config)
172 .await?
173 .with_stream_connect_timeout(self.config.stream_connect_timeout);
174 Box::new(t)
175 };
176
177 let transport: Box<dyn Transport> = if let Some(policy) = self.retry_policy {
178 Box::new(
179 RetryTransport::new(transport, policy)
180 .with_peer_honouring_idempotency(self.peer_honours_idempotency),
181 )
182 } else {
183 transport
184 };
185
186 Ok(A2aClient::new(transport, self.interceptors, self.config))
187 }
188}
189
190#[cfg(feature = "grpc")]
196const GRPC_NOT_SYNC: &str = "gRPC transport requires async connect; \
197 use ClientBuilder::build_grpc() or \
198 with_custom_transport(GrpcTransport::connect(...))";
199#[cfg(not(feature = "grpc"))]
200const GRPC_NOT_SYNC: &str = "gRPC transport requires the `grpc` feature flag";
201
202fn http_transport(
209 config: &ClientConfig,
210 binding: &str,
211 endpoint: &str,
212) -> ClientResult<Box<dyn Transport>> {
213 match canonical_binding(binding) {
214 Some(BINDING_JSONRPC) => Ok(Box::new(
215 JsonRpcTransport::with_all_timeouts(
216 endpoint,
217 config.request_timeout,
218 config.stream_connect_timeout,
219 config.connection_timeout,
220 )?
221 .with_max_response_size(config.max_response_size),
222 )),
223 Some(BINDING_HTTP_JSON) => Ok(Box::new(
224 RestTransport::with_all_timeouts(
225 endpoint,
226 config.request_timeout,
227 config.stream_connect_timeout,
228 config.connection_timeout,
229 )?
230 .with_max_response_size(config.max_response_size),
231 )),
232 Some(BINDING_GRPC) => Err(ClientError::Transport(GRPC_NOT_SYNC.into())),
233 _ => Err(ClientError::Transport(format!(
234 "unknown protocol binding: {binding}"
235 ))),
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::super::*;
242 use crate::config::{BINDING_GRPC, BINDING_HTTP_JSON, BINDING_REST};
243 use std::time::Duration;
244
245 #[test]
246 fn builder_defaults_to_jsonrpc() {
247 let client = ClientBuilder::new("http://localhost:8080")
248 .build()
249 .expect("build");
250 let _ = client;
251 }
252
253 #[test]
254 fn builder_rest_transport() {
255 let client = ClientBuilder::new("http://localhost:8080")
256 .with_protocol_binding(BINDING_REST)
257 .build()
258 .expect("build");
259 let _ = client;
260 }
261
262 #[test]
263 fn builder_accepts_spec_http_json_binding() {
264 let client = ClientBuilder::new("http://localhost:8080")
267 .with_protocol_binding(BINDING_HTTP_JSON)
268 .build()
269 .expect("HTTP+JSON binding should resolve to the REST transport");
270 let _ = client;
271 }
272
273 #[test]
274 fn builder_grpc_sync_build_returns_error() {
275 let result = ClientBuilder::new("http://localhost:8080")
276 .with_protocol_binding(BINDING_GRPC)
277 .build();
278 assert!(result.is_err());
279 }
280
281 #[test]
282 fn builder_invalid_url_returns_error() {
283 let result = ClientBuilder::new("not-a-url").build();
284 assert!(result.is_err());
285 }
286
287 #[test]
288 fn builder_zero_request_timeout_errors() {
289 let result = ClientBuilder::new("http://localhost:8080")
290 .with_timeout(Duration::ZERO)
291 .build();
292 assert!(result.is_err());
293 }
294
295 #[test]
296 fn builder_zero_stream_timeout_errors() {
297 let result = ClientBuilder::new("http://localhost:8080")
298 .with_stream_connect_timeout(Duration::ZERO)
299 .build();
300 assert!(result.is_err());
301 }
302
303 #[cfg(feature = "grpc")]
318 #[tokio::test]
319 async fn build_grpc_zero_stream_timeout_errors() {
320 let result = ClientBuilder::new("http://127.0.0.1:1")
321 .with_stream_connect_timeout(Duration::ZERO)
322 .build_grpc()
323 .await;
324 let err = result.expect_err("a zero stream_connect_timeout is invalid");
325 assert!(
326 err.to_string().contains("stream_connect_timeout"),
327 "the error must name the knob that was wrong, not the dial that \
328 never should have been attempted: {err}"
329 );
330 }
331
332 #[test]
333 fn builder_zero_connection_timeout_errors() {
334 let result = ClientBuilder::new("http://localhost:8080")
335 .with_connection_timeout(Duration::ZERO)
336 .build();
337 assert!(result.is_err());
338 }
339
340 #[test]
341 fn builder_unknown_binding_errors() {
342 let result = ClientBuilder::new("http://localhost:8080")
343 .with_protocol_binding("UNKNOWN_PROTOCOL")
344 .build();
345 assert!(result.is_err());
346 }
347
348 #[test]
349 fn builder_rest_with_retry_policy() {
350 use crate::retry::RetryPolicy;
351
352 let client = ClientBuilder::new("http://localhost:8080")
354 .with_protocol_binding(BINDING_REST)
355 .with_retry_policy(RetryPolicy::default())
356 .build()
357 .expect("build");
358 let _ = client;
359 }
360
361 #[test]
362 fn builder_jsonrpc_with_retry_policy() {
363 use crate::retry::RetryPolicy;
364
365 let client = ClientBuilder::new("http://localhost:8080")
367 .with_retry_policy(RetryPolicy::default())
368 .build()
369 .expect("build");
370 let _ = client;
371 }
372
373 #[test]
374 fn builder_from_card_rejects_incompatible_binding() {
375 use a2a_protocol_types::{AgentCapabilities, AgentCard, AgentInterface};
376
377 let card = AgentCard {
378 url: None,
379 name: "test".into(),
380 version: "1.0".into(),
381 description: "Test agent".into(),
382 supported_interfaces: vec![AgentInterface {
383 url: "http://localhost:9090".into(),
384 protocol_binding: "UNKNOWN".into(),
385 protocol_version: "1.0.0".into(),
386 tenant: None,
387 }],
388 provider: None,
389 icon_url: None,
390 documentation_url: None,
391 capabilities: AgentCapabilities::none(),
392 security_schemes: None,
393 security_requirements: None,
394 default_input_modes: vec![],
395 default_output_modes: vec![],
396 skills: vec![],
397 signatures: None,
398 };
399
400 let result = ClientBuilder::from_card(&card).unwrap().build();
401 assert!(result.is_err(), "unknown binding should fail");
402 }
403}