Skip to main content

vtcode_a2a/
webhook.rs

1//! Webhook delivery for A2A push notifications
2//!
3//! Handles HTTP POST delivery of streaming events to configured webhook URLs
4//! with retry logic, authentication, and error handling.
5
6use super::rpc::{SendStreamingMessageResponse, StreamingEvent, TaskPushNotificationConfig};
7use reqwest::Client;
8use std::time::Duration;
9use tracing::{debug, warn};
10
11/// Webhook notifier for delivering A2A events
12#[derive(Debug, Clone)]
13pub struct WebhookNotifier {
14    client: Client,
15    max_retries: u32,
16    retry_delay_ms: u64,
17}
18
19impl Default for WebhookNotifier {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl WebhookNotifier {
26    fn build_http_client() -> Client {
27        match Client::builder().timeout(Duration::from_secs(10)).build() {
28            Ok(client) => client,
29            Err(error) => {
30                warn!(error = %error, "Failed to configure webhook HTTP client; using default client");
31                Client::new()
32            }
33        }
34    }
35
36    /// Create a new webhook notifier with default settings
37    pub(crate) fn new() -> Self {
38        Self {
39            client: Self::build_http_client(),
40            max_retries: 3,
41            retry_delay_ms: 1000,
42        }
43    }
44
45    /// Create a webhook notifier with custom settings
46    fn with_settings(max_retries: u32, retry_delay_ms: u64) -> Self {
47        Self {
48            client: Self::build_http_client(),
49            max_retries,
50            retry_delay_ms,
51        }
52    }
53
54    /// Deliver a streaming event to a webhook URL
55    pub(crate) async fn send_event(
56        &self,
57        config: &TaskPushNotificationConfig,
58        event: StreamingEvent,
59    ) -> Result<(), WebhookError> {
60        let response = SendStreamingMessageResponse { event };
61        let json = serde_json::to_string(&response).map_err(|e| WebhookError::Serialization(e.to_string()))?;
62
63        self.send_with_retry(&config.url, &json, config.authentication.as_deref()).await
64    }
65
66    /// Send webhook with retry logic
67    async fn send_with_retry(&self, url: &str, json: &str, auth: Option<&str>) -> Result<(), WebhookError> {
68        let mut last_error = None;
69
70        for attempt in 0..=self.max_retries {
71            if attempt > 0 {
72                let delay = self.retry_delay_ms * 2u64.pow(attempt - 1); // Exponential backoff
73                debug!("Retrying webhook delivery after {}ms (attempt {})", delay, attempt);
74                tokio::time::sleep(Duration::from_millis(delay)).await;
75            }
76
77            match self.send_request(url, json, auth).await {
78                Ok(()) => {
79                    debug!("Webhook delivered successfully to {}", url);
80                    return Ok(());
81                }
82                Err(e) => {
83                    warn!("Webhook delivery attempt {} failed: {}", attempt + 1, e);
84                    last_error = Some(e);
85                }
86            }
87        }
88
89        Err(last_error.unwrap_or(WebhookError::Unknown))
90    }
91
92    /// Send a single HTTP request
93    async fn send_request(&self, url: &str, json: &str, auth: Option<&str>) -> Result<(), WebhookError> {
94        let mut request = self
95            .client
96            .post(url)
97            .header("Content-Type", "application/json")
98            .header("User-Agent", "VT Code-A2A/1.0");
99
100        if let Some(auth_header) = auth {
101            request = request.header("Authorization", auth_header);
102        }
103
104        let response = request
105            .body(json.to_string())
106            .send()
107            .await
108            .map_err(|e| WebhookError::Network(e.to_string()))?;
109
110        if response.status().is_success() {
111            Ok(())
112        } else {
113            Err(WebhookError::HttpError(response.status().as_u16()))
114        }
115    }
116}
117
118/// Webhook delivery errors
119#[derive(Debug, Clone, thiserror::Error)]
120pub enum WebhookError {
121    /// Network error
122    #[error("Network error: {0}")]
123    Network(String),
124    /// HTTP error status code
125    #[error("HTTP error: {0}")]
126    HttpError(u16),
127    /// JSON serialization error
128    #[error("Serialization error: {0}")]
129    Serialization(String),
130    /// Unknown error
131    #[error("Unknown error")]
132    Unknown,
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::types::{TaskState, TaskStatus};
139
140    #[test]
141    fn test_webhook_notifier_creation() {
142        let notifier = WebhookNotifier::new();
143        assert_eq!(notifier.max_retries, 3);
144        assert_eq!(notifier.retry_delay_ms, 1000);
145    }
146
147    #[test]
148    fn test_webhook_notifier_with_settings() {
149        let notifier = WebhookNotifier::with_settings(5, 2000);
150        assert_eq!(notifier.max_retries, 5);
151        assert_eq!(notifier.retry_delay_ms, 2000);
152    }
153
154    #[tokio::test]
155    async fn test_webhook_error_display() {
156        let err = WebhookError::Network("Connection refused".to_string());
157        assert!(err.to_string().contains("Network error"));
158
159        let err = WebhookError::HttpError(404);
160        assert!(err.to_string().contains("404"));
161    }
162
163    #[tokio::test]
164    async fn test_send_event_serialization() {
165        let notifier = WebhookNotifier::new();
166        let config = TaskPushNotificationConfig {
167            task_id: "task-1".to_string(),
168            url: "https://example.com/webhook".to_string(),
169            authentication: None,
170        };
171
172        let event = StreamingEvent::TaskStatus {
173            task_id: "task-1".to_string(),
174            context_id: None,
175            status: TaskStatus::new(TaskState::Completed),
176            kind: "status-update".to_string(),
177            r#final: true,
178        };
179
180        // This will fail with network error since the URL doesn't exist,
181        // but we're testing that serialization works
182        let result = notifier.send_event(&config, event).await;
183        assert!(result.is_err());
184
185        if let Err(WebhookError::Serialization(_)) = result {
186            panic!("Unexpected serialization error");
187        }
188    }
189}