use std::collections::HashMap;
use std::time::Instant;
use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
use a2a_protocol_types::push::TaskPushNotificationConfig;
use a2a_protocol_types::task::TaskId;
use crate::error::{ServerError, ServerResult};
use super::helpers::build_call_context;
use super::RequestHandler;
impl RequestHandler {
#[allow(clippy::too_many_lines)]
pub async fn on_set_push_config(
&self,
config: TaskPushNotificationConfig,
headers: Option<&HashMap<String, String>>,
) -> ServerResult<TaskPushNotificationConfig> {
let start = Instant::now();
self.metrics.on_request("CreateTaskPushNotificationConfig");
let tenant = self
.resolve_tenant(
"CreateTaskPushNotificationConfig",
headers,
config.tenant.as_deref(),
)
.await?;
let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
self.ensure_push_supported()?;
let Some(ref sender) = self.push_sender else {
return Err(ServerError::PushNotSupported);
};
if config.task_id.as_deref().unwrap_or("").is_empty() {
return Err(ServerError::InvalidParams(
"taskId is required for CreateTaskPushNotificationConfig".into(),
));
}
let target_task = TaskId::new(config.task_id.clone().unwrap_or_default());
if self.task_store.get(&target_task).await?.is_none() {
return Err(ServerError::TaskNotFound(target_task));
}
if !sender.allows_private_urls() {
crate::push::sender::validate_webhook_url(&config.url)?;
}
let call_ctx = build_call_context("CreateTaskPushNotificationConfig", headers);
self.interceptors.run_before(&call_ctx).await?;
self.ensure_required_extensions(&call_ctx)?;
let task_key = config.task_id.clone().unwrap_or_default();
let existing = self.push_config_store.list(&task_key).await?;
let is_update = config
.id
.as_deref()
.is_some_and(|id| existing.iter().any(|c| c.id.as_deref() == Some(id)));
if !is_update && existing.len() >= self.limits.max_push_configs_per_task {
return Err(ServerError::InvalidParams(format!(
"task {task_key} already has the maximum of {} push notification configs",
self.limits.max_push_configs_per_task
)));
}
if !is_update {
if let Some(total) = self.push_config_store.count().await? {
if total >= self.limits.max_total_push_configs {
return Err(ServerError::Overloaded(format!(
"server is at the maximum of {} push notification configs; \
delete unused configs before creating more",
self.limits.max_total_push_configs
)));
}
}
}
let result = self.push_config_store.set(config).await?;
self.interceptors.run_after(&call_ctx).await?;
Ok(result)
})
.await;
let elapsed = start.elapsed();
match &result {
Ok(_) => {
self.metrics.on_response("CreateTaskPushNotificationConfig");
self.metrics
.on_latency("CreateTaskPushNotificationConfig", elapsed);
}
Err(e) => {
self.metrics
.on_error("CreateTaskPushNotificationConfig", e.metric_label());
self.metrics
.on_latency("CreateTaskPushNotificationConfig", elapsed);
}
}
result
}
pub async fn on_get_push_config(
&self,
params: GetPushConfigParams,
headers: Option<&HashMap<String, String>>,
) -> ServerResult<TaskPushNotificationConfig> {
let start = Instant::now();
self.metrics.on_request("GetTaskPushNotificationConfig");
let tenant = self
.resolve_tenant(
"GetTaskPushNotificationConfig",
headers,
params.tenant.as_deref(),
)
.await?;
let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
self.ensure_push_supported()?;
let call_ctx = build_call_context("GetTaskPushNotificationConfig", headers);
self.interceptors.run_before(&call_ctx).await?;
self.ensure_required_extensions(&call_ctx)?;
let config = self
.push_config_store
.get(¶ms.task_id, ¶ms.id)
.await?
.ok_or_else(|| ServerError::TaskNotFound(TaskId::new(¶ms.task_id)))?;
self.interceptors.run_after(&call_ctx).await?;
Ok(config)
})
.await;
let elapsed = start.elapsed();
match &result {
Ok(_) => {
self.metrics.on_response("GetTaskPushNotificationConfig");
self.metrics
.on_latency("GetTaskPushNotificationConfig", elapsed);
}
Err(e) => {
self.metrics
.on_error("GetTaskPushNotificationConfig", e.metric_label());
self.metrics
.on_latency("GetTaskPushNotificationConfig", elapsed);
}
}
result
}
pub async fn on_list_push_configs(
&self,
task_id: &str,
tenant: Option<&str>,
headers: Option<&HashMap<String, String>>,
) -> ServerResult<Vec<TaskPushNotificationConfig>> {
let start = Instant::now();
self.metrics.on_request("ListTaskPushNotificationConfigs");
let tenant_owned = self
.resolve_tenant("ListTaskPushNotificationConfigs", headers, tenant)
.await?;
let result: ServerResult<_> =
crate::store::tenant::TenantContext::scope(tenant_owned, async {
self.ensure_push_supported()?;
let call_ctx = build_call_context("ListTaskPushNotificationConfigs", headers);
self.interceptors.run_before(&call_ctx).await?;
self.ensure_required_extensions(&call_ctx)?;
let configs = self.push_config_store.list(task_id).await?;
self.interceptors.run_after(&call_ctx).await?;
Ok(configs)
})
.await;
let elapsed = start.elapsed();
match &result {
Ok(_) => {
self.metrics.on_response("ListTaskPushNotificationConfigs");
self.metrics
.on_latency("ListTaskPushNotificationConfigs", elapsed);
}
Err(e) => {
self.metrics
.on_error("ListTaskPushNotificationConfigs", e.metric_label());
self.metrics
.on_latency("ListTaskPushNotificationConfigs", elapsed);
}
}
result
}
pub async fn on_delete_push_config(
&self,
params: DeletePushConfigParams,
headers: Option<&HashMap<String, String>>,
) -> ServerResult<()> {
let start = Instant::now();
self.metrics.on_request("DeleteTaskPushNotificationConfig");
let tenant = self
.resolve_tenant(
"DeleteTaskPushNotificationConfig",
headers,
params.tenant.as_deref(),
)
.await?;
let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(tenant, async {
self.ensure_push_supported()?;
let call_ctx = build_call_context("DeleteTaskPushNotificationConfig", headers);
self.interceptors.run_before(&call_ctx).await?;
self.ensure_required_extensions(&call_ctx)?;
self.push_config_store
.delete(¶ms.task_id, ¶ms.id)
.await?;
self.interceptors.run_after(&call_ctx).await?;
Ok(())
})
.await;
let elapsed = start.elapsed();
match &result {
Ok(()) => {
self.metrics.on_response("DeleteTaskPushNotificationConfig");
self.metrics
.on_latency("DeleteTaskPushNotificationConfig", elapsed);
}
Err(e) => {
self.metrics
.on_error("DeleteTaskPushNotificationConfig", e.metric_label());
self.metrics
.on_latency("DeleteTaskPushNotificationConfig", elapsed);
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent_executor;
use crate::builder::RequestHandlerBuilder;
struct DummyExecutor;
agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
fn make_handler() -> RequestHandler {
RequestHandlerBuilder::new(DummyExecutor).build().unwrap()
}
fn make_push_config(task_id: &str) -> TaskPushNotificationConfig {
TaskPushNotificationConfig {
tenant: None,
id: Some("cfg-1".to_owned()),
task_id: Some(task_id.to_owned()),
url: "https://example.com/webhook".to_owned(),
token: None,
authentication: None,
}
}
async fn save_task(handler: &RequestHandler, id: &str) {
use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
let task = Task {
id: TaskId::new(id),
context_id: ContextId::new("ctx"),
status: TaskStatus::new(TaskState::Submitted),
history: None,
artifacts: None,
metadata: None,
};
handler.task_store.save(&task).await.unwrap();
}
#[tokio::test]
async fn set_push_config_without_sender_returns_push_not_supported() {
let handler = make_handler();
let config = make_push_config("task-1");
let result = handler.on_set_push_config(config, None).await;
assert!(
matches!(result, Err(crate::error::ServerError::PushNotSupported)),
"expected PushNotSupported, got: {result:?}"
);
}
#[tokio::test]
async fn set_push_config_without_task_id_returns_invalid_params() {
use crate::push::PushSender;
use a2a_protocol_types::events::StreamResponse;
use std::future::Future;
use std::pin::Pin;
struct NoopSender;
impl PushSender for NoopSender {
fn send<'a>(
&'a self,
_url: &'a str,
_event: &'a StreamResponse,
_config: &'a TaskPushNotificationConfig,
) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
{
Box::pin(async { Ok(()) })
}
fn allows_private_urls(&self) -> bool {
true
}
}
let handler = RequestHandlerBuilder::new(DummyExecutor)
.with_push_sender(NoopSender)
.build()
.unwrap();
let config = TaskPushNotificationConfig {
tenant: None,
id: None,
task_id: None,
url: "https://example.com/webhook".to_owned(),
token: None,
authentication: None,
};
let result = handler.on_set_push_config(config, None).await;
match result {
Err(crate::error::ServerError::InvalidParams(msg)) => {
assert!(msg.contains("taskId"), "got: {msg}");
}
other => panic!("expected InvalidParams for missing taskId, got: {other:?}"),
}
}
#[tokio::test]
async fn set_push_config_enforces_global_cap() {
use crate::push::PushSender;
use a2a_protocol_types::events::StreamResponse;
use std::future::Future;
use std::pin::Pin;
struct NoopSender;
impl PushSender for NoopSender {
fn send<'a>(
&'a self,
_url: &'a str,
_event: &'a StreamResponse,
_config: &'a TaskPushNotificationConfig,
) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
{
Box::pin(async { Ok(()) })
}
}
let handler = RequestHandlerBuilder::new(DummyExecutor)
.with_push_sender(NoopSender)
.with_handler_limits(
crate::handler::HandlerLimits::default().with_max_total_push_configs(2),
)
.build()
.unwrap();
for i in 0..2 {
save_task(&handler, &format!("task-{i}")).await;
let cfg = TaskPushNotificationConfig {
tenant: None,
id: Some(format!("cfg-{i}")),
task_id: Some(format!("task-{i}")),
url: "https://example.com/webhook".to_owned(),
token: None,
authentication: None,
};
handler
.on_set_push_config(cfg, None)
.await
.expect("creates under the global cap should succeed");
}
save_task(&handler, "task-x").await;
let cfg = TaskPushNotificationConfig {
tenant: None,
id: Some("cfg-x".to_owned()),
task_id: Some("task-x".to_owned()),
url: "https://example.com/webhook".to_owned(),
token: None,
authentication: None,
};
let result = handler.on_set_push_config(cfg, None).await;
assert!(
matches!(result, Err(crate::error::ServerError::Overloaded(_))),
"global push-config cap must reject, got {result:?}"
);
}
#[tokio::test]
async fn set_push_config_update_allowed_at_per_task_cap() {
use crate::push::PushSender;
use a2a_protocol_types::events::StreamResponse;
use std::future::Future;
use std::pin::Pin;
struct NoopSender;
impl PushSender for NoopSender {
fn send<'a>(
&'a self,
_url: &'a str,
_event: &'a StreamResponse,
_config: &'a TaskPushNotificationConfig,
) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
{
Box::pin(async { Ok(()) })
}
}
let handler = RequestHandlerBuilder::new(DummyExecutor)
.with_push_sender(NoopSender)
.with_handler_limits(
crate::handler::HandlerLimits::default().with_max_push_configs_per_task(1),
)
.build()
.unwrap();
save_task(&handler, "task-1").await;
let make = |url: &str| TaskPushNotificationConfig {
tenant: None,
id: Some("cfg-1".to_owned()),
task_id: Some("task-1".to_owned()),
url: url.to_owned(),
token: None,
authentication: None,
};
handler
.on_set_push_config(make("https://example.com/a"), None)
.await
.expect("first create should succeed");
handler
.on_set_push_config(make("https://example.com/b"), None)
.await
.expect("updating an existing config at the cap must be allowed");
let mut newcfg = make("https://example.com/c");
newcfg.id = Some("cfg-2".to_owned());
let rejected = handler.on_set_push_config(newcfg, None).await;
assert!(
matches!(rejected, Err(crate::error::ServerError::InvalidParams(_))),
"a new config beyond the per-task cap must be rejected, got {rejected:?}"
);
}
struct NoopSender;
impl crate::push::PushSender for NoopSender {
fn send<'a>(
&'a self,
_url: &'a str,
_event: &'a a2a_protocol_types::events::StreamResponse,
_config: &'a TaskPushNotificationConfig,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
+ Send
+ 'a,
>,
> {
Box::pin(async { Ok(()) })
}
fn allows_private_urls(&self) -> bool {
true
}
}
fn card_with(
caps: a2a_protocol_types::agent_card::AgentCapabilities,
) -> a2a_protocol_types::agent_card::AgentCard {
use a2a_protocol_types::agent_card::{AgentCard, AgentInterface};
AgentCard {
url: None,
name: "Test Agent".into(),
description: "A test agent".into(),
version: "1.0.0".into(),
supported_interfaces: vec![AgentInterface {
url: "http://localhost:8080".into(),
protocol_binding: "JSONRPC".into(),
protocol_version: "1.0.0".into(),
tenant: None,
}],
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
capabilities: caps,
provider: None,
icon_url: None,
documentation_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
}
}
#[tokio::test]
async fn set_push_config_for_missing_task_returns_task_not_found() {
let handler = RequestHandlerBuilder::new(DummyExecutor)
.with_push_sender(NoopSender)
.build()
.unwrap();
let config = make_push_config("ghost-task");
let result = handler.on_set_push_config(config, None).await;
assert!(
matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
"expected TaskNotFound for a config targeting a missing task, got: {result:?}"
);
}
#[tokio::test]
async fn push_ops_rejected_when_card_lacks_capability() {
use a2a_protocol_types::agent_card::AgentCapabilities;
use a2a_protocol_types::params::{DeletePushConfigParams, GetPushConfigParams};
let handler = RequestHandlerBuilder::new(DummyExecutor)
.with_push_sender(NoopSender)
.with_agent_card(card_with(AgentCapabilities::none()))
.build()
.unwrap();
let set = handler
.on_set_push_config(make_push_config("t1"), None)
.await;
assert!(
matches!(set, Err(crate::error::ServerError::PushNotSupported)),
"set must be rejected, got: {set:?}"
);
let get = handler
.on_get_push_config(
GetPushConfigParams {
tenant: None,
task_id: "t1".into(),
id: "cfg-1".into(),
},
None,
)
.await;
assert!(
matches!(get, Err(crate::error::ServerError::PushNotSupported)),
"get must be rejected, got: {get:?}"
);
let list = handler.on_list_push_configs("t1", None, None).await;
assert!(
matches!(list, Err(crate::error::ServerError::PushNotSupported)),
"list must be rejected, got: {list:?}"
);
let delete = handler
.on_delete_push_config(
DeletePushConfigParams {
tenant: None,
task_id: "t1".into(),
id: "cfg-1".into(),
},
None,
)
.await;
assert!(
matches!(delete, Err(crate::error::ServerError::PushNotSupported)),
"delete must be rejected, got: {delete:?}"
);
}
#[tokio::test]
async fn push_ops_allowed_when_card_has_capability() {
use a2a_protocol_types::agent_card::AgentCapabilities;
let handler = RequestHandlerBuilder::new(DummyExecutor)
.with_push_sender(NoopSender)
.with_agent_card(card_with(
AgentCapabilities::none().with_push_notifications(true),
))
.build()
.unwrap();
save_task(&handler, "t1").await;
handler
.on_set_push_config(make_push_config("t1"), None)
.await
.expect("set should succeed when push capability is advertised");
let configs = handler
.on_list_push_configs("t1", None, None)
.await
.expect("list should succeed");
assert_eq!(configs.len(), 1, "the created config should be listed");
}
#[tokio::test]
async fn get_push_config_not_found_returns_task_not_found() {
use a2a_protocol_types::params::GetPushConfigParams;
let handler = make_handler();
let params = GetPushConfigParams {
tenant: None,
task_id: "no-task".to_owned(),
id: "no-id".to_owned(),
};
let result = handler.on_get_push_config(params, None).await;
assert!(
matches!(result, Err(crate::error::ServerError::TaskNotFound(_))),
"expected TaskNotFound for missing config, got: {result:?}"
);
}
#[tokio::test]
async fn list_push_configs_empty_returns_empty_vec() {
let handler = make_handler();
let result = handler
.on_list_push_configs("no-task", None, None)
.await
.expect("list should succeed on empty store");
assert!(
result.is_empty(),
"listing configs for an unknown task should return an empty vec"
);
}
#[tokio::test]
async fn delete_push_config_nonexistent_returns_ok() {
use a2a_protocol_types::params::DeletePushConfigParams;
let handler = make_handler();
let params = DeletePushConfigParams {
tenant: None,
task_id: "no-task".to_owned(),
id: "no-id".to_owned(),
};
let result = handler.on_delete_push_config(params, None).await;
assert!(
result.is_ok(),
"deleting a non-existent push config should return Ok, got: {result:?}"
);
}
#[tokio::test]
async fn list_push_configs_error_path_records_metrics() {
use crate::call_context::CallContext;
use crate::interceptor::ServerInterceptor;
use std::future::Future;
use std::pin::Pin;
struct FailInterceptor;
impl ServerInterceptor for FailInterceptor {
fn before<'a>(
&'a self,
_ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
{
Box::pin(async {
Err(a2a_protocol_types::error::A2aError::internal(
"forced failure",
))
})
}
fn after<'a>(
&'a self,
_ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
{
Box::pin(async { Ok(()) })
}
}
let handler = RequestHandlerBuilder::new(DummyExecutor)
.with_interceptor(FailInterceptor)
.build()
.unwrap();
let result = handler.on_list_push_configs("task-1", None, None).await;
assert!(
result.is_err(),
"list_push_configs should fail when interceptor rejects"
);
}
#[tokio::test]
async fn delete_push_config_error_path_records_metrics() {
use crate::call_context::CallContext;
use crate::interceptor::ServerInterceptor;
use a2a_protocol_types::params::DeletePushConfigParams;
use std::future::Future;
use std::pin::Pin;
struct FailInterceptor;
impl ServerInterceptor for FailInterceptor {
fn before<'a>(
&'a self,
_ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
{
Box::pin(async {
Err(a2a_protocol_types::error::A2aError::internal(
"forced failure",
))
})
}
fn after<'a>(
&'a self,
_ctx: &'a CallContext,
) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
{
Box::pin(async { Ok(()) })
}
}
let handler = RequestHandlerBuilder::new(DummyExecutor)
.with_interceptor(FailInterceptor)
.build()
.unwrap();
let params = DeletePushConfigParams {
tenant: None,
task_id: "task-1".to_owned(),
id: "cfg-1".to_owned(),
};
let result = handler.on_delete_push_config(params, None).await;
assert!(
result.is_err(),
"delete_push_config should fail when interceptor rejects"
);
}
#[tokio::test]
async fn set_push_config_error_path_records_metrics() {
let handler = make_handler();
let config = make_push_config("task-err");
let result = handler.on_set_push_config(config, None).await;
assert!(
result.is_err(),
"set_push_config without push sender should hit error metrics path"
);
}
#[tokio::test]
async fn get_push_config_error_path_records_metrics() {
use a2a_protocol_types::params::GetPushConfigParams;
let handler = make_handler();
let params = GetPushConfigParams {
tenant: None,
task_id: "missing-task".to_owned(),
id: "missing-id".to_owned(),
};
let result = handler.on_get_push_config(params, None).await;
assert!(
result.is_err(),
"get_push_config for missing config should hit error metrics path"
);
}
}