1use std::collections::HashMap;
39use std::future::Future;
40use std::pin::Pin;
41use std::sync::Arc;
42use std::time::Duration;
43
44use a2a_protocol_types::proto as apb;
45use a2a_protocol_types::proto::convert::ConvertError;
46use tokio::sync::mpsc;
47use tonic::transport::Channel;
48
49use crate::error::{ClientError, ClientResult};
50use crate::streaming::EventStream;
51use crate::transport::Transport;
52
53mod proto {
56 #![allow(
57 clippy::all,
58 clippy::pedantic,
59 clippy::nursery,
60 missing_docs,
61 unused_qualifications
62 )]
63 tonic::include_proto!("lf.a2a.v1");
64}
65
66use proto::a2a_service_client::A2aServiceClient;
67
68#[derive(Debug, Clone)]
83pub struct GrpcTransportConfig {
84 pub timeout: Duration,
86 pub connect_timeout: Duration,
88 pub max_message_size: usize,
90 pub stream_channel_capacity: usize,
92}
93
94impl Default for GrpcTransportConfig {
95 fn default() -> Self {
96 Self {
97 timeout: Duration::from_secs(30),
98 connect_timeout: Duration::from_secs(10),
99 max_message_size: 4 * 1024 * 1024,
100 stream_channel_capacity: 64,
101 }
102 }
103}
104
105impl GrpcTransportConfig {
106 #[must_use]
108 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
109 self.timeout = timeout;
110 self
111 }
112
113 #[must_use]
115 pub const fn with_connect_timeout(mut self, timeout: Duration) -> Self {
116 self.connect_timeout = timeout;
117 self
118 }
119
120 #[must_use]
122 pub const fn with_max_message_size(mut self, size: usize) -> Self {
123 self.max_message_size = size;
124 self
125 }
126
127 #[must_use]
129 pub const fn with_stream_channel_capacity(mut self, capacity: usize) -> Self {
130 self.stream_channel_capacity = capacity;
131 self
132 }
133}
134
135#[derive(Clone, Debug)]
143pub struct GrpcTransport {
144 inner: Arc<Inner>,
145}
146
147#[derive(Debug)]
148struct Inner {
149 channel: Channel,
153 endpoint: String,
154 config: GrpcTransportConfig,
155 stream_connect_timeout: Option<Duration>,
167}
168
169impl GrpcTransport {
170 pub async fn connect(endpoint: impl Into<String>) -> ClientResult<Self> {
178 Self::connect_with_config(endpoint, GrpcTransportConfig::default()).await
179 }
180
181 pub async fn connect_with_config(
187 endpoint: impl Into<String>,
188 config: GrpcTransportConfig,
189 ) -> ClientResult<Self> {
190 let endpoint_str = endpoint.into();
191 validate_url(&endpoint_str)?;
192
193 let channel = tonic::transport::Channel::from_shared(endpoint_str.clone())
194 .map_err(|e| ClientError::InvalidEndpoint(format!("invalid gRPC endpoint: {e}")))?
195 .connect_timeout(config.connect_timeout)
196 .timeout(config.timeout)
197 .connect()
198 .await
199 .map_err(|e| ClientError::Transport(format!("gRPC connect failed: {e}")))?;
200
201 Ok(Self {
202 inner: Arc::new(Inner {
203 channel,
204 endpoint: endpoint_str,
205 config,
206 stream_connect_timeout: None,
207 }),
208 })
209 }
210
211 #[must_use]
226 pub fn with_stream_connect_timeout(mut self, timeout: Duration) -> Self {
227 let inner = Arc::new(Inner {
231 channel: self.inner.channel.clone(),
232 endpoint: self.inner.endpoint.clone(),
233 config: self.inner.config.clone(),
234 stream_connect_timeout: Some(timeout),
235 });
236 self.inner = inner;
237 self
238 }
239
240 fn first_event_bound(&self) -> Duration {
247 self.inner
248 .stream_connect_timeout
249 .unwrap_or(self.inner.config.timeout)
250 }
251
252 #[must_use]
254 pub fn endpoint(&self) -> &str {
255 &self.inner.endpoint
256 }
257
258 fn client(&self) -> A2aServiceClient<Channel> {
261 A2aServiceClient::new(self.inner.channel.clone())
265 .max_decoding_message_size(self.inner.config.max_message_size)
266 .max_encoding_message_size(self.inner.config.max_message_size)
267 }
268
269 fn request<T>(
270 &self,
271 message: T,
272 extra_headers: &HashMap<String, String>,
273 with_deadline: bool,
274 ) -> ClientResult<tonic::Request<T>> {
275 let mut req = tonic::Request::new(message);
276 if with_deadline {
277 req.set_timeout(self.inner.config.timeout);
278 }
279 Self::add_metadata(&mut req, extra_headers)?;
280 Ok(req)
281 }
282
283 fn add_metadata<T>(
284 req: &mut tonic::Request<T>,
285 extra_headers: &HashMap<String, String>,
286 ) -> ClientResult<()> {
287 let md = req.metadata_mut();
288 md.insert(
289 "a2a-version",
290 a2a_protocol_types::A2A_VERSION
291 .parse()
292 .unwrap_or_else(|_| tonic::metadata::MetadataValue::from_static("")),
293 );
294 for (k, v) in extra_headers {
295 let key = k.parse::<tonic::metadata::MetadataKey<_>>().map_err(|e| {
302 ClientError::Transport(format!("invalid gRPC metadata key {k:?}: {e}"))
303 })?;
304 let val = v
305 .parse::<tonic::metadata::MetadataValue<_>>()
306 .map_err(|_| {
307 ClientError::Transport(format!("invalid gRPC metadata value for key {k:?}"))
308 })?;
309 md.insert(key, val);
310 }
311 Ok(())
312 }
313
314 fn parse_params<T: serde::de::DeserializeOwned>(params: serde_json::Value) -> ClientResult<T> {
315 serde_json::from_value(params).map_err(ClientError::Serialization)
316 }
317
318 fn to_json<T: serde::Serialize>(value: &T) -> ClientResult<serde_json::Value> {
319 serde_json::to_value(value).map_err(ClientError::Serialization)
320 }
321
322 fn status_to_error(status: &tonic::Status) -> ClientError {
323 match status.code() {
326 tonic::Code::DeadlineExceeded => {
327 ClientError::Timeout(format!("gRPC deadline exceeded: {}", status.message()))
328 }
329 tonic::Code::Cancelled => {
330 ClientError::Timeout(format!("gRPC request cancelled: {}", status.message()))
331 }
332 tonic::Code::Unavailable => {
333 ClientError::HttpClient(format!("gRPC unavailable: {}", status.message()))
334 }
335 tonic::Code::ResourceExhausted => ClientError::UnexpectedStatus {
340 status: 429,
341 body: status.message().to_owned(),
342 retry_after: None,
343 },
344 _ => {
345 use tonic_types::StatusExt as _;
351 let code = status
352 .get_details_error_info()
353 .and_then(|info| a2a_protocol_types::ErrorCode::from_a2a_reason(&info.reason))
354 .unwrap_or_else(|| grpc_code_to_error_code(status.code()));
355 let a2a = a2a_protocol_types::A2aError::new(code, status.message().to_owned());
356 ClientError::Protocol(a2a)
357 }
358 }
359 }
360
361 async fn execute_unary(
362 &self,
363 method: &str,
364 params: serde_json::Value,
365 extra_headers: &HashMap<String, String>,
366 ) -> ClientResult<serde_json::Value> {
367 trace_info!(
368 method,
369 endpoint = %self.inner.endpoint,
370 "sending gRPC request"
371 );
372
373 let mut client = self.client();
374 tokio::time::timeout(
375 self.inner.config.timeout,
376 self.dispatch_unary(&mut client, method, params, extra_headers),
377 )
378 .await
379 .map_err(|_| {
380 trace_error!(method, "gRPC request timed out");
381 ClientError::Timeout("gRPC request timed out".into())
382 })?
383 }
384
385 #[allow(clippy::too_many_lines)]
391 async fn dispatch_unary(
392 &self,
393 client: &mut A2aServiceClient<Channel>,
394 method: &str,
395 params: serde_json::Value,
396 extra_headers: &HashMap<String, String>,
397 ) -> ClientResult<serde_json::Value> {
398 match method {
399 "SendMessage" => {
400 let p: a2a_protocol_types::params::MessageSendParams = Self::parse_params(params)?;
401 let req = apb::SendMessageRequest::try_from(p).map_err(convert_error)?;
402 let resp = client
403 .send_message(self.request(req, extra_headers, true)?)
404 .await
405 .map_err(|s| Self::status_to_error(&s))?;
406 let domain: a2a_protocol_types::responses::SendMessageResponse =
407 resp.into_inner().try_into().map_err(convert_error)?;
408 Self::to_json(&domain)
409 }
410 "GetTask" => {
411 let p: a2a_protocol_types::params::TaskQueryParams = Self::parse_params(params)?;
412 let req = apb::GetTaskRequest::try_from(p).map_err(convert_error)?;
413 let resp = client
414 .get_task(self.request(req, extra_headers, true)?)
415 .await
416 .map_err(|s| Self::status_to_error(&s))?;
417 let domain: a2a_protocol_types::task::Task =
418 resp.into_inner().try_into().map_err(convert_error)?;
419 Self::to_json(&domain)
420 }
421 "ListTasks" => {
422 let p: a2a_protocol_types::params::ListTasksParams = Self::parse_params(params)?;
423 let req = apb::ListTasksRequest::try_from(p).map_err(convert_error)?;
424 let resp = client
425 .list_tasks(self.request(req, extra_headers, true)?)
426 .await
427 .map_err(|s| Self::status_to_error(&s))?;
428 let domain: a2a_protocol_types::responses::TaskListResponse =
429 resp.into_inner().try_into().map_err(convert_error)?;
430 Self::to_json(&domain)
431 }
432 "CancelTask" => {
433 let p: a2a_protocol_types::params::CancelTaskParams = Self::parse_params(params)?;
434 let req = apb::CancelTaskRequest::try_from(p).map_err(convert_error)?;
435 let resp = client
436 .cancel_task(self.request(req, extra_headers, true)?)
437 .await
438 .map_err(|s| Self::status_to_error(&s))?;
439 let domain: a2a_protocol_types::task::Task =
440 resp.into_inner().try_into().map_err(convert_error)?;
441 Self::to_json(&domain)
442 }
443 "CreateTaskPushNotificationConfig" => {
444 let p: a2a_protocol_types::push::TaskPushNotificationConfig =
445 Self::parse_params(params)?;
446 let req = apb::TaskPushNotificationConfig::from(p);
447 let resp = client
448 .create_task_push_notification_config(self.request(req, extra_headers, true)?)
449 .await
450 .map_err(|s| Self::status_to_error(&s))?;
451 let domain: a2a_protocol_types::push::TaskPushNotificationConfig =
452 resp.into_inner().into();
453 Self::to_json(&domain)
454 }
455 "GetTaskPushNotificationConfig" => {
456 let p: a2a_protocol_types::params::GetPushConfigParams =
457 Self::parse_params(params)?;
458 let req = apb::GetTaskPushNotificationConfigRequest::from(p);
459 let resp = client
460 .get_task_push_notification_config(self.request(req, extra_headers, true)?)
461 .await
462 .map_err(|s| Self::status_to_error(&s))?;
463 let domain: a2a_protocol_types::push::TaskPushNotificationConfig =
464 resp.into_inner().into();
465 Self::to_json(&domain)
466 }
467 "ListTaskPushNotificationConfigs" => {
468 let p: a2a_protocol_types::params::ListPushConfigsParams =
469 Self::parse_params(params)?;
470 let req = apb::ListTaskPushNotificationConfigsRequest::try_from(p)
471 .map_err(convert_error)?;
472 let resp = client
473 .list_task_push_notification_configs(self.request(req, extra_headers, true)?)
474 .await
475 .map_err(|s| Self::status_to_error(&s))?;
476 let domain: a2a_protocol_types::responses::ListPushConfigsResponse =
477 resp.into_inner().into();
478 Self::to_json(&domain)
479 }
480 "DeleteTaskPushNotificationConfig" => {
481 let p: a2a_protocol_types::params::DeletePushConfigParams =
482 Self::parse_params(params)?;
483 let req = apb::DeleteTaskPushNotificationConfigRequest::from(p);
484 client
485 .delete_task_push_notification_config(self.request(req, extra_headers, true)?)
486 .await
487 .map_err(|s| Self::status_to_error(&s))?;
488 Ok(serde_json::json!({}))
489 }
490 "GetExtendedAgentCard" => {
491 let params = if params.is_null() {
493 serde_json::json!({})
494 } else {
495 params
496 };
497 let p: a2a_protocol_types::params::GetExtendedAgentCardParams =
498 Self::parse_params(params)?;
499 let req = apb::GetExtendedAgentCardRequest::from(p);
500 let resp = client
501 .get_extended_agent_card(self.request(req, extra_headers, true)?)
502 .await
503 .map_err(|s| Self::status_to_error(&s))?;
504 let domain: a2a_protocol_types::agent_card::AgentCard =
505 resp.into_inner().try_into().map_err(convert_error)?;
506 Self::to_json(&domain)
507 }
508 other => Err(ClientError::Protocol(a2a_protocol_types::A2aError::new(
509 a2a_protocol_types::ErrorCode::MethodNotFound,
510 format!("unknown gRPC method: {other}"),
511 ))),
512 }
513 }
514
515 async fn execute_streaming(
516 &self,
517 method: &str,
518 params: serde_json::Value,
519 extra_headers: &HashMap<String, String>,
520 ) -> ClientResult<EventStream> {
521 trace_info!(
522 method,
523 endpoint = %self.inner.endpoint,
524 "opening gRPC stream"
525 );
526
527 let mut client = self.client();
528 let stream = tokio::time::timeout(self.inner.config.timeout, async {
529 match method {
530 "SendStreamingMessage" => {
531 let p: a2a_protocol_types::params::MessageSendParams =
532 Self::parse_params(params)?;
533 let req = apb::SendMessageRequest::try_from(p).map_err(convert_error)?;
534 client
535 .send_streaming_message(self.request(req, extra_headers, false)?)
538 .await
539 .map(tonic::Response::into_inner)
540 .map_err(|s| Self::status_to_error(&s))
541 }
542 "SubscribeToTask" => {
543 let p: a2a_protocol_types::params::TaskIdParams = Self::parse_params(params)?;
544 let req = apb::SubscribeToTaskRequest::from(p);
545 client
546 .subscribe_to_task(self.request(req, extra_headers, false)?)
547 .await
548 .map(tonic::Response::into_inner)
549 .map_err(|s| Self::status_to_error(&s))
550 }
551 other => Err(ClientError::Protocol(a2a_protocol_types::A2aError::new(
552 a2a_protocol_types::ErrorCode::MethodNotFound,
553 format!("unknown streaming gRPC method: {other}"),
554 ))),
555 }
556 })
557 .await
558 .map_err(|_| {
559 trace_error!(method, "gRPC stream connect timed out");
560 ClientError::Timeout("gRPC stream connect timed out".into())
561 })??;
562
563 let cap = self.inner.config.stream_channel_capacity;
564 let (tx, rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(cap);
565
566 let task_handle = tokio::spawn(async move {
567 grpc_stream_reader_task(stream, tx).await;
568 });
569
570 Ok(
579 EventStream::with_status(rx, task_handle.abort_handle(), 200)
580 .with_first_event_timeout(self.first_event_bound()),
581 )
582 }
583}
584
585impl Transport for GrpcTransport {
586 fn send_request<'a>(
587 &'a self,
588 method: &'a str,
589 params: serde_json::Value,
590 extra_headers: &'a HashMap<String, String>,
591 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
592 Box::pin(self.execute_unary(method, params, extra_headers))
593 }
594
595 fn send_streaming_request<'a>(
596 &'a self,
597 method: &'a str,
598 params: serde_json::Value,
599 extra_headers: &'a HashMap<String, String>,
600 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
601 Box::pin(self.execute_streaming(method, params, extra_headers))
602 }
603}
604
605async fn grpc_stream_reader_task<S>(
615 mut stream: S,
616 tx: mpsc::Sender<crate::streaming::event_stream::BodyChunk>,
617) where
618 S: tonic::codegen::tokio_stream::Stream<Item = Result<apb::StreamResponse, tonic::Status>>
619 + Unpin,
620{
621 use tonic::codegen::tokio_stream::StreamExt;
622
623 loop {
624 match stream.next().await {
625 Some(Ok(pb_event)) => {
626 let event: a2a_protocol_types::events::StreamResponse =
627 match pb_event.try_into().map_err(convert_error) {
628 Ok(e) => e,
629 Err(err) => {
630 let _ = tx.send(Err(err)).await;
631 break;
632 }
633 };
634 let json_str = match serde_json::to_string(&event) {
635 Ok(s) => s,
636 Err(e) => {
637 let _ = tx.send(Err(ClientError::Serialization(e))).await;
638 break;
639 }
640 };
641 let envelope =
644 format!("data: {{\"jsonrpc\":\"2.0\",\"id\":null,\"result\":{json_str}}}\n\n");
645 if tx
646 .send(Ok(hyper::body::Bytes::from(envelope)))
647 .await
648 .is_err()
649 {
650 break;
651 }
652 }
653 Some(Err(status)) => {
654 let _ = tx.send(Err(GrpcTransport::status_to_error(&status))).await;
659 break;
660 }
661 None => break,
662 }
663 }
664}
665
666#[allow(clippy::needless_pass_by_value)]
670fn convert_error(err: ConvertError) -> ClientError {
671 ClientError::Transport(format!("protobuf conversion failed: {err}"))
672}
673
674fn validate_url(url: &str) -> ClientResult<()> {
675 if url.is_empty() {
676 return Err(ClientError::InvalidEndpoint("URL must not be empty".into()));
677 }
678 if !url.starts_with("http://") && !url.starts_with("https://") {
679 return Err(ClientError::InvalidEndpoint(format!(
680 "URL must start with http:// or https://: {url}"
681 )));
682 }
683 Ok(())
684}
685
686const fn grpc_code_to_error_code(code: tonic::Code) -> a2a_protocol_types::ErrorCode {
687 match code {
691 tonic::Code::NotFound => a2a_protocol_types::ErrorCode::TaskNotFound,
692 tonic::Code::InvalidArgument
693 | tonic::Code::Unauthenticated
694 | tonic::Code::PermissionDenied
695 | tonic::Code::ResourceExhausted => a2a_protocol_types::ErrorCode::InvalidParams,
696 tonic::Code::Unimplemented => a2a_protocol_types::ErrorCode::MethodNotFound,
697 tonic::Code::FailedPrecondition => a2a_protocol_types::ErrorCode::TaskNotCancelable,
698 _ => a2a_protocol_types::ErrorCode::InternalError,
699 }
700}
701
702#[cfg(test)]
705mod tests {
706 use super::*;
707 use a2a_protocol_types::events::TaskStatusUpdateEvent;
708 use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
709
710 #[test]
711 fn validate_url_rejects_empty() {
712 assert!(validate_url("").is_err());
713 }
714
715 #[test]
716 fn validate_url_rejects_non_http() {
717 assert!(validate_url("ftp://example.com").is_err());
718 }
719
720 #[test]
721 fn validate_url_accepts_http() {
722 assert!(validate_url("http://localhost:50051").is_ok());
723 }
724
725 #[test]
726 fn config_default_timeout() {
727 let cfg = GrpcTransportConfig::default();
728 assert_eq!(cfg.timeout, Duration::from_secs(30));
729 }
730
731 #[test]
732 fn config_builder() {
733 let cfg = GrpcTransportConfig::default()
734 .with_timeout(Duration::from_secs(60))
735 .with_max_message_size(8 * 1024 * 1024)
736 .with_stream_channel_capacity(128);
737 assert_eq!(cfg.timeout, Duration::from_secs(60));
738 assert_eq!(cfg.max_message_size, 8 * 1024 * 1024);
739 assert_eq!(cfg.stream_channel_capacity, 128);
740 }
741
742 #[test]
743 fn convert_error_maps_to_non_retryable_transport() {
744 let err = convert_error(ConvertError {
745 field: "part.raw",
746 reason: "invalid base64".into(),
747 });
748 assert!(
749 matches!(err, ClientError::Transport(_)),
750 "conversion failures must be non-retryable: {err:?}"
751 );
752 assert!(!err.is_retryable());
753 }
754
755 #[test]
756 fn grpc_code_not_found_maps_to_task_not_found() {
757 assert_eq!(
758 grpc_code_to_error_code(tonic::Code::NotFound),
759 a2a_protocol_types::ErrorCode::TaskNotFound,
760 );
761 }
762
763 #[test]
764 fn grpc_code_invalid_argument_maps_to_invalid_params() {
765 assert_eq!(
766 grpc_code_to_error_code(tonic::Code::InvalidArgument),
767 a2a_protocol_types::ErrorCode::InvalidParams,
768 );
769 }
770
771 #[test]
772 fn grpc_code_unauthenticated_maps_to_invalid_params() {
773 assert_eq!(
774 grpc_code_to_error_code(tonic::Code::Unauthenticated),
775 a2a_protocol_types::ErrorCode::InvalidParams,
776 );
777 }
778
779 #[test]
780 fn grpc_code_permission_denied_maps_to_invalid_params() {
781 assert_eq!(
782 grpc_code_to_error_code(tonic::Code::PermissionDenied),
783 a2a_protocol_types::ErrorCode::InvalidParams,
784 );
785 }
786
787 #[test]
788 fn grpc_code_resource_exhausted_maps_to_invalid_params() {
789 assert_eq!(
790 grpc_code_to_error_code(tonic::Code::ResourceExhausted),
791 a2a_protocol_types::ErrorCode::InvalidParams,
792 );
793 }
794
795 #[test]
796 fn grpc_code_unimplemented_maps_to_method_not_found() {
797 assert_eq!(
798 grpc_code_to_error_code(tonic::Code::Unimplemented),
799 a2a_protocol_types::ErrorCode::MethodNotFound,
800 );
801 }
802
803 #[test]
804 fn grpc_code_failed_precondition_maps_to_task_not_cancelable() {
805 assert_eq!(
806 grpc_code_to_error_code(tonic::Code::FailedPrecondition),
807 a2a_protocol_types::ErrorCode::TaskNotCancelable,
808 );
809 }
810
811 #[test]
812 fn grpc_code_deadline_exceeded_maps_to_internal() {
813 assert_eq!(
814 grpc_code_to_error_code(tonic::Code::DeadlineExceeded),
815 a2a_protocol_types::ErrorCode::InternalError,
816 );
817 }
818
819 #[test]
820 fn grpc_code_cancelled_maps_to_internal() {
821 assert_eq!(
822 grpc_code_to_error_code(tonic::Code::Cancelled),
823 a2a_protocol_types::ErrorCode::InternalError,
824 );
825 }
826
827 #[test]
828 fn grpc_code_unknown_maps_to_internal() {
829 assert_eq!(
830 grpc_code_to_error_code(tonic::Code::Unknown),
831 a2a_protocol_types::ErrorCode::InternalError,
832 );
833 }
834
835 #[test]
836 fn add_metadata_injects_a2a_version() {
837 let mut req = tonic::Request::new(());
838 let headers = HashMap::new();
839 GrpcTransport::add_metadata(&mut req, &headers).expect("valid headers");
840 let md = req.metadata();
841 let version_value = md
842 .get("a2a-version")
843 .expect("a2a-version header should be present");
844 assert_eq!(
845 version_value.to_str().unwrap(),
846 a2a_protocol_types::A2A_VERSION,
847 );
848 }
849
850 #[test]
851 fn add_metadata_injects_extra_headers() {
852 let mut req = tonic::Request::new(());
853 let mut headers = HashMap::new();
854 headers.insert("x-custom".to_string(), "value123".to_string());
855 GrpcTransport::add_metadata(&mut req, &headers).expect("valid headers");
856 let md = req.metadata();
857 assert_eq!(md.get("x-custom").unwrap().to_str().unwrap(), "value123",);
858 }
859
860 #[test]
861 fn add_metadata_fails_closed_on_invalid_header() {
862 let mut req = tonic::Request::new(());
866 let mut headers = HashMap::new();
867 headers.insert("authorization".to_string(), "Bearer bad\nvalue".to_string());
868 let result = GrpcTransport::add_metadata(&mut req, &headers);
869 assert!(
870 matches!(result, Err(ClientError::Transport(_))),
871 "invalid metadata must fail closed, got: {result:?}"
872 );
873 if let Err(ClientError::Transport(msg)) = result {
875 assert!(!msg.contains("Bearer bad"), "value leaked in error: {msg}");
876 }
877 }
878
879 #[test]
880 fn resource_exhausted_maps_to_retryable_429() {
881 let status = tonic::Status::resource_exhausted("slow down");
882 let err = GrpcTransport::status_to_error(&status);
883 assert!(
884 matches!(err, ClientError::UnexpectedStatus { status: 429, .. }),
885 "ResourceExhausted should map to 429, got {err:?}"
886 );
887 assert!(
888 err.is_retryable(),
889 "gRPC ResourceExhausted must be retryable"
890 );
891 }
892
893 #[test]
896 fn status_to_error_deadline_exceeded_is_timeout() {
897 let status = tonic::Status::deadline_exceeded("test deadline");
898 let err = GrpcTransport::status_to_error(&status);
899 assert!(
900 matches!(err, ClientError::Timeout(_)),
901 "DeadlineExceeded should map to Timeout, got: {err:?}"
902 );
903 }
904
905 #[test]
906 fn status_to_error_cancelled_is_timeout() {
907 let status = tonic::Status::cancelled("test cancel");
908 let err = GrpcTransport::status_to_error(&status);
909 assert!(
910 matches!(err, ClientError::Timeout(_)),
911 "Cancelled should map to Timeout, got: {err:?}"
912 );
913 }
914
915 #[test]
916 fn status_to_error_unavailable_is_http_client() {
917 let status = tonic::Status::unavailable("test unavailable");
918 let err = GrpcTransport::status_to_error(&status);
919 assert!(
920 matches!(err, ClientError::HttpClient(_)),
921 "Unavailable should map to HttpClient, got: {err:?}"
922 );
923 }
924
925 #[test]
926 fn status_to_error_other_is_protocol() {
927 let status = tonic::Status::internal("test internal");
928 let err = GrpcTransport::status_to_error(&status);
929 assert!(
930 matches!(err, ClientError::Protocol(_)),
931 "other codes should map to Protocol, got: {err:?}"
932 );
933 }
934
935 #[test]
938 fn status_to_error_prefers_error_info_reason() {
939 use tonic_types::StatusExt as _;
940 let mut details = tonic_types::ErrorDetails::new();
941 details.set_error_info(
942 "TASK_NOT_CANCELABLE",
943 "a2a-protocol.org",
944 std::collections::HashMap::<String, String>::new(),
945 );
946 let status = tonic::Status::with_error_details(
948 tonic::Code::FailedPrecondition,
949 "task done",
950 details,
951 );
952 let err = GrpcTransport::status_to_error(&status);
953 match err {
954 ClientError::Protocol(a2a) => assert_eq!(
955 a2a.code,
956 a2a_protocol_types::ErrorCode::TaskNotCancelable,
957 "ErrorInfo reason must resolve the exact A2A code"
958 ),
959 other => panic!("expected Protocol error, got: {other:?}"),
960 }
961 }
962
963 #[test]
965 fn status_to_error_unknown_reason_falls_back_to_code() {
966 use tonic_types::StatusExt as _;
967 let mut details = tonic_types::ErrorDetails::new();
968 details.set_error_info(
969 "SOMETHING_NOVEL",
970 "a2a-protocol.org",
971 std::collections::HashMap::<String, String>::new(),
972 );
973 let status = tonic::Status::with_error_details(tonic::Code::NotFound, "missing", details);
974 let err = GrpcTransport::status_to_error(&status);
975 match err {
976 ClientError::Protocol(a2a) => assert_eq!(
977 a2a.code,
978 a2a_protocol_types::ErrorCode::TaskNotFound,
979 "unknown reason must fall back to code-based mapping"
980 ),
981 other => panic!("expected Protocol error, got: {other:?}"),
982 }
983 }
984
985 fn status_update_event() -> apb::StreamResponse {
993 let event = TaskStatusUpdateEvent {
994 task_id: TaskId("t-1".into()),
995 context_id: ContextId("c-1".into()),
996 status: TaskStatus {
997 state: TaskState::Working,
998 message: None,
999 timestamp: None,
1000 },
1001 metadata: None,
1002 };
1003 apb::StreamResponse {
1004 payload: Some(apb::stream_response::Payload::StatusUpdate(
1005 event.try_into().unwrap(),
1006 )),
1007 }
1008 }
1009
1010 #[tokio::test]
1011 async fn grpc_stream_reader_task_forwards_typed_event_as_sse() {
1012 let payloads = vec![Ok(status_update_event())];
1013 let stream = tonic::codegen::tokio_stream::iter(payloads);
1014 let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
1015
1016 grpc_stream_reader_task(stream, tx).await;
1017
1018 let first = rx.recv().await.expect("expected one chunk");
1019 let bytes = first.expect("expected Ok chunk");
1020 let text = std::str::from_utf8(&bytes).expect("utf8");
1021 assert!(
1022 text.starts_with("data: "),
1023 "chunk must be SSE-framed: {text}"
1024 );
1025 assert!(
1026 text.contains("\"jsonrpc\":\"2.0\""),
1027 "chunk must be JSON-RPC envelope: {text}"
1028 );
1029 assert!(
1030 text.contains("\"statusUpdate\""),
1031 "typed event must serialize as the domain union: {text}"
1032 );
1033 assert!(
1034 text.contains("TASK_STATE_WORKING"),
1035 "state must use canonical wire encoding: {text}"
1036 );
1037 assert!(rx.recv().await.is_none());
1039 }
1040
1041 #[tokio::test]
1042 async fn grpc_stream_reader_task_forwards_multiple_payloads() {
1043 let payloads = vec![
1044 Ok(status_update_event()),
1045 Ok(status_update_event()),
1046 Ok(status_update_event()),
1047 ];
1048 let stream = tonic::codegen::tokio_stream::iter(payloads);
1049 let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
1050
1051 grpc_stream_reader_task(stream, tx).await;
1052
1053 let mut received = 0;
1054 while let Some(item) = rx.recv().await {
1055 assert!(item.is_ok());
1056 received += 1;
1057 }
1058 assert_eq!(received, 3, "all three payloads must be forwarded");
1059 }
1060
1061 #[tokio::test]
1062 async fn grpc_stream_reader_task_maps_status_error_to_protocol_error() {
1063 let payloads: Vec<Result<apb::StreamResponse, tonic::Status>> =
1064 vec![Err(tonic::Status::not_found("missing"))];
1065 let stream = tonic::codegen::tokio_stream::iter(payloads);
1066 let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
1067
1068 grpc_stream_reader_task(stream, tx).await;
1069
1070 let chunk = rx.recv().await.expect("expected an error chunk");
1071 match chunk {
1072 Err(ClientError::Protocol(a2a)) => {
1073 assert_eq!(a2a.code, a2a_protocol_types::ErrorCode::TaskNotFound);
1074 assert!(a2a.message.contains("missing"));
1075 }
1076 other => panic!("expected Protocol(TaskNotFound), got {other:?}"),
1077 }
1078 }
1079
1080 #[tokio::test]
1081 async fn grpc_stream_reader_task_rejects_empty_payload() {
1082 let payloads = vec![Ok(apb::StreamResponse { payload: None })];
1085 let stream = tonic::codegen::tokio_stream::iter(payloads);
1086 let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
1087
1088 grpc_stream_reader_task(stream, tx).await;
1089
1090 let chunk = rx.recv().await.expect("expected an error chunk");
1091 match chunk {
1092 Err(ClientError::Transport(msg)) => {
1093 assert!(
1094 msg.contains("streamResponse.payload"),
1095 "msg should name the field: {msg}"
1096 );
1097 }
1098 other => panic!("expected Transport error, got {other:?}"),
1099 }
1100 }
1101
1102 #[tokio::test]
1110 async fn grpc_transport_endpoint_returns_input_url() {
1111 let endpoint_str = "http://localhost:50055".to_string();
1112 let channel = tonic::transport::Channel::from_shared(endpoint_str.clone())
1113 .expect("valid endpoint")
1114 .connect_lazy();
1115 let transport = GrpcTransport {
1116 inner: Arc::new(Inner {
1117 channel,
1118 endpoint: endpoint_str.clone(),
1119 config: GrpcTransportConfig::default(),
1120 stream_connect_timeout: None,
1121 }),
1122 };
1123 assert_eq!(transport.endpoint(), endpoint_str);
1124 }
1125
1126 #[tokio::test]
1135 async fn the_first_event_bound_follows_stream_connect_timeout_when_set() {
1136 let endpoint = "http://example.com:1234".to_string();
1137 let mk = || {
1138 let channel = tonic::transport::Channel::from_shared(endpoint.clone())
1139 .expect("valid endpoint")
1140 .connect_lazy();
1141 GrpcTransport {
1142 inner: Arc::new(Inner {
1143 channel,
1144 endpoint: endpoint.clone(),
1145 config: GrpcTransportConfig::default().with_timeout(Duration::from_secs(30)),
1146 stream_connect_timeout: None,
1147 }),
1148 }
1149 };
1150
1151 assert_eq!(
1152 mk().first_event_bound(),
1153 Duration::from_secs(30),
1154 "unset must fall back to the unary timeout, so a caller who \
1155 constructs this transport directly sees no change"
1156 );
1157 assert_eq!(
1158 mk().with_stream_connect_timeout(Duration::from_secs(3))
1159 .first_event_bound(),
1160 Duration::from_secs(3),
1161 "and a set stream_connect_timeout must win over the unary timeout"
1162 );
1163 }
1164
1165 #[tokio::test]
1166 async fn grpc_transport_endpoint_preserves_distinct_urls() {
1167 let a = "http://example.com:1234".to_string();
1168 let b = "https://other.test:9000".to_string();
1169 let mk = |s: String| {
1170 let ch = tonic::transport::Channel::from_shared(s.clone())
1171 .unwrap()
1172 .connect_lazy();
1173 GrpcTransport {
1174 inner: Arc::new(Inner {
1175 channel: ch,
1176 endpoint: s,
1177 config: GrpcTransportConfig::default(),
1178 stream_connect_timeout: None,
1179 }),
1180 }
1181 };
1182 let ta = mk(a.clone());
1183 let tb = mk(b.clone());
1184 assert_eq!(ta.endpoint(), a);
1185 assert_eq!(tb.endpoint(), b);
1186 assert_ne!(ta.endpoint(), tb.endpoint());
1187 }
1188}