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 #[allow(clippy::too_many_lines)]
27 pub async fn on_set_push_config(
28 &self,
29 config: TaskPushNotificationConfig,
30 headers: Option<&HashMap<String, String>>,
31 ) -> ServerResult<TaskPushNotificationConfig> {
32 let start = Instant::now();
33 self.metrics.on_request("CreateTaskPushNotificationConfig");
34
35 let tenant = self
36 .resolve_tenant(
37 "CreateTaskPushNotificationConfig",
38 headers,
39 config.tenant.as_deref(),
40 )
41 .await?;
42 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
43 self.ensure_push_supported()?;
46 let Some(ref sender) = self.push_sender else {
47 return Err(ServerError::PushNotSupported);
48 };
49 if config.task_id.as_deref().unwrap_or("").is_empty() {
54 return Err(ServerError::InvalidParams(
55 "taskId is required for CreateTaskPushNotificationConfig".into(),
56 ));
57 }
58 let target_task = TaskId::new(config.task_id.clone().unwrap_or_default());
61 if self.task_store.get(&target_task).await?.is_none() {
62 return Err(ServerError::TaskNotFound(target_task));
63 }
64 if !sender.allows_private_urls() {
76 crate::push::sender::validate_webhook_url(&config.url)?;
77 }
78
79 let call_ctx = build_call_context("CreateTaskPushNotificationConfig", headers);
80 self.interceptors.run_before(&call_ctx).await?;
81 self.ensure_required_extensions(&call_ctx)?;
84
85 let task_key = config.task_id.clone().unwrap_or_default();
90 let existing = self.push_config_store.list(&task_key).await?;
91 let is_update = config
92 .id
93 .as_deref()
94 .is_some_and(|id| existing.iter().any(|c| c.id.as_deref() == Some(id)));
95 if !is_update && existing.len() >= self.limits.max_push_configs_per_task {
96 return Err(ServerError::InvalidParams(format!(
97 "task {task_key} already has the maximum of {} push notification configs",
98 self.limits.max_push_configs_per_task
99 )));
100 }
101
102 if !is_update {
106 if let Some(total) = self.push_config_store.count().await? {
107 if total >= self.limits.max_total_push_configs {
108 return Err(ServerError::Overloaded(format!(
109 "server is at the maximum of {} push notification configs; \
110 delete unused configs before creating more",
111 self.limits.max_total_push_configs
112 )));
113 }
114 }
115 }
116
117 let result = self.push_config_store.set(config).await?;
118 self.interceptors.run_after(&call_ctx).await?;
119 Ok(result)
120 })
121 .await;
122
123 let elapsed = start.elapsed();
124 match &result {
125 Ok(_) => {
126 self.metrics.on_response("CreateTaskPushNotificationConfig");
127 self.metrics
128 .on_latency("CreateTaskPushNotificationConfig", elapsed);
129 }
130 Err(e) => {
131 self.metrics
132 .on_error("CreateTaskPushNotificationConfig", e.metric_label());
133 self.metrics
134 .on_latency("CreateTaskPushNotificationConfig", elapsed);
135 }
136 }
137 result
138 }
139
140 pub async fn on_get_push_config(
148 &self,
149 params: GetPushConfigParams,
150 headers: Option<&HashMap<String, String>>,
151 ) -> ServerResult<TaskPushNotificationConfig> {
152 let start = Instant::now();
153 self.metrics.on_request("GetTaskPushNotificationConfig");
154
155 let tenant = self
156 .resolve_tenant(
157 "GetTaskPushNotificationConfig",
158 headers,
159 params.tenant.as_deref(),
160 )
161 .await?;
162 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
163 self.ensure_push_supported()?;
165 let call_ctx = build_call_context("GetTaskPushNotificationConfig", headers);
166 self.interceptors.run_before(&call_ctx).await?;
167 self.ensure_required_extensions(&call_ctx)?;
170
171 let config = self
174 .push_config_store
175 .get(¶ms.task_id, ¶ms.id)
176 .await?
177 .ok_or_else(|| ServerError::TaskNotFound(TaskId::new(¶ms.task_id)))?;
178
179 self.interceptors.run_after(&call_ctx).await?;
180 Ok(config)
181 })
182 .await;
183
184 let elapsed = start.elapsed();
185 match &result {
186 Ok(_) => {
187 self.metrics.on_response("GetTaskPushNotificationConfig");
188 self.metrics
189 .on_latency("GetTaskPushNotificationConfig", elapsed);
190 }
191 Err(e) => {
192 self.metrics
193 .on_error("GetTaskPushNotificationConfig", e.metric_label());
194 self.metrics
195 .on_latency("GetTaskPushNotificationConfig", elapsed);
196 }
197 }
198 result
199 }
200
201 pub async fn on_list_push_configs(
207 &self,
208 task_id: &str,
209 tenant: Option<&str>,
210 headers: Option<&HashMap<String, String>>,
211 ) -> ServerResult<Vec<TaskPushNotificationConfig>> {
212 let start = Instant::now();
213 self.metrics.on_request("ListTaskPushNotificationConfigs");
214
215 let tenant_owned = self
216 .resolve_tenant("ListTaskPushNotificationConfigs", headers, tenant)
217 .await?;
218 let result: ServerResult<_> =
219 crate::store::tenant::TenantContext::scope(tenant_owned, async {
220 self.ensure_push_supported()?;
222 let call_ctx = build_call_context("ListTaskPushNotificationConfigs", headers);
223 self.interceptors.run_before(&call_ctx).await?;
224 self.ensure_required_extensions(&call_ctx)?;
227 let configs = self.push_config_store.list(task_id).await?;
228 self.interceptors.run_after(&call_ctx).await?;
229 Ok(configs)
230 })
231 .await;
232
233 let elapsed = start.elapsed();
234 match &result {
235 Ok(_) => {
236 self.metrics.on_response("ListTaskPushNotificationConfigs");
237 self.metrics
238 .on_latency("ListTaskPushNotificationConfigs", elapsed);
239 }
240 Err(e) => {
241 self.metrics
242 .on_error("ListTaskPushNotificationConfigs", e.metric_label());
243 self.metrics
244 .on_latency("ListTaskPushNotificationConfigs", elapsed);
245 }
246 }
247 result
248 }
249
250 pub async fn on_delete_push_config(
256 &self,
257 params: DeletePushConfigParams,
258 headers: Option<&HashMap<String, String>>,
259 ) -> ServerResult<()> {
260 let start = Instant::now();
261 self.metrics.on_request("DeleteTaskPushNotificationConfig");
262
263 let tenant = self
264 .resolve_tenant(
265 "DeleteTaskPushNotificationConfig",
266 headers,
267 params.tenant.as_deref(),
268 )
269 .await?;
270 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
271 self.ensure_push_supported()?;
273 let call_ctx = build_call_context("DeleteTaskPushNotificationConfig", headers);
274 self.interceptors.run_before(&call_ctx).await?;
275 self.ensure_required_extensions(&call_ctx)?;
278 self.push_config_store
279 .delete(¶ms.task_id, ¶ms.id)
280 .await?;
281 self.interceptors.run_after(&call_ctx).await?;
282 Ok(())
283 })
284 .await;
285
286 let elapsed = start.elapsed();
287 match &result {
288 Ok(()) => {
289 self.metrics.on_response("DeleteTaskPushNotificationConfig");
290 self.metrics
291 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
292 }
293 Err(e) => {
294 self.metrics
295 .on_error("DeleteTaskPushNotificationConfig", e.metric_label());
296 self.metrics
297 .on_latency("DeleteTaskPushNotificationConfig", elapsed);
298 }
299 }
300 result
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::agent_executor;
308 use crate::builder::RequestHandlerBuilder;
309
310 struct DummyExecutor;
311 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
312
313 fn make_handler() -> RequestHandler {
314 RequestHandlerBuilder::new(DummyExecutor).build().unwrap()
315 }
316
317 fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
318 TaskPushNotificationConfig {
319 tenant: None,
320 id: Some("cfg-1".to_owned()),
321 task_id: Some(task_id.to_owned()),
322 url: "https://example.com/webhook".to_owned(),
323 token: None,
324 authentication: None,
325 }
326 }
327
328 async fn save_task(handler: &RequestHandler, id: &str) {
331 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
332 let task = Task {
333 id: TaskId::new(id),
334 context_id: ContextId::new("ctx"),
335 status: TaskStatus::new(TaskState::Submitted),
336 history: None,
337 artifacts: None,
338 metadata: None,
339 };
340 handler.task_store.save(&task).await.unwrap();
341 }
342
343 #[tokio::test]
346 async fn set_push_config_without_sender_returns_push_not_supported() {
347 let handler = make_handler();
348 let config = make_push_config("task-1");
349 let result = handler.on_set_push_config(config, None).await;
350 assert!(
351 matches!(result, Err(crate::error::ServerError::PushNotSupported)),
352 "expected PushNotSupported, got: {result:?}"
353 );
354 }
355
356 #[tokio::test]
361 async fn set_push_config_without_task_id_returns_invalid_params() {
362 use crate::push::PushSender;
363 use a2a_protocol_types::events::StreamResponse;
364 use std::future::Future;
365 use std::pin::Pin;
366
367 struct NoopSender;
368 impl PushSender for NoopSender {
369 fn send<'a>(
370 &'a self,
371 _url: &'a str,
372 _event: &'a StreamResponse,
373 _config: &'a TaskPushNotificationConfig,
374 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
375 {
376 Box::pin(async { Ok(()) })
377 }
378 fn allows_private_urls(&self) -> bool {
379 true
380 }
381 }
382
383 let handler = RequestHandlerBuilder::new(DummyExecutor)
384 .with_push_sender(NoopSender)
385 .build()
386 .unwrap();
387
388 let config = TaskPushNotificationConfig {
389 tenant: None,
390 id: None,
391 task_id: None,
392 url: "https://example.com/webhook".to_owned(),
393 token: None,
394 authentication: None,
395 };
396 let result = handler.on_set_push_config(config, None).await;
397 match result {
398 Err(crate::error::ServerError::InvalidParams(msg)) => {
399 assert!(msg.contains("taskId"), "got: {msg}");
400 }
401 other => panic!("expected InvalidParams for missing taskId, got: {other:?}"),
402 }
403 }
404
405 #[tokio::test]
409 async fn set_push_config_enforces_global_cap() {
410 use crate::push::PushSender;
411 use a2a_protocol_types::events::StreamResponse;
412 use std::future::Future;
413 use std::pin::Pin;
414
415 struct NoopSender;
416 impl PushSender for NoopSender {
417 fn send<'a>(
418 &'a self,
419 _url: &'a str,
420 _event: &'a StreamResponse,
421 _config: &'a TaskPushNotificationConfig,
422 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
423 {
424 Box::pin(async { Ok(()) })
425 }
426 }
427
428 let handler = RequestHandlerBuilder::new(DummyExecutor)
429 .with_push_sender(NoopSender)
430 .with_handler_limits(
431 crate::handler::HandlerLimits::default().with_max_total_push_configs(2),
432 )
433 .build()
434 .unwrap();
435
436 for i in 0..2 {
438 save_task(&handler, &format!("task-{i}")).await;
439 let cfg = TaskPushNotificationConfig {
440 tenant: None,
441 id: Some(format!("cfg-{i}")),
442 task_id: Some(format!("task-{i}")),
443 url: "https://example.com/webhook".to_owned(),
444 token: None,
445 authentication: None,
446 };
447 handler
448 .on_set_push_config(cfg, None)
449 .await
450 .expect("creates under the global cap should succeed");
451 }
452
453 save_task(&handler, "task-x").await;
455 let cfg = TaskPushNotificationConfig {
456 tenant: None,
457 id: Some("cfg-x".to_owned()),
458 task_id: Some("task-x".to_owned()),
459 url: "https://example.com/webhook".to_owned(),
460 token: None,
461 authentication: None,
462 };
463 let result = handler.on_set_push_config(cfg, None).await;
464 assert!(
465 matches!(result, Err(crate::error::ServerError::Overloaded(_))),
466 "global push-config cap must reject, got {result:?}"
467 );
468 }
469
470 #[tokio::test]
474 async fn set_push_config_update_allowed_at_per_task_cap() {
475 use crate::push::PushSender;
476 use a2a_protocol_types::events::StreamResponse;
477 use std::future::Future;
478 use std::pin::Pin;
479
480 struct NoopSender;
481 impl PushSender for NoopSender {
482 fn send<'a>(
483 &'a self,
484 _url: &'a str,
485 _event: &'a StreamResponse,
486 _config: &'a TaskPushNotificationConfig,
487 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
488 {
489 Box::pin(async { Ok(()) })
490 }
491 }
492
493 let handler = RequestHandlerBuilder::new(DummyExecutor)
495 .with_push_sender(NoopSender)
496 .with_handler_limits(
497 crate::handler::HandlerLimits::default().with_max_push_configs_per_task(1),
498 )
499 .build()
500 .unwrap();
501
502 save_task(&handler, "task-1").await;
503 let make = |url: &str| TaskPushNotificationConfig {
504 tenant: None,
505 id: Some("cfg-1".to_owned()),
506 task_id: Some("task-1".to_owned()),
507 url: url.to_owned(),
508 token: None,
509 authentication: None,
510 };
511
512 handler
514 .on_set_push_config(make("https://example.com/a"), None)
515 .await
516 .expect("first create should succeed");
517
518 handler
520 .on_set_push_config(make("https://example.com/b"), None)
521 .await
522 .expect("updating an existing config at the cap must be allowed");
523
524 let mut newcfg = make("https://example.com/c");
526 newcfg.id = Some("cfg-2".to_owned());
527 let rejected = handler.on_set_push_config(newcfg, None).await;
528 assert!(
529 matches!(rejected, Err(crate::error::ServerError::InvalidParams(_))),
530 "a new config beyond the per-task cap must be rejected, got {rejected:?}"
531 );
532 }
533
534 struct NoopSender;
537 impl crate::push::PushSender for NoopSender {
538 fn send<'a>(
539 &'a self,
540 _url: &'a str,
541 _event: &'a a2a_protocol_types::events::StreamResponse,
542 _config: &'a TaskPushNotificationConfig,
543 ) -> std::pin::Pin<
544 Box<
545 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
546 + Send
547 + 'a,
548 >,
549 > {
550 Box::pin(async { Ok(()) })
551 }
552 fn allows_private_urls(&self) -> bool {
553 true
554 }
555 }
556
557 fn card_with(
559 caps: a2a_protocol_types::agent_card::AgentCapabilities,
560 ) -> a2a_protocol_types::agent_card::AgentCard {
561 use a2a_protocol_types::agent_card::{AgentCard, AgentInterface};
562 AgentCard {
563 url: None,
564 name: "Test Agent".into(),
565 description: "A test agent".into(),
566 version: "1.0.0".into(),
567 supported_interfaces: vec![AgentInterface {
568 url: "http://localhost:8080".into(),
569 protocol_binding: "JSONRPC".into(),
570 protocol_version: "1.0.0".into(),
571 tenant: None,
572 }],
573 default_input_modes: vec![],
574 default_output_modes: vec![],
575 skills: vec![],
576 capabilities: caps,
577 provider: None,
578 icon_url: None,
579 documentation_url: None,
580 security_schemes: None,
581 security_requirements: None,
582 signatures: None,
583 }
584 }
585
586 #[tokio::test]
589 async fn set_push_config_for_missing_task_returns_task_not_found() {
590 let handler = RequestHandlerBuilder::new(DummyExecutor)
591 .with_push_sender(NoopSender)
592 .build()
593 .unwrap();
594 let config = make_push_config("ghost-task");
595 let result = handler.on_set_push_config(config, None).await;
596 assert!(
597 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
598 "expected TaskNotFound for a config targeting a missing task, got: {result:?}"
599 );
600 }
601
602 #[tokio::test]
606 async fn push_ops_rejected_when_card_lacks_capability() {
607 use a2a_protocol_types::agent_card::AgentCapabilities;
608 use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
609
610 let handler = RequestHandlerBuilder::new(DummyExecutor)
611 .with_push_sender(NoopSender)
612 .with_agent_card(card_with(AgentCapabilities::none()))
613 .build()
614 .unwrap();
615
616 let set = handler
617 .on_set_push_config(make_push_config("t1"), None)
618 .await;
619 assert!(
620 matches!(set, Err(crate::error::ServerError::PushNotSupported)),
621 "set must be rejected, got: {set:?}"
622 );
623
624 let get = handler
625 .on_get_push_config(
626 GetPushConfigParams {
627 tenant: None,
628 task_id: "t1".into(),
629 id: "cfg-1".into(),
630 },
631 None,
632 )
633 .await;
634 assert!(
635 matches!(get, Err(crate::error::ServerError::PushNotSupported)),
636 "get must be rejected, got: {get:?}"
637 );
638
639 let list = handler.on_list_push_configs("t1", None, None).await;
640 assert!(
641 matches!(list, Err(crate::error::ServerError::PushNotSupported)),
642 "list must be rejected, got: {list:?}"
643 );
644
645 let delete = handler
646 .on_delete_push_config(
647 DeletePushConfigParams {
648 tenant: None,
649 task_id: "t1".into(),
650 id: "cfg-1".into(),
651 },
652 None,
653 )
654 .await;
655 assert!(
656 matches!(delete, Err(crate::error::ServerError::PushNotSupported)),
657 "delete must be rejected, got: {delete:?}"
658 );
659 }
660
661 #[tokio::test]
664 async fn push_ops_allowed_when_card_has_capability() {
665 use a2a_protocol_types::agent_card::AgentCapabilities;
666
667 let handler = RequestHandlerBuilder::new(DummyExecutor)
668 .with_push_sender(NoopSender)
669 .with_agent_card(card_with(
670 AgentCapabilities::none().with_push_notifications(true),
671 ))
672 .build()
673 .unwrap();
674 save_task(&handler, "t1").await;
675
676 handler
677 .on_set_push_config(make_push_config("t1"), None)
678 .await
679 .expect("set should succeed when push capability is advertised");
680 let configs = handler
681 .on_list_push_configs("t1", None, None)
682 .await
683 .expect("list should succeed");
684 assert_eq!(configs.len(), 1, "the created config should be listed");
685 }
686
687 #[tokio::test]
690 async fn get_push_config_not_found_returns_task_not_found() {
691 use a2a_protocol_types::params::GetPushConfigParams;
694
695 let handler = make_handler();
696 let params = GetPushConfigParams {
697 tenant: None,
698 task_id: "no-task".to_owned(),
699 id: "no-id".to_owned(),
700 };
701 let result = handler.on_get_push_config(params, None).await;
702 assert!(
703 matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
704 "expected TaskNotFound for missing config, got: {result:?}"
705 );
706 }
707
708 #[tokio::test]
711 async fn list_push_configs_empty_returns_empty_vec() {
712 let handler = make_handler();
713 let result = handler
714 .on_list_push_configs("no-task", None, None)
715 .await
716 .expect("list should succeed on empty store");
717 assert!(
718 result.is_empty(),
719 "listing configs for an unknown task should return an empty vec"
720 );
721 }
722
723 #[tokio::test]
726 async fn delete_push_config_nonexistent_returns_ok() {
727 use a2a_protocol_types::params::DeletePushConfigParams;
728
729 let handler = make_handler();
730 let params = DeletePushConfigParams {
731 tenant: None,
732 task_id: "no-task".to_owned(),
733 id: "no-id".to_owned(),
734 };
735 let result = handler.on_delete_push_config(params, None).await;
738 assert!(
739 result.is_ok(),
740 "deleting a non-existent push config should return Ok, got: {result:?}"
741 );
742 }
743
744 #[tokio::test]
747 async fn list_push_configs_error_path_records_metrics() {
748 use crate::call_context::CallContext;
751 use crate::interceptor::ServerInterceptor;
752 use std::future::Future;
753 use std::pin::Pin;
754
755 struct FailInterceptor;
756 impl ServerInterceptor for FailInterceptor {
757 fn before<'a>(
758 &'a self,
759 _ctx: &'a CallContext,
760 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
761 {
762 Box::pin(async {
763 Err(a2a_protocol_types::error::A2aError::internal(
764 "forced failure",
765 ))
766 })
767 }
768 fn after<'a>(
769 &'a self,
770 _ctx: &'a CallContext,
771 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
772 {
773 Box::pin(async { Ok(()) })
774 }
775 }
776
777 let handler = RequestHandlerBuilder::new(DummyExecutor)
778 .with_interceptor(FailInterceptor)
779 .build()
780 .unwrap();
781
782 let result = handler.on_list_push_configs("task-1", None, None).await;
783 assert!(
784 result.is_err(),
785 "list_push_configs should fail when interceptor rejects"
786 );
787 }
788
789 #[tokio::test]
790 async fn delete_push_config_error_path_records_metrics() {
791 use crate::call_context::CallContext;
794 use crate::interceptor::ServerInterceptor;
795 use a2a_protocol_types::params::DeletePushConfigParams;
796 use std::future::Future;
797 use std::pin::Pin;
798
799 struct FailInterceptor;
800 impl ServerInterceptor for FailInterceptor {
801 fn before<'a>(
802 &'a self,
803 _ctx: &'a CallContext,
804 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
805 {
806 Box::pin(async {
807 Err(a2a_protocol_types::error::A2aError::internal(
808 "forced failure",
809 ))
810 })
811 }
812 fn after<'a>(
813 &'a self,
814 _ctx: &'a CallContext,
815 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
816 {
817 Box::pin(async { Ok(()) })
818 }
819 }
820
821 let handler = RequestHandlerBuilder::new(DummyExecutor)
822 .with_interceptor(FailInterceptor)
823 .build()
824 .unwrap();
825
826 let params = DeletePushConfigParams {
827 tenant: None,
828 task_id: "task-1".to_owned(),
829 id: "cfg-1".to_owned(),
830 };
831 let result = handler.on_delete_push_config(params, None).await;
832 assert!(
833 result.is_err(),
834 "delete_push_config should fail when interceptor rejects"
835 );
836 }
837
838 #[tokio::test]
839 async fn set_push_config_error_path_records_metrics() {
840 let handler = make_handler();
843 let config = make_push_config("task-err");
844 let result = handler.on_set_push_config(config, None).await;
845 assert!(
846 result.is_err(),
847 "set_push_config without push sender should hit error metrics path"
848 );
849 }
850
851 #[tokio::test]
852 async fn get_push_config_error_path_records_metrics() {
853 use a2a_protocol_types::params::GetPushConfigParams;
856
857 let handler = make_handler();
858 let params = GetPushConfigParams {
859 tenant: None,
860 task_id: "missing-task".to_owned(),
861 id: "missing-id".to_owned(),
862 };
863 let result = handler.on_get_push_config(params, None).await;
864 assert!(
865 result.is_err(),
866 "get_push_config for missing config should hit error metrics path"
867 );
868 }
869}