1use std::collections::HashMap;
9use std::time::Instant;
10
11use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
12use a2a_protocol_types::push::TaskPushNotificationConfig;
13use a2a_protocol_types::task::TaskId;
14
15use crate::error::{ServerError, ServerResult};
16
17use super::helpers::build_call_context;
18use super::RequestHandler;
19
20impl RequestHandler {
21 pub(super) async fn validate_and_store_push_config(
41 &self,
42 config: TaskPushNotificationConfig,
43 ) -> ServerResult<TaskPushNotificationConfig> {
44 self.ensure_push_supported()?;
47 let Some(ref sender) = self.push_sender else {
48 return Err(ServerError::PushNotSupported);
49 };
50
51 let target_task = TaskId::new(config.task_id.clone().unwrap_or_default());
54 if self.task_store.get(&target_task).await?.is_none() {
55 return Err(ServerError::TaskNotFound(target_task));
56 }
57
58 if !sender.allows_private_urls() {
70 crate::push::sender::validate_webhook_url(&config.url)?;
71 }
72
73 let task_key = config.task_id.clone().unwrap_or_default();
78 let existing = self.push_config_store.list(&task_key).await?;
79 let is_update = config
80 .id
81 .as_deref()
82 .is_some_and(|id| existing.iter().any(|c| c.id.as_deref() == Some(id)));
83 if !is_update && existing.len() >= self.limits.max_push_configs_per_task {
84 return Err(ServerError::InvalidParams(format!(
85 "task {task_key} already has the maximum of {} push notification configs",
86 self.limits.max_push_configs_per_task
87 )));
88 }
89
90 if !is_update {
94 if let Some(total) = self.push_config_store.count().await? {
95 if total >= self.limits.max_total_push_configs {
96 return Err(ServerError::Overloaded(format!(
97 "server is at the maximum of {} push notification configs; \
98 delete unused configs before creating more",
99 self.limits.max_total_push_configs
100 )));
101 }
102 }
103 }
104
105 Ok(self.push_config_store.set(config).await?)
106 }
107
108 #[allow(clippy::too_many_lines)]
114 pub async fn on_set_push_config(
115 &self,
116 config: TaskPushNotificationConfig,
117 headers: Option<&HashMap<String, String>>,
118 ) -> ServerResult<TaskPushNotificationConfig> {
119 let start = Instant::now();
120 self.metrics.on_request("CreateTaskPushNotificationConfig");
121
122 let tenant = self
123 .resolve_tenant(
124 "CreateTaskPushNotificationConfig",
125 headers,
126 config.tenant.as_deref(),
127 )
128 .await?;
129 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
130 if config.task_id.as_deref().unwrap_or("").is_empty() {
135 return Err(ServerError::InvalidParams(
136 "taskId is required for CreateTaskPushNotificationConfig".into(),
137 ));
138 }
139
140 let call_ctx = build_call_context("CreateTaskPushNotificationConfig", headers);
141 self.interceptors.run_before(&call_ctx).await?;
142 self.ensure_required_extensions(&call_ctx)?;
145
146 let result = self.validate_and_store_push_config(config).await?;
147 self.interceptors.run_after(&call_ctx).await?;
148 Ok(result)
149 })
150 .await;
151
152 let elapsed = start.elapsed();
153 match &result {
154 Ok(_) => {
155 self.metrics.on_response("CreateTaskPushNotificationConfig");
156 self.metrics
157 .on_latency("CreateTaskPushNotificationConfig", elapsed);
158 }
159 Err(e) => {
160 self.metrics
161 .on_error("CreateTaskPushNotificationConfig", e.metric_label());
162 self.metrics
163 .on_latency("CreateTaskPushNotificationConfig", elapsed);
164 }
165 }
166 result
167 }
168
169 pub async fn on_get_push_config(
177 &self,
178 params: GetPushConfigParams,
179 headers: Option<&HashMap<String, String>>,
180 ) -> ServerResult<TaskPushNotificationConfig> {
181 let start = Instant::now();
182 self.metrics.on_request("GetTaskPushNotificationConfig");
183
184 let tenant = self
185 .resolve_tenant(
186 "GetTaskPushNotificationConfig",
187 headers,
188 params.tenant.as_deref(),
189 )
190 .await?;
191 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
192 self.ensure_push_supported()?;
194 let call_ctx = build_call_context("GetTaskPushNotificationConfig", headers);
195 self.interceptors.run_before(&call_ctx).await?;
196 self.ensure_required_extensions(&call_ctx)?;
199
200 let config = self
203 .push_config_store
204 .get(¶ms.task_id, ¶ms.id)
205 .await?
206 .ok_or_else(|| ServerError::TaskNotFound(TaskId::new(¶ms.task_id)))?;
207
208 self.interceptors.run_after(&call_ctx).await?;
209 Ok(config)
210 })
211 .await;
212
213 let elapsed = start.elapsed();
214 match &result {
215 Ok(_) => {
216 self.metrics.on_response("GetTaskPushNotificationConfig");
217 self.metrics
218 .on_latency("GetTaskPushNotificationConfig", elapsed);
219 }
220 Err(e) => {
221 self.metrics
222 .on_error("GetTaskPushNotificationConfig", e.metric_label());
223 self.metrics
224 .on_latency("GetTaskPushNotificationConfig", elapsed);
225 }
226 }
227 result
228 }
229
230 pub async fn on_list_push_configs(
236 &self,
237 task_id: &str,
238 tenant: Option<&str>,
239 headers: Option<&HashMap<String, String>>,
240 ) -> ServerResult<Vec<TaskPushNotificationConfig>> {
241 let start = Instant::now();
242 self.metrics.on_request("ListTaskPushNotificationConfigs");
243
244 let tenant_owned = self
245 .resolve_tenant("ListTaskPushNotificationConfigs", headers, tenant)
246 .await?;
247 let result: ServerResult<_> =
248 crate::store::tenant::TenantContext::scope(tenant_owned, async {
249 self.ensure_push_supported()?;
251 let call_ctx = build_call_context("ListTaskPushNotificationConfigs", headers);
252 self.interceptors.run_before(&call_ctx).await?;
253 self.ensure_required_extensions(&call_ctx)?;
256 let configs = self.push_config_store.list(task_id).await?;
257 self.interceptors.run_after(&call_ctx).await?;
258 Ok(configs)
259 })
260 .await;
261
262 let elapsed = start.elapsed();
263 match &result {
264 Ok(_) => {
265 self.metrics.on_response("ListTaskPushNotificationConfigs");
266 self.metrics
267 .on_latency("ListTaskPushNotificationConfigs", elapsed);
268 }
269 Err(e) => {
270 self.metrics
271 .on_error("ListTaskPushNotificationConfigs", e.metric_label());
272 self.metrics
273 .on_latency("ListTaskPushNotificationConfigs", elapsed);
274 }
275 }
276 result
277 }
278
279 pub async fn on_delete_push_config(
285 &self,
286 params: DeletePushConfigParams,
287 headers: Option<&HashMap<String, String>>,
288 ) -> ServerResult<()> {
289 let start = Instant::now();
290 self.metrics.on_request("DeleteTaskPushNotificationConfig");
291
292 let tenant = self
293 .resolve_tenant(
294 "DeleteTaskPushNotificationConfig",
295 headers,
296 params.tenant.as_deref(),
297 )
298 .await?;
299 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
300 self.ensure_push_supported()?;
302 let call_ctx = build_call_context("DeleteTaskPushNotificationConfig", headers);
303 self.interceptors.run_before(&call_ctx).await?;
304 self.ensure_required_extensions(&call_ctx)?;
307 self.push_config_store
308 .delete(¶ms.task_id, ¶ms.id)
309 .await?;
310 self.interceptors.run_after(&call_ctx).await?;
311 Ok(())
312 })
313 .await;
314
315 let elapsed = start.elapsed();
316 match &result {
317 Ok(()) => {
318 self.metrics.on_response("DeleteTaskPushNotificationConfig");
319 self.metrics
320 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
321 }
322 Err(e) => {
323 self.metrics
324 .on_error("DeleteTaskPushNotificationConfig", e.metric_label());
325 self.metrics
326 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
327 }
328 }
329 result
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::agent_executor;
337 use crate::builder::RequestHandlerBuilder;
338
339 struct DummyExecutor;
340 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
341
342 fn make_handler() -> RequestHandler {
343 RequestHandlerBuilder::new(DummyExecutor).build().unwrap()
344 }
345
346 fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
347 TaskPushNotificationConfig {
348 tenant: None,
349 id: Some("cfg-1".to_owned()),
350 task_id: Some(task_id.to_owned()),
351 url: "https://example.com/webhook".to_owned(),
352 token: None,
353 authentication: None,
354 }
355 }
356
357 async fn save_task(handler: &RequestHandler, id: &str) {
360 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
361 let task = Task {
362 id: TaskId::new(id),
363 context_id: ContextId::new("ctx"),
364 status: TaskStatus::new(TaskState::Submitted),
365 history: None,
366 artifacts: None,
367 metadata: None,
368 };
369 handler.task_store.save(&task).await.unwrap();
370 }
371
372 #[tokio::test]
375 async fn set_push_config_without_sender_returns_push_not_supported() {
376 let handler = make_handler();
377 let config = make_push_config("task-1");
378 let result = handler.on_set_push_config(config, None).await;
379 assert!(
380 matches!(result, Err(crate::error::ServerError::PushNotSupported)),
381 "expected PushNotSupported, got: {result:?}"
382 );
383 }
384
385 #[tokio::test]
390 async fn set_push_config_without_task_id_returns_invalid_params() {
391 use crate::push::PushSender;
392 use a2a_protocol_types::events::StreamResponse;
393 use std::future::Future;
394 use std::pin::Pin;
395
396 struct NoopSender;
397 impl PushSender for NoopSender {
398 fn send<'a>(
399 &'a self,
400 _url: &'a str,
401 _event: &'a StreamResponse,
402 _config: &'a TaskPushNotificationConfig,
403 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
404 {
405 Box::pin(async { Ok(()) })
406 }
407 fn allows_private_urls(&self) -> bool {
408 true
409 }
410 }
411
412 let handler = RequestHandlerBuilder::new(DummyExecutor)
413 .with_push_sender(NoopSender)
414 .build()
415 .unwrap();
416
417 let config = TaskPushNotificationConfig {
418 tenant: None,
419 id: None,
420 task_id: None,
421 url: "https://example.com/webhook".to_owned(),
422 token: None,
423 authentication: None,
424 };
425 let result = handler.on_set_push_config(config, None).await;
426 match result {
427 Err(crate::error::ServerError::InvalidParams(msg)) => {
428 assert!(msg.contains("taskId"), "got: {msg}");
429 }
430 other => panic!("expected InvalidParams for missing taskId, got: {other:?}"),
431 }
432 }
433
434 #[tokio::test]
448 async fn set_push_config_rejects_private_url_under_the_default_sender_policy() {
449 use crate::push::PushSender;
450 use a2a_protocol_types::events::StreamResponse;
451 use std::future::Future;
452 use std::pin::Pin;
453
454 struct DefaultPolicySender;
457 impl PushSender for DefaultPolicySender {
458 fn send<'a>(
459 &'a self,
460 _url: &'a str,
461 _event: &'a StreamResponse,
462 _config: &'a TaskPushNotificationConfig,
463 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
464 {
465 Box::pin(async { Ok(()) })
466 }
467 }
468
469 let handler = RequestHandlerBuilder::new(DummyExecutor)
470 .with_push_sender(DefaultPolicySender)
471 .build()
472 .unwrap();
473 save_task(&handler, "task-ssrf").await;
474
475 let config = TaskPushNotificationConfig {
476 tenant: None,
477 id: Some("cfg-ssrf".to_owned()),
478 task_id: Some("task-ssrf".to_owned()),
479 url: "http://127.0.0.1:9000/webhook".to_owned(),
480 token: None,
481 authentication: None,
482 };
483
484 match handler.on_set_push_config(config, None).await {
489 Err(e) => {
490 let msg = e.to_string();
491 assert!(
492 msg.contains("private/loopback"),
493 "expected the SSRF rejection, got: {msg}"
494 );
495 }
496 Ok(v) => panic!(
497 "a sender taking the default policy must refuse a loopback webhook URL, got: Ok({v:?})"
498 ),
499 }
500 }
501
502 #[tokio::test]
506 async fn set_push_config_enforces_global_cap() {
507 use crate::push::PushSender;
508 use a2a_protocol_types::events::StreamResponse;
509 use std::future::Future;
510 use std::pin::Pin;
511
512 struct NoopSender;
513 impl PushSender for NoopSender {
514 fn send<'a>(
515 &'a self,
516 _url: &'a str,
517 _event: &'a StreamResponse,
518 _config: &'a TaskPushNotificationConfig,
519 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
520 {
521 Box::pin(async { Ok(()) })
522 }
523 }
524
525 let handler = RequestHandlerBuilder::new(DummyExecutor)
526 .with_push_sender(NoopSender)
527 .with_handler_limits(
528 crate::handler::HandlerLimits::default().with_max_total_push_configs(2),
529 )
530 .build()
531 .unwrap();
532
533 for i in 0..2 {
535 save_task(&handler, &format!("task-{i}")).await;
536 let cfg = TaskPushNotificationConfig {
537 tenant: None,
538 id: Some(format!("cfg-{i}")),
539 task_id: Some(format!("task-{i}")),
540 url: "https://example.com/webhook".to_owned(),
541 token: None,
542 authentication: None,
543 };
544 handler
545 .on_set_push_config(cfg, None)
546 .await
547 .expect("creates under the global cap should succeed");
548 }
549
550 save_task(&handler, "task-x").await;
552 let cfg = TaskPushNotificationConfig {
553 tenant: None,
554 id: Some("cfg-x".to_owned()),
555 task_id: Some("task-x".to_owned()),
556 url: "https://example.com/webhook".to_owned(),
557 token: None,
558 authentication: None,
559 };
560 let result = handler.on_set_push_config(cfg, None).await;
561 assert!(
562 matches!(result, Err(crate::error::ServerError::Overloaded(_))),
563 "global push-config cap must reject, got {result:?}"
564 );
565 }
566
567 #[tokio::test]
571 async fn set_push_config_update_allowed_at_per_task_cap() {
572 use crate::push::PushSender;
573 use a2a_protocol_types::events::StreamResponse;
574 use std::future::Future;
575 use std::pin::Pin;
576
577 struct NoopSender;
578 impl PushSender for NoopSender {
579 fn send<'a>(
580 &'a self,
581 _url: &'a str,
582 _event: &'a StreamResponse,
583 _config: &'a TaskPushNotificationConfig,
584 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
585 {
586 Box::pin(async { Ok(()) })
587 }
588 }
589
590 let handler = RequestHandlerBuilder::new(DummyExecutor)
592 .with_push_sender(NoopSender)
593 .with_handler_limits(
594 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(1),
595 )
596 .build()
597 .unwrap();
598
599 save_task(&handler, "task-1").await;
600 let make = |url: &str| TaskPushNotificationConfig {
601 tenant: None,
602 id: Some("cfg-1".to_owned()),
603 task_id: Some("task-1".to_owned()),
604 url: url.to_owned(),
605 token: None,
606 authentication: None,
607 };
608
609 handler
611 .on_set_push_config(make("https://example.com/a"), None)
612 .await
613 .expect("first create should succeed");
614
615 handler
617 .on_set_push_config(make("https://example.com/b"), None)
618 .await
619 .expect("updating an existing config at the cap must be allowed");
620
621 let mut newcfg = make("https://example.com/c");
623 newcfg.id = Some("cfg-2".to_owned());
624 let rejected = handler.on_set_push_config(newcfg, None).await;
625 assert!(
626 matches!(rejected, Err(crate::error::ServerError::InvalidParams(_))),
627 "a new config beyond the per-task cap must be rejected, got {rejected:?}"
628 );
629 }
630
631 struct NoopSender;
634 impl crate::push::PushSender for NoopSender {
635 fn send<'a>(
636 &'a self,
637 _url: &'a str,
638 _event: &'a a2a_protocol_types::events::StreamResponse,
639 _config: &'a TaskPushNotificationConfig,
640 ) -> std::pin::Pin<
641 Box<
642 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
643 + Send
644 + 'a,
645 >,
646 > {
647 Box::pin(async { Ok(()) })
648 }
649 fn allows_private_urls(&self) -> bool {
650 true
651 }
652 }
653
654 fn card_with(
656 caps: a2a_protocol_types::agent_card::AgentCapabilities,
657 ) -> a2a_protocol_types::agent_card::AgentCard {
658 use a2a_protocol_types::agent_card::{AgentCard, AgentInterface};
659 AgentCard {
660 url: None,
661 name: "Test Agent".into(),
662 description: "A test agent".into(),
663 version: "1.0.0".into(),
664 supported_interfaces: vec![AgentInterface {
665 url: "http://localhost:8080".into(),
666 protocol_binding: "JSONRPC".into(),
667 protocol_version: "1.0.0".into(),
668 tenant: None,
669 }],
670 default_input_modes: vec![],
671 default_output_modes: vec![],
672 skills: vec![],
673 capabilities: caps,
674 provider: None,
675 icon_url: None,
676 documentation_url: None,
677 security_schemes: None,
678 security_requirements: None,
679 signatures: None,
680 }
681 }
682
683 #[tokio::test]
686 async fn set_push_config_for_missing_task_returns_task_not_found() {
687 let handler = RequestHandlerBuilder::new(DummyExecutor)
688 .with_push_sender(NoopSender)
689 .build()
690 .unwrap();
691 let config = make_push_config("ghost-task");
692 let result = handler.on_set_push_config(config, None).await;
693 assert!(
694 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
695 "expected TaskNotFound for a config targeting a missing task, got: {result:?}"
696 );
697 }
698
699 #[tokio::test]
703 async fn push_ops_rejected_when_card_lacks_capability() {
704 use a2a_protocol_types::agent_card::AgentCapabilities;
705 use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
706
707 let handler = RequestHandlerBuilder::new(DummyExecutor)
708 .with_push_sender(NoopSender)
709 .with_agent_card(card_with(AgentCapabilities::none()))
710 .build()
711 .unwrap();
712
713 let set = handler
714 .on_set_push_config(make_push_config("t1"), None)
715 .await;
716 assert!(
717 matches!(set, Err(crate::error::ServerError::PushNotSupported)),
718 "set must be rejected, got: {set:?}"
719 );
720
721 let get = handler
722 .on_get_push_config(
723 GetPushConfigParams {
724 tenant: None,
725 task_id: "t1".into(),
726 id: "cfg-1".into(),
727 },
728 None,
729 )
730 .await;
731 assert!(
732 matches!(get, Err(crate::error::ServerError::PushNotSupported)),
733 "get must be rejected, got: {get:?}"
734 );
735
736 let list = handler.on_list_push_configs("t1", None, None).await;
737 assert!(
738 matches!(list, Err(crate::error::ServerError::PushNotSupported)),
739 "list must be rejected, got: {list:?}"
740 );
741
742 let delete = handler
743 .on_delete_push_config(
744 DeletePushConfigParams {
745 tenant: None,
746 task_id: "t1".into(),
747 id: "cfg-1".into(),
748 },
749 None,
750 )
751 .await;
752 assert!(
753 matches!(delete, Err(crate::error::ServerError::PushNotSupported)),
754 "delete must be rejected, got: {delete:?}"
755 );
756 }
757
758 #[tokio::test]
761 async fn push_ops_allowed_when_card_has_capability() {
762 use a2a_protocol_types::agent_card::AgentCapabilities;
763
764 let handler = RequestHandlerBuilder::new(DummyExecutor)
765 .with_push_sender(NoopSender)
766 .with_agent_card(card_with(
767 AgentCapabilities::none().with_push_notifications(true),
768 ))
769 .build()
770 .unwrap();
771 save_task(&handler, "t1").await;
772
773 handler
774 .on_set_push_config(make_push_config("t1"), None)
775 .await
776 .expect("set should succeed when push capability is advertised");
777 let configs = handler
778 .on_list_push_configs("t1", None, None)
779 .await
780 .expect("list should succeed");
781 assert_eq!(configs.len(), 1, "the created config should be listed");
782 }
783
784 #[tokio::test]
787 async fn get_push_config_not_found_returns_task_not_found() {
788 use a2a_protocol_types::params::GetPushConfigParams;
791
792 let handler = make_handler();
793 let params = GetPushConfigParams {
794 tenant: None,
795 task_id: "no-task".to_owned(),
796 id: "no-id".to_owned(),
797 };
798 let result = handler.on_get_push_config(params, None).await;
799 assert!(
800 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
801 "expected TaskNotFound for missing config, got: {result:?}"
802 );
803 }
804
805 #[tokio::test]
808 async fn list_push_configs_empty_returns_empty_vec() {
809 let handler = make_handler();
810 let result = handler
811 .on_list_push_configs("no-task", None, None)
812 .await
813 .expect("list should succeed on empty store");
814 assert!(
815 result.is_empty(),
816 "listing configs for an unknown task should return an empty vec"
817 );
818 }
819
820 #[tokio::test]
823 async fn delete_push_config_nonexistent_returns_ok() {
824 use a2a_protocol_types::params::DeletePushConfigParams;
825
826 let handler = make_handler();
827 let params = DeletePushConfigParams {
828 tenant: None,
829 task_id: "no-task".to_owned(),
830 id: "no-id".to_owned(),
831 };
832 let result = handler.on_delete_push_config(params, None).await;
835 assert!(
836 result.is_ok(),
837 "deleting a non-existent push config should return Ok, got: {result:?}"
838 );
839 }
840
841 #[tokio::test]
844 async fn list_push_configs_error_path_records_metrics() {
845 use crate::call_context::CallContext;
848 use crate::interceptor::ServerInterceptor;
849 use std::future::Future;
850 use std::pin::Pin;
851
852 struct FailInterceptor;
853 impl ServerInterceptor for FailInterceptor {
854 fn before<'a>(
855 &'a self,
856 _ctx: &'a CallContext,
857 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
858 {
859 Box::pin(async {
860 Err(a2a_protocol_types::error::A2aError::internal(
861 "forced failure",
862 ))
863 })
864 }
865 fn after<'a>(
866 &'a self,
867 _ctx: &'a CallContext,
868 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
869 {
870 Box::pin(async { Ok(()) })
871 }
872 }
873
874 let handler = RequestHandlerBuilder::new(DummyExecutor)
875 .with_interceptor(FailInterceptor)
876 .build()
877 .unwrap();
878
879 let result = handler.on_list_push_configs("task-1", None, None).await;
880 assert!(
881 result.is_err(),
882 "list_push_configs should fail when interceptor rejects"
883 );
884 }
885
886 #[tokio::test]
887 async fn delete_push_config_error_path_records_metrics() {
888 use crate::call_context::CallContext;
891 use crate::interceptor::ServerInterceptor;
892 use a2a_protocol_types::params::DeletePushConfigParams;
893 use std::future::Future;
894 use std::pin::Pin;
895
896 struct FailInterceptor;
897 impl ServerInterceptor for FailInterceptor {
898 fn before<'a>(
899 &'a self,
900 _ctx: &'a CallContext,
901 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
902 {
903 Box::pin(async {
904 Err(a2a_protocol_types::error::A2aError::internal(
905 "forced failure",
906 ))
907 })
908 }
909 fn after<'a>(
910 &'a self,
911 _ctx: &'a CallContext,
912 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
913 {
914 Box::pin(async { Ok(()) })
915 }
916 }
917
918 let handler = RequestHandlerBuilder::new(DummyExecutor)
919 .with_interceptor(FailInterceptor)
920 .build()
921 .unwrap();
922
923 let params = DeletePushConfigParams {
924 tenant: None,
925 task_id: "task-1".to_owned(),
926 id: "cfg-1".to_owned(),
927 };
928 let result = handler.on_delete_push_config(params, None).await;
929 assert!(
930 result.is_err(),
931 "delete_push_config should fail when interceptor rejects"
932 );
933 }
934
935 #[tokio::test]
936 async fn set_push_config_error_path_records_metrics() {
937 let handler = make_handler();
940 let config = make_push_config("task-err");
941 let result = handler.on_set_push_config(config, None).await;
942 assert!(
943 result.is_err(),
944 "set_push_config without push sender should hit error metrics path"
945 );
946 }
947
948 #[tokio::test]
949 async fn get_push_config_error_path_records_metrics() {
950 use a2a_protocol_types::params::GetPushConfigParams;
953
954 let handler = make_handler();
955 let params = GetPushConfigParams {
956 tenant: None,
957 task_id: "missing-task".to_owned(),
958 id: "missing-id".to_owned(),
959 };
960 let result = handler.on_get_push_config(params, None).await;
961 assert!(
962 result.is_err(),
963 "get_push_config for missing config should hit error metrics path"
964 );
965 }
966}