1use std::collections::HashMap;
78use std::convert::Infallible;
79use std::sync::Arc;
80
81use axum::body::Body;
82use axum::extract::{Path, Query, State};
83use axum::response::IntoResponse;
84use axum::routing::{get, post};
85use axum::Router;
86use bytes::Bytes;
87
88use crate::handler::{RequestHandler, SendMessageResult};
89use crate::streaming::build_sse_response;
90
91pub struct A2aRouter {
117 handler: Arc<RequestHandler>,
118 config: super::DispatchConfig,
119}
120
121impl A2aRouter {
122 #[must_use]
124 pub fn new(handler: Arc<RequestHandler>) -> Self {
125 Self {
126 handler,
127 config: super::DispatchConfig::default(),
128 }
129 }
130
131 #[must_use]
133 pub const fn with_config(handler: Arc<RequestHandler>, config: super::DispatchConfig) -> Self {
134 Self { handler, config }
135 }
136
137 pub fn into_router(self) -> Router {
142 let max_body = self.config.max_request_body_size;
147 let state = A2aState {
148 handler: self.handler,
149 config: Arc::new(self.config),
150 };
151
152 Router::new()
153 .route("/message:send", post(handle_send_message))
155 .route("/message:stream", post(handle_stream_message))
156 .route("/tasks", get(handle_list_tasks))
158 .route("/tasks/{*rest}", axum::routing::any(handle_tasks_catchall))
163 .route("/extendedAgentCard", get(handle_extended_card))
165 .route("/.well-known/agent-card.json", get(handle_agent_card))
167 .route("/health", get(handle_health))
169 .route("/ready", get(handle_ready))
170 .with_state(state)
171 .layer(axum::extract::DefaultBodyLimit::max(max_body))
172 }
173}
174
175#[derive(Clone)]
178struct A2aState {
179 handler: Arc<RequestHandler>,
180 config: Arc<super::DispatchConfig>,
181}
182
183fn extract_headers(headers: &axum::http::HeaderMap) -> HashMap<String, String> {
186 headers
187 .iter()
188 .filter_map(|(k, v)| {
189 v.to_str()
190 .ok()
191 .map(|val| (k.as_str().to_lowercase(), val.to_owned()))
192 })
193 .collect()
194}
195
196fn a2a_error_to_response(err: &dyn std::fmt::Display, status: u16) -> axum::response::Response {
199 let body = serde_json::json!({ "error": err.to_string() });
200 (
201 axum::http::StatusCode::from_u16(status)
202 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
203 axum::Json(body),
204 )
205 .into_response()
206}
207
208const fn server_error_status(err: &crate::error::ServerError) -> u16 {
209 use crate::error::ServerError;
210
211 match err {
212 ServerError::TaskNotFound(_) | ServerError::MethodNotFound(_) => 404,
213 ServerError::InvalidParams(_) | ServerError::Serialization(_) => 400,
214 ServerError::InvalidStateTransition { .. } | ServerError::TaskNotCancelable(_) => 409,
215 ServerError::PushNotSupported => 501,
216 ServerError::PayloadTooLarge(_) => 413,
217 ServerError::Overloaded(_) => 503,
220 _ => 500,
221 }
222}
223
224fn handler_error_to_response(err: &crate::error::ServerError) -> axum::response::Response {
225 a2a_error_to_response(err, server_error_status(err))
226}
227
228fn hyper_sse_to_axum(
233 resp: hyper::Response<http_body_util::combinators::BoxBody<Bytes, Infallible>>,
234) -> axum::response::Response {
235 let (parts, body) = resp.into_parts();
236 let axum_body = Body::new(body);
237 axum::response::Response::from_parts(parts, axum_body)
238}
239
240async fn handle_tasks_catchall(
253 State(state): State<A2aState>,
254 method: axum::http::Method,
255 Path(rest): Path<String>,
256 headers: axum::http::HeaderMap,
257 body: Bytes,
258) -> axum::response::Response {
259 let hdrs = extract_headers(&headers);
260 let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
261
262 match (method.as_str(), segments.as_slice()) {
263 ("GET", [id]) if !id.contains(':') => handle_get_task_inner(&state, id, &hdrs).await,
265
266 ("POST", [id_action]) if id_action.ends_with(":cancel") => {
268 let id = &id_action[..id_action.len() - ":cancel".len()];
269 handle_cancel_task_inner(&state, id, &hdrs).await
270 }
271
272 ("GET" | "POST", [id_action]) if id_action.ends_with(":subscribe") => {
274 let id = &id_action[..id_action.len() - ":subscribe".len()];
275 handle_subscribe_inner(&state, id, &hdrs).await
276 }
277
278 ("POST", [task_id, "pushNotificationConfigs"]) => {
280 handle_create_push_config_inner(&state, task_id, &hdrs, body).await
281 }
282
283 ("GET", [task_id, "pushNotificationConfigs"]) => {
285 handle_list_push_configs_inner(&state, task_id, &hdrs).await
286 }
287
288 ("GET", [task_id, "pushNotificationConfigs", config_id]) => {
290 handle_get_push_config_inner(&state, task_id, config_id, &hdrs).await
291 }
292
293 ("DELETE", [task_id, "pushNotificationConfigs", config_id]) => {
295 handle_delete_push_config_inner(&state, task_id, config_id, &hdrs).await
296 }
297
298 _ => a2a_error_to_response(&"not found", 404),
299 }
300}
301
302async fn handle_send_message(
305 State(state): State<A2aState>,
306 headers: axum::http::HeaderMap,
307 body: Bytes,
308) -> axum::response::Response {
309 handle_send_inner(&state, false, &headers, body).await
310}
311
312async fn handle_stream_message(
313 State(state): State<A2aState>,
314 headers: axum::http::HeaderMap,
315 body: Bytes,
316) -> axum::response::Response {
317 handle_send_inner(&state, true, &headers, body).await
318}
319
320async fn handle_list_tasks(
321 State(state): State<A2aState>,
322 Query(query): Query<HashMap<String, String>>,
323 headers: axum::http::HeaderMap,
324) -> axum::response::Response {
325 let hdrs = extract_headers(&headers);
326 let params = a2a_protocol_types::params::ListTasksParams {
327 tenant: None,
328 context_id: query.get("contextId").cloned(),
329 status: query
330 .get("status")
331 .and_then(|s| serde_json::from_value(serde_json::Value::String(s.clone())).ok()),
332 page_size: query.get("pageSize").and_then(|v| v.parse().ok()),
333 page_token: query.get("pageToken").cloned(),
334 status_timestamp_after: query.get("statusTimestampAfter").cloned(),
335 include_artifacts: query.get("includeArtifacts").and_then(|v| v.parse().ok()),
336 history_length: query.get("historyLength").and_then(|v| v.parse().ok()),
337 };
338 match state.handler.on_list_tasks(params, Some(&hdrs)).await {
339 Ok(result) => axum::Json(result).into_response(),
340 Err(e) => handler_error_to_response(&e),
341 }
342}
343
344async fn handle_extended_card(
345 State(state): State<A2aState>,
346 headers: axum::http::HeaderMap,
347) -> axum::response::Response {
348 let hdrs = extract_headers(&headers);
349 match state.handler.on_get_extended_agent_card(Some(&hdrs)).await {
350 Ok(card) => axum::Json(card).into_response(),
351 Err(e) => handler_error_to_response(&e),
352 }
353}
354
355async fn handle_agent_card(State(state): State<A2aState>) -> axum::response::Response {
356 state.handler.agent_card.as_ref().map_or_else(
357 || a2a_error_to_response(&"agent card not configured", 404),
358 |card| axum::Json(card).into_response(),
359 )
360}
361
362async fn handle_health() -> axum::response::Response {
369 axum::Json(serde_json::json!({"status": "ok"})).into_response()
370}
371
372async fn handle_ready(State(state): State<A2aState>) -> axum::response::Response {
390 match state.handler.task_store_health().await {
391 Ok(()) => axum::Json(serde_json::json!({"status": "ready"})).into_response(),
392 Err(e) => (
393 axum::http::StatusCode::SERVICE_UNAVAILABLE,
394 axum::Json(serde_json::json!({
395 "status": "not_ready",
396 "reason": e.metric_label(),
397 })),
398 )
399 .into_response(),
400 }
401}
402
403async fn handle_send_inner(
406 state: &A2aState,
407 streaming: bool,
408 headers: &axum::http::HeaderMap,
409 body: Bytes,
410) -> axum::response::Response {
411 let hdrs = extract_headers(headers);
412 let params: a2a_protocol_types::params::MessageSendParams = match serde_json::from_slice(&body)
413 {
414 Ok(p) => p,
415 Err(e) => return a2a_error_to_response(&e, 400),
416 };
417 match state
418 .handler
419 .on_send_message(params, streaming, Some(&hdrs))
420 .await
421 {
422 Ok(SendMessageResult::Response(resp)) => axum::Json(resp).into_response(),
423 Ok(SendMessageResult::Stream(reader)) => hyper_sse_to_axum(build_sse_response(
424 reader,
425 Some(state.config.sse_keep_alive_interval),
426 Some(state.config.sse_channel_capacity),
427 None, )),
429 Err(e) => handler_error_to_response(&e),
430 }
431}
432
433async fn handle_get_task_inner(
434 state: &A2aState,
435 id: &str,
436 hdrs: &HashMap<String, String>,
437) -> axum::response::Response {
438 let params = a2a_protocol_types::params::TaskQueryParams {
439 tenant: None,
440 id: id.to_owned(),
441 history_length: None,
442 };
443 match state.handler.on_get_task(params, Some(hdrs)).await {
444 Ok(task) => axum::Json(task).into_response(),
445 Err(e) => handler_error_to_response(&e),
446 }
447}
448
449async fn handle_cancel_task_inner(
450 state: &A2aState,
451 id: &str,
452 hdrs: &HashMap<String, String>,
453) -> axum::response::Response {
454 let params = a2a_protocol_types::params::CancelTaskParams {
455 tenant: None,
456 id: id.to_owned(),
457 metadata: None,
458 };
459 match state.handler.on_cancel_task(params, Some(hdrs)).await {
460 Ok(task) => axum::Json(task).into_response(),
461 Err(e) => handler_error_to_response(&e),
462 }
463}
464
465async fn handle_subscribe_inner(
466 state: &A2aState,
467 id: &str,
468 hdrs: &HashMap<String, String>,
469) -> axum::response::Response {
470 let params = a2a_protocol_types::params::TaskIdParams {
471 tenant: None,
472 id: id.to_owned(),
473 };
474 match state.handler.on_resubscribe(params, Some(hdrs)).await {
475 Ok(reader) => hyper_sse_to_axum(build_sse_response(
476 reader,
477 Some(state.config.sse_keep_alive_interval),
478 Some(state.config.sse_channel_capacity),
479 None, )),
481 Err(e) => handler_error_to_response(&e),
482 }
483}
484
485async fn handle_create_push_config_inner(
486 state: &A2aState,
487 task_id: &str,
488 hdrs: &HashMap<String, String>,
489 body: Bytes,
490) -> axum::response::Response {
491 let mut value: serde_json::Value = match serde_json::from_slice(&body) {
492 Ok(v) => v,
493 Err(e) => return a2a_error_to_response(&e, 400),
494 };
495 if let Some(obj) = value.as_object_mut() {
496 obj.entry("taskId")
497 .or_insert_with(|| serde_json::Value::String(task_id.to_owned()));
498 }
499 let config: a2a_protocol_types::push::TaskPushNotificationConfig =
500 match serde_json::from_value(value) {
501 Ok(c) => c,
502 Err(e) => return a2a_error_to_response(&e, 400),
503 };
504 match state.handler.on_set_push_config(config, Some(hdrs)).await {
505 Ok(result) => axum::Json(result).into_response(),
506 Err(e) => handler_error_to_response(&e),
507 }
508}
509
510async fn handle_get_push_config_inner(
511 state: &A2aState,
512 task_id: &str,
513 config_id: &str,
514 hdrs: &HashMap<String, String>,
515) -> axum::response::Response {
516 let params = a2a_protocol_types::params::GetPushConfigParams {
517 tenant: None,
518 task_id: task_id.to_owned(),
519 id: config_id.to_owned(),
520 };
521 match state.handler.on_get_push_config(params, Some(hdrs)).await {
522 Ok(config) => axum::Json(config).into_response(),
523 Err(e) => handler_error_to_response(&e),
524 }
525}
526
527async fn handle_list_push_configs_inner(
528 state: &A2aState,
529 task_id: &str,
530 hdrs: &HashMap<String, String>,
531) -> axum::response::Response {
532 match state
533 .handler
534 .on_list_push_configs(task_id, None, Some(hdrs))
535 .await
536 {
537 Ok(configs) => {
538 let resp = a2a_protocol_types::responses::ListPushConfigsResponse {
539 configs,
540 next_page_token: None,
541 };
542 axum::Json(resp).into_response()
543 }
544 Err(e) => handler_error_to_response(&e),
545 }
546}
547
548async fn handle_delete_push_config_inner(
549 state: &A2aState,
550 task_id: &str,
551 config_id: &str,
552 hdrs: &HashMap<String, String>,
553) -> axum::response::Response {
554 let params = a2a_protocol_types::params::DeletePushConfigParams {
555 tenant: None,
556 task_id: task_id.to_owned(),
557 id: config_id.to_owned(),
558 };
559 match state
560 .handler
561 .on_delete_push_config(params, Some(hdrs))
562 .await
563 {
564 Ok(()) => axum::Json(serde_json::json!({})).into_response(),
565 Err(e) => handler_error_to_response(&e),
566 }
567}
568
569#[cfg(test)]
572mod tests {
573 use super::*;
574
575 fn catchall_state() -> A2aState {
591 let handler = Arc::new(
592 crate::builder::RequestHandlerBuilder::new({
593 struct Noop;
594 crate::agent_executor!(Noop, |_ctx, _q| async { Ok(()) });
595 Noop
596 })
597 .build()
598 .unwrap(),
599 );
600 A2aState {
601 handler,
602 config: Arc::new(super::super::DispatchConfig::default()),
603 }
604 }
605
606 async fn seed_task(state: &A2aState, id: &str) {
607 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
608 let task = Task {
609 id: TaskId::new(id),
610 context_id: ContextId::new("ctx"),
611 status: TaskStatus::new(TaskState::Submitted),
612 history: None,
613 artifacts: None,
614 metadata: None,
615 };
616 state.handler.task_store.save(&task).await.unwrap();
617 }
618
619 async fn dispatch_tail(state: &A2aState, method: &str, rest: &str) -> axum::http::StatusCode {
620 let response = handle_tasks_catchall(
621 State(state.clone()),
622 axum::http::Method::from_bytes(method.as_bytes()).unwrap(),
623 Path(rest.to_owned()),
624 axum::http::HeaderMap::new(),
625 Bytes::new(),
626 )
627 .await;
628 response.status()
629 }
630
631 #[tokio::test]
635 async fn catchall_routes_cancel_and_strips_the_suffix() {
636 let state = catchall_state();
637 seed_task(&state, "task-abc").await;
638
639 assert_eq!(
641 dispatch_tail(&state, "POST", "task-abc:cancel").await,
642 axum::http::StatusCode::OK,
643 "POST /tasks/task-abc:cancel must cancel task-abc"
644 );
645 assert_eq!(
648 dispatch_tail(&state, "POST", "missing-xyz:cancel").await,
649 axum::http::StatusCode::NOT_FOUND,
650 "an unknown task id must 404 rather than resolve to a truncated one"
651 );
652 }
653
654 #[tokio::test]
658 async fn catchall_routes_subscribe_and_strips_the_suffix() {
659 let state = catchall_state();
660 seed_task(&state, "task-abc").await;
661
662 assert_eq!(
678 dispatch_tail(&state, "GET", "task-abc:subscribe").await,
679 axum::http::StatusCode::OK,
680 "GET /tasks/task-abc:subscribe must subscribe to task-abc"
681 );
682 assert_eq!(
683 dispatch_tail(&state, "GET", "missing-xyz:subscribe").await,
684 axum::http::StatusCode::NOT_FOUND,
685 "subscribe on an unknown id must still 404"
686 );
687 }
688
689 #[tokio::test]
698 async fn catchall_post_without_a_colon_action_falls_through() {
699 let state = catchall_state();
700 seed_task(&state, "tid").await;
701
702 assert_eq!(
704 dispatch_tail(&state, "POST", "tidZZZZZZZ").await,
705 axum::http::StatusCode::NOT_FOUND,
706 "a POST with no colon action must not be routed to CancelTask"
707 );
708 assert_eq!(
710 dispatch_tail(&state, "POST", "tidZZZZZZZZZZ").await,
711 axum::http::StatusCode::NOT_FOUND,
712 "a POST with no colon action must not be routed to SubscribeToTask"
713 );
714 }
715
716 #[tokio::test]
720 async fn catchall_plain_get_does_not_swallow_colon_actions() {
721 let state = catchall_state();
722 seed_task(&state, "task-abc").await;
723
724 assert_eq!(
725 dispatch_tail(&state, "GET", "task-abc").await,
726 axum::http::StatusCode::OK,
727 "GET /tasks/task-abc must fetch the task"
728 );
729 assert_eq!(
733 dispatch_tail(&state, "POST", "task-abc:cancel").await,
734 axum::http::StatusCode::OK,
735 "a colon action must not be captured by the plain `GetTask` arm"
736 );
737 }
738
739 #[test]
740 fn extract_headers_lowercases_names() {
741 let mut map = axum::http::HeaderMap::new();
742 map.insert("X-Request-ID", "abc".parse().unwrap());
743 map.insert("content-type", "application/json".parse().unwrap());
744
745 let result = extract_headers(&map);
746 assert_eq!(result.get("x-request-id").unwrap(), "abc");
747 assert_eq!(result.get("content-type").unwrap(), "application/json");
748 }
749
750 #[test]
751 fn extract_headers_skips_non_utf8_values() {
752 let mut map = axum::http::HeaderMap::new();
753 map.insert("good", "valid".parse().unwrap());
754 let result = extract_headers(&map);
756 assert_eq!(result.len(), 1);
757 assert_eq!(result.get("good").unwrap(), "valid");
758 }
759
760 #[test]
761 fn extract_headers_empty_map() {
762 let map = axum::http::HeaderMap::new();
763 let result = extract_headers(&map);
764 assert!(result.is_empty());
765 }
766
767 #[test]
768 fn a2a_state_is_clone() {
769 fn assert_clone<T: Clone>() {}
770 assert_clone::<A2aState>();
771 }
772
773 #[test]
774 fn server_error_status_task_not_found() {
775 use crate::error::ServerError;
776 assert_eq!(
777 server_error_status(&ServerError::TaskNotFound("t".into())),
778 404
779 );
780 }
781
782 #[test]
783 fn server_error_status_method_not_found() {
784 use crate::error::ServerError;
785 assert_eq!(
786 server_error_status(&ServerError::MethodNotFound("m".into())),
787 404
788 );
789 }
790
791 #[test]
792 fn server_error_status_invalid_params() {
793 use crate::error::ServerError;
794 assert_eq!(
795 server_error_status(&ServerError::InvalidParams("p".into())),
796 400
797 );
798 }
799
800 #[test]
801 fn server_error_status_serialization() {
802 use crate::error::ServerError;
803 let err = ServerError::Serialization(serde_json::from_str::<String>("bad").unwrap_err());
804 assert_eq!(server_error_status(&err), 400);
805 }
806
807 #[test]
808 fn server_error_status_task_not_cancelable() {
809 use crate::error::ServerError;
810 assert_eq!(
811 server_error_status(&ServerError::TaskNotCancelable("t".into())),
812 409
813 );
814 }
815
816 #[test]
817 fn server_error_status_invalid_state_transition() {
818 use crate::error::ServerError;
819 let err = ServerError::InvalidStateTransition {
820 task_id: "t".into(),
821 from: a2a_protocol_types::task::TaskState::Working,
822 to: a2a_protocol_types::task::TaskState::Submitted,
823 };
824 assert_eq!(server_error_status(&err), 409);
825 }
826
827 #[test]
828 fn server_error_status_push_not_supported() {
829 use crate::error::ServerError;
830 assert_eq!(server_error_status(&ServerError::PushNotSupported), 501);
831 }
832
833 #[test]
834 fn server_error_status_payload_too_large() {
835 use crate::error::ServerError;
836 assert_eq!(
837 server_error_status(&ServerError::PayloadTooLarge("big".into())),
838 413
839 );
840 }
841
842 #[test]
843 fn server_error_status_overloaded() {
844 use crate::error::ServerError;
845 assert_eq!(
848 server_error_status(&ServerError::Overloaded("at capacity".into())),
849 503
850 );
851 }
852
853 #[test]
854 fn server_error_status_internal() {
855 use crate::error::ServerError;
856 assert_eq!(
857 server_error_status(&ServerError::Internal("oops".into())),
858 500
859 );
860 }
861
862 #[test]
863 fn a2a_error_to_response_returns_correct_status() {
864 let resp = a2a_error_to_response(&"test error", 400);
865 assert_eq!(resp.status().as_u16(), 400);
866 }
867
868 #[test]
869 fn a2a_error_to_response_returns_json_body() {
870 let resp = a2a_error_to_response(&"not found", 404);
871 assert_eq!(resp.status().as_u16(), 404);
872 }
873
874 #[test]
875 fn a2a_error_to_response_invalid_status_falls_back_to_500() {
876 let resp = a2a_error_to_response(&"bad status", 1000);
878 assert_eq!(resp.status().as_u16(), 500);
879 }
880
881 #[test]
882 fn handler_error_to_response_maps_correctly() {
883 use crate::error::ServerError;
884 let resp = handler_error_to_response(&ServerError::TaskNotFound("t1".into()));
885 assert_eq!(resp.status().as_u16(), 404);
886
887 let resp = handler_error_to_response(&ServerError::InvalidParams("bad".into()));
888 assert_eq!(resp.status().as_u16(), 400);
889
890 let resp = handler_error_to_response(&ServerError::Internal("oops".into()));
891 assert_eq!(resp.status().as_u16(), 500);
892 }
893
894 #[test]
895 fn a2a_router_new_creates_with_defaults() {
896 use crate::builder::RequestHandlerBuilder;
898
899 struct NoopExecutor;
900 impl crate::executor::AgentExecutor for NoopExecutor {
901 fn execute<'a>(
902 &'a self,
903 _ctx: &'a crate::request_context::RequestContext,
904 _queue: &'a dyn crate::streaming::EventQueueWriter,
905 ) -> std::pin::Pin<
906 Box<
907 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
908 + Send
909 + 'a,
910 >,
911 > {
912 Box::pin(async { Ok(()) })
913 }
914 }
915
916 let handler = Arc::new(RequestHandlerBuilder::new(NoopExecutor).build().unwrap());
917 let router = A2aRouter::new(handler);
918 let _axum_router = router.into_router();
920 }
921
922 #[test]
923 fn a2a_router_with_config() {
924 use crate::builder::RequestHandlerBuilder;
925
926 struct NoopExecutor;
927 impl crate::executor::AgentExecutor for NoopExecutor {
928 fn execute<'a>(
929 &'a self,
930 _ctx: &'a crate::request_context::RequestContext,
931 _queue: &'a dyn crate::streaming::EventQueueWriter,
932 ) -> std::pin::Pin<
933 Box<
934 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
935 + Send
936 + 'a,
937 >,
938 > {
939 Box::pin(async { Ok(()) })
940 }
941 }
942
943 let handler = Arc::new(RequestHandlerBuilder::new(NoopExecutor).build().unwrap());
944 let config =
945 super::super::DispatchConfig::default().with_max_request_body_size(8 * 1024 * 1024);
946 let router = A2aRouter::with_config(handler, config);
947 let _axum_router = router.into_router();
948 }
949}
950
951#[cfg(test)]
960mod readiness_tests {
961 use std::future::Future;
962 use std::pin::Pin;
963
964 use a2a_protocol_types::error::{A2aError, A2aResult};
965 use a2a_protocol_types::params::ListTasksParams;
966 use a2a_protocol_types::responses::TaskListResponse;
967 use a2a_protocol_types::task::{Task, TaskId};
968 use axum::http::StatusCode;
969
970 use crate::store::TaskStore;
971
972 use super::*;
973
974 struct UnreachableStore;
976
977 impl TaskStore for UnreachableStore {
978 fn save<'a>(
979 &'a self,
980 _task: &'a Task,
981 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
982 Box::pin(async { Err(A2aError::internal("connection refused")) })
983 }
984 fn get<'a>(
985 &'a self,
986 _id: &'a TaskId,
987 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
988 Box::pin(async { Err(A2aError::internal("connection refused")) })
989 }
990 fn list<'a>(
991 &'a self,
992 _p: &'a ListTasksParams,
993 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
994 Box::pin(async { Err(A2aError::internal("connection refused")) })
995 }
996 fn insert_if_absent<'a>(
997 &'a self,
998 _task: &'a Task,
999 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
1000 Box::pin(async { Err(A2aError::internal("connection refused")) })
1001 }
1002 fn delete<'a>(
1003 &'a self,
1004 _id: &'a TaskId,
1005 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
1006 Box::pin(async { Err(A2aError::internal("connection refused")) })
1007 }
1008 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
1009 Box::pin(async { Err(A2aError::internal("connection refused")) })
1010 }
1011 }
1012
1013 fn state_with(store: Option<UnreachableStore>) -> A2aState {
1014 struct Noop;
1015 crate::agent_executor!(Noop, |_ctx, _q| async { Ok(()) });
1016
1017 let builder = crate::builder::RequestHandlerBuilder::new(Noop);
1018 let builder = match store {
1019 Some(s) => builder.with_task_store(s),
1020 None => builder,
1021 };
1022 A2aState {
1023 handler: Arc::new(builder.build().expect("build handler")),
1024 config: Arc::new(super::super::DispatchConfig::default()),
1025 }
1026 }
1027
1028 async fn read_response(resp: axum::response::Response) -> (StatusCode, String) {
1032 let status = resp.status();
1033 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
1034 .await
1035 .expect("body");
1036 (status, String::from_utf8_lossy(&bytes).into_owned())
1037 }
1038
1039 #[tokio::test]
1040 async fn ready_reports_ok_when_the_store_answers() {
1041 let (status, body) = read_response(handle_ready(State(state_with(None))).await).await;
1042 assert_eq!(status, StatusCode::OK);
1043 assert!(body.contains("\"ready\""), "unexpected body: {body}");
1044 }
1045
1046 #[tokio::test]
1047 async fn ready_reports_503_when_the_store_is_unreachable() {
1048 let (status, body) =
1049 read_response(handle_ready(State(state_with(Some(UnreachableStore)))).await).await;
1050
1051 assert_eq!(
1052 status,
1053 StatusCode::SERVICE_UNAVAILABLE,
1054 "an unreachable store must drain traffic from this replica"
1055 );
1056 assert!(body.contains("not_ready"), "unexpected body: {body}");
1057 assert!(body.contains("internal_error"), "unexpected body: {body}");
1060 assert!(
1061 !body.contains("connection refused"),
1062 "the store's message must not be echoed to an unauthenticated probe: {body}"
1063 );
1064 }
1065
1066 #[tokio::test]
1069 async fn health_stays_ok_when_the_store_is_unreachable() {
1070 let (status, body) = read_response(handle_health().await).await;
1071
1072 assert_eq!(
1073 status,
1074 StatusCode::OK,
1075 "liveness must not depend on a downstream"
1076 );
1077 assert!(body.contains("\"ok\""), "unexpected body: {body}");
1078 }
1079}