use async_trait::async_trait;
use chrono::{DateTime, Utc};
use ipnet::IpNet;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::Duration;
use thiserror::Error;
use url::Url;
use crate::TaskId;
#[derive(Debug, Error)]
pub enum WebhookError {
#[error("Webhook request failed: {0}")]
RequestFailed(String),
#[error("Max retries exceeded for webhook")]
MaxRetriesExceeded,
#[error("Webhook serialization error: {0}")]
SerializationError(String),
#[error("Invalid webhook URL: {0}")]
InvalidUrl(String),
#[error("URL scheme not allowed: {0}. Only HTTPS is permitted for webhooks")]
SchemeNotAllowed(String),
#[error("Webhook URL resolves to blocked IP address: {0}")]
BlockedIpAddress(String),
#[error("DNS resolution failed for webhook URL host: {0}")]
DnsResolutionFailed(String),
}
const BLOCKED_IP_RANGES: &[&str] = &[
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"::1/128",
"fe80::/10",
"fc00::/7",
];
pub fn is_blocked_ip(ip: &IpAddr) -> bool {
BLOCKED_IP_RANGES.iter().any(|range| {
range
.parse::<IpNet>()
.map(|net| net.contains(ip))
.unwrap_or(false)
})
}
pub fn validate_webhook_url(url_str: &str) -> Result<Url, WebhookError> {
let parsed_url =
Url::parse(url_str).map_err(|e| WebhookError::InvalidUrl(format!("{}: {}", url_str, e)))?;
if parsed_url.scheme() != "https" {
return Err(WebhookError::SchemeNotAllowed(
parsed_url.scheme().to_string(),
));
}
let host = parsed_url
.host_str()
.ok_or_else(|| WebhookError::InvalidUrl("URL has no host".to_string()))?;
let host_for_parse = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
if let Ok(ip) = host_for_parse.parse::<IpAddr>() {
if is_blocked_ip(&ip) {
return Err(WebhookError::BlockedIpAddress(ip.to_string()));
}
return Ok(parsed_url);
}
let host_lower = host.to_lowercase();
if host_lower == "localhost" || host_lower.ends_with(".localhost") {
return Err(WebhookError::BlockedIpAddress("localhost".to_string()));
}
if host_lower.ends_with(".internal") || host_lower.ends_with(".local") {
return Err(WebhookError::BlockedIpAddress(format!(
"internal hostname: {}",
host
)));
}
Ok(parsed_url)
}
pub async fn validate_resolved_ips(url: &Url) -> Result<(), WebhookError> {
let host = url
.host_str()
.ok_or_else(|| WebhookError::InvalidUrl("URL has no host".to_string()))?;
let host_for_parse = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
if host_for_parse.parse::<IpAddr>().is_ok() {
return Ok(());
}
let port = url.port().unwrap_or(443);
let addrs = tokio::net::lookup_host(format!("{}:{}", host, port))
.await
.map_err(|e| WebhookError::DnsResolutionFailed(format!("{}: {}", host, e)))?;
for addr in addrs {
if is_blocked_ip(&addr.ip()) {
return Err(WebhookError::BlockedIpAddress(format!(
"{} resolves to {}",
host,
addr.ip()
)));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
Success,
Failed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEvent {
pub task_id: TaskId,
pub task_name: String,
pub status: TaskStatus,
pub result: Option<String>,
pub error: Option<String>,
pub started_at: DateTime<Utc>,
pub completed_at: DateTime<Utc>,
pub duration_ms: u64,
}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub backoff_multiplier: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(30),
backoff_multiplier: 2.0,
}
}
}
#[derive(Debug, Clone)]
pub struct WebhookConfig {
pub url: String,
pub method: String,
pub headers: HashMap<String, String>,
pub timeout: Duration,
pub retry_config: RetryConfig,
}
impl Default for WebhookConfig {
fn default() -> Self {
Self {
url: String::new(),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig::default(),
}
}
}
#[async_trait]
pub trait WebhookSender: Send + Sync {
async fn send(&self, event: &WebhookEvent) -> Result<(), WebhookError>;
}
pub struct HttpWebhookSender {
client: reqwest::Client,
config: WebhookConfig,
}
impl HttpWebhookSender {
pub fn new(config: WebhookConfig) -> Self {
let client = reqwest::Client::builder()
.timeout(config.timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { client, config }
}
pub fn calculate_backoff(&self, retry_count: u32) -> Duration {
let retry_config = &self.config.retry_config;
let backoff_ms = retry_config.initial_backoff.as_millis() as f64
* retry_config.backoff_multiplier.powi(retry_count as i32);
let mut rng = rand::rng();
let jitter = rng.random_range(-0.25..=0.25);
let backoff_with_jitter = backoff_ms * (1.0 + jitter);
let capped_backoff = backoff_with_jitter.min(retry_config.max_backoff.as_millis() as f64);
Duration::from_millis(capped_backoff.max(0.0) as u64)
}
async fn send_with_retry(&self, event: &WebhookEvent) -> Result<(), WebhookError> {
let mut retry_count = 0;
let max_retries = self.config.retry_config.max_retries;
loop {
match self.send_request(event).await {
Ok(_) => return Ok(()),
Err(e) => {
if retry_count >= max_retries {
return Err(WebhookError::MaxRetriesExceeded);
}
let backoff = self.calculate_backoff(retry_count);
tracing::warn!(
attempt = retry_count + 1,
max_attempts = max_retries + 1,
error = %e,
backoff = ?backoff,
"Webhook request failed, retrying"
);
tokio::time::sleep(backoff).await;
retry_count += 1;
}
}
}
}
async fn send_request(&self, event: &WebhookEvent) -> Result<(), WebhookError> {
let json_body = serde_json::to_string(event)
.map_err(|e| WebhookError::SerializationError(e.to_string()))?;
let mut request = match self.config.method.to_uppercase().as_str() {
"POST" => self.client.post(&self.config.url),
"PUT" => self.client.put(&self.config.url),
"PATCH" => self.client.patch(&self.config.url),
_ => self.client.post(&self.config.url),
};
for (key, value) in &self.config.headers {
request = request.header(key, value);
}
let response = request
.header("Content-Type", "application/json")
.body(json_body)
.send()
.await
.map_err(|e| WebhookError::RequestFailed(e.to_string()))?;
if !response.status().is_success() {
return Err(WebhookError::RequestFailed(format!(
"HTTP {}: {}",
response.status(),
response
.text()
.await
.unwrap_or_else(|_| "No response body".to_string())
)));
}
Ok(())
}
}
#[async_trait]
impl WebhookSender for HttpWebhookSender {
async fn send(&self, event: &WebhookEvent) -> Result<(), WebhookError> {
let validated_url = validate_webhook_url(&self.config.url)?;
validate_resolved_ips(&validated_url).await?;
self.send_with_retry(event).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::time::Duration;
#[rstest]
fn test_task_status_serialization() {
let status = TaskStatus::Success;
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, r#""success""#);
let status: TaskStatus = serde_json::from_str(r#""failed""#).unwrap();
assert_eq!(status, TaskStatus::Failed);
}
#[rstest]
fn test_webhook_event_serialization() {
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: Some("OK".to_string()),
error: None,
started_at: now,
completed_at: now,
duration_ms: 1000,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("test_task"));
assert!(json.contains(r#""status":"success""#));
let deserialized: WebhookEvent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.task_name, "test_task");
assert_eq!(deserialized.status, TaskStatus::Success);
}
#[rstest]
fn test_retry_config_default() {
let config = RetryConfig::default();
assert_eq!(config.max_retries, 3);
assert_eq!(config.initial_backoff, Duration::from_millis(100));
assert_eq!(config.max_backoff, Duration::from_secs(30));
assert_eq!(config.backoff_multiplier, 2.0);
}
#[rstest]
fn test_webhook_config_default() {
let config = WebhookConfig::default();
assert_eq!(config.url, "");
assert_eq!(config.method, "POST");
assert_eq!(config.timeout, Duration::from_secs(5));
assert!(config.headers.is_empty());
}
#[rstest]
fn test_calculate_backoff() {
let config = WebhookConfig {
url: "https://example.com".to_string(),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig {
max_retries: 3,
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(10),
backoff_multiplier: 2.0,
},
};
let sender = HttpWebhookSender::new(config);
let backoff0 = sender.calculate_backoff(0);
let backoff1 = sender.calculate_backoff(1);
let backoff2 = sender.calculate_backoff(2);
assert!(backoff0.as_millis() >= 75 && backoff0.as_millis() <= 125); assert!(backoff1.as_millis() >= 150 && backoff1.as_millis() <= 250); assert!(backoff2.as_millis() >= 300 && backoff2.as_millis() <= 500);
let backoff_large = sender.calculate_backoff(100);
assert!(backoff_large <= Duration::from_secs(10));
}
#[rstest]
fn test_webhook_error_display() {
let error = WebhookError::RequestFailed("Connection timeout".to_string());
assert_eq!(
error.to_string(),
"Webhook request failed: Connection timeout"
);
let error = WebhookError::MaxRetriesExceeded;
assert_eq!(error.to_string(), "Max retries exceeded for webhook");
let error = WebhookError::SerializationError("Invalid JSON".to_string());
assert_eq!(
error.to_string(),
"Webhook serialization error: Invalid JSON"
);
}
#[rstest]
#[tokio::test]
async fn test_http_webhook_sender_creation() {
let config = WebhookConfig::default();
let sender = HttpWebhookSender::new(config);
assert_eq!(sender.config.method, "POST");
}
#[rstest]
#[tokio::test]
async fn test_webhook_event_creation() {
let now = Utc::now();
let started = now - chrono::Duration::seconds(5);
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: Some("Task completed successfully".to_string()),
error: None,
started_at: started,
completed_at: now,
duration_ms: 5000,
};
assert_eq!(event.task_name, "test_task");
assert_eq!(event.status, TaskStatus::Success);
assert!(event.result.is_some());
assert!(event.error.is_none());
assert_eq!(event.duration_ms, 5000);
}
#[rstest]
#[tokio::test]
async fn test_webhook_failed_event() {
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "failed_task".to_string(),
status: TaskStatus::Failed,
result: None,
error: Some("Database connection failed".to_string()),
started_at: now,
completed_at: now,
duration_ms: 100,
};
assert_eq!(event.status, TaskStatus::Failed);
assert!(event.result.is_none());
assert!(event.error.is_some());
assert_eq!(
event.error.unwrap(),
"Database connection failed".to_string()
);
}
#[rstest]
#[tokio::test]
async fn test_webhook_send_success() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/webhook")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"status":"ok"}"#)
.create_async()
.await;
let config = WebhookConfig {
url: format!("{}/webhook", server.url()),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig {
max_retries: 0,
initial_backoff: Duration::from_millis(10),
max_backoff: Duration::from_secs(1),
backoff_multiplier: 2.0,
},
};
let sender = HttpWebhookSender::new(config);
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: Some("OK".to_string()),
error: None,
started_at: now,
completed_at: now,
duration_ms: 100,
};
let result = sender.send_with_retry(&event).await;
assert!(result.is_ok());
mock.assert_async().await;
}
#[rstest]
#[tokio::test]
async fn test_webhook_send_retry_then_success() {
let mut server = mockito::Server::new_async().await;
let mock1 = server
.mock("POST", "/webhook")
.with_status(500)
.expect(1)
.create_async()
.await;
let mock2 = server
.mock("POST", "/webhook")
.with_status(503)
.expect(1)
.create_async()
.await;
let mock3 = server
.mock("POST", "/webhook")
.with_status(200)
.expect(1)
.create_async()
.await;
let config = WebhookConfig {
url: format!("{}/webhook", server.url()),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig {
max_retries: 3,
initial_backoff: Duration::from_millis(10),
max_backoff: Duration::from_secs(1),
backoff_multiplier: 2.0,
},
};
let sender = HttpWebhookSender::new(config);
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: Some("OK".to_string()),
error: None,
started_at: now,
completed_at: now,
duration_ms: 100,
};
let result = sender.send_with_retry(&event).await;
assert!(result.is_ok());
mock1.assert_async().await;
mock2.assert_async().await;
mock3.assert_async().await;
}
#[rstest]
#[tokio::test]
async fn test_webhook_send_max_retries_exceeded() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/webhook")
.with_status(500)
.expect(4) .create_async()
.await;
let config = WebhookConfig {
url: format!("{}/webhook", server.url()),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig {
max_retries: 3,
initial_backoff: Duration::from_millis(10),
max_backoff: Duration::from_secs(1),
backoff_multiplier: 2.0,
},
};
let sender = HttpWebhookSender::new(config);
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: Some("OK".to_string()),
error: None,
started_at: now,
completed_at: now,
duration_ms: 100,
};
let result = sender.send_with_retry(&event).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
WebhookError::MaxRetriesExceeded
));
mock.assert_async().await;
}
#[rstest]
#[tokio::test]
async fn test_webhook_custom_headers() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/webhook")
.match_header("Authorization", "Bearer test-token")
.match_header("X-Custom-Header", "custom-value")
.with_status(200)
.create_async()
.await;
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
headers.insert("X-Custom-Header".to_string(), "custom-value".to_string());
let config = WebhookConfig {
url: format!("{}/webhook", server.url()),
method: "POST".to_string(),
headers,
timeout: Duration::from_secs(5),
retry_config: RetryConfig {
max_retries: 0,
initial_backoff: Duration::from_millis(10),
max_backoff: Duration::from_secs(1),
backoff_multiplier: 2.0,
},
};
let sender = HttpWebhookSender::new(config);
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: Some("OK".to_string()),
error: None,
started_at: now,
completed_at: now,
duration_ms: 100,
};
let result = sender.send_with_retry(&event).await;
assert!(result.is_ok());
mock.assert_async().await;
}
#[rstest]
#[tokio::test]
async fn test_webhook_retry_loop_sleeps_between_retries() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/webhook")
.with_status(500)
.expect(3) .create_async()
.await;
let config = WebhookConfig {
url: format!("{}/webhook", server.url()),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig {
max_retries: 2,
initial_backoff: Duration::from_millis(50),
max_backoff: Duration::from_secs(1),
backoff_multiplier: 2.0,
},
};
let sender = HttpWebhookSender::new(config);
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: None,
error: None,
started_at: now,
completed_at: now,
duration_ms: 0,
};
let start = std::time::Instant::now();
let result = sender.send_with_retry(&event).await;
let elapsed = start.elapsed();
assert!(result.is_err());
assert!(
elapsed >= Duration::from_millis(80),
"Expected at least 80ms delay from retry backoff sleep, got {:?}",
elapsed
);
}
#[rstest]
#[case("127.0.0.1", true)]
#[case("127.0.0.2", true)]
#[case("127.255.255.255", true)]
#[case("10.0.0.1", true)]
#[case("10.255.255.255", true)]
#[case("172.16.0.1", true)]
#[case("172.31.255.255", true)]
#[case("192.168.0.1", true)]
#[case("192.168.255.255", true)]
#[case("169.254.169.254", true)]
#[case("169.254.170.2", true)]
#[case("::1", true)]
#[case("fe80::1", true)]
#[case("fc00::1", true)]
#[case("8.8.8.8", false)]
#[case("1.1.1.1", false)]
#[case("203.0.113.1", false)]
#[case("2001:db8::1", false)]
fn test_is_blocked_ip(#[case] ip_str: &str, #[case] expected: bool) {
let ip: IpAddr = ip_str.parse().unwrap();
let result = is_blocked_ip(&ip);
assert_eq!(
result, expected,
"IP {} should be blocked={}",
ip_str, expected
);
}
#[rstest]
#[case("https://example.com/webhook", true)]
#[case("https://api.example.com/hooks/123", true)]
#[case("https://hooks.slack.com/services/T00/B00/xxx", true)]
fn test_validate_webhook_url_accepts_valid_urls(#[case] url: &str, #[case] _valid: bool) {
let result = validate_webhook_url(url);
assert!(
result.is_ok(),
"URL {} should be valid: {:?}",
url,
result.err()
);
}
#[rstest]
#[case("http://example.com/webhook", "SchemeNotAllowed")]
#[case("ftp://example.com/file", "SchemeNotAllowed")]
#[case("not-a-url", "InvalidUrl")]
#[case("https://127.0.0.1/webhook", "BlockedIpAddress")]
#[case("https://10.0.0.1/webhook", "BlockedIpAddress")]
#[case("https://172.16.0.1/webhook", "BlockedIpAddress")]
#[case("https://192.168.1.1/webhook", "BlockedIpAddress")]
#[case("https://169.254.169.254/latest/meta-data/", "BlockedIpAddress")]
#[case("https://[::1]/webhook", "BlockedIpAddress")]
#[case("https://[fe80::1]/webhook", "BlockedIpAddress")]
#[case("https://[fc00::1]/webhook", "BlockedIpAddress")]
#[case("https://localhost/webhook", "BlockedIpAddress")]
#[case("https://sub.localhost/webhook", "BlockedIpAddress")]
#[case("https://service.internal/webhook", "BlockedIpAddress")]
#[case("https://printer.local/webhook", "BlockedIpAddress")]
fn test_validate_webhook_url_rejects_unsafe_urls(
#[case] url: &str,
#[case] expected_error: &str,
) {
let result = validate_webhook_url(url);
assert!(result.is_err(), "URL {} should be rejected", url);
let err = result.unwrap_err();
let err_name = match &err {
WebhookError::InvalidUrl(_) => "InvalidUrl",
WebhookError::SchemeNotAllowed(_) => "SchemeNotAllowed",
WebhookError::BlockedIpAddress(_) => "BlockedIpAddress",
WebhookError::DnsResolutionFailed(_) => "DnsResolutionFailed",
_ => "Other",
};
assert_eq!(
err_name, expected_error,
"URL {} should produce {} error, got: {}",
url, expected_error, err
);
}
#[rstest]
fn test_validate_webhook_url_blocks_cloud_metadata_endpoint() {
let metadata_urls = [
"https://169.254.169.254/latest/meta-data/",
"https://169.254.169.254/computeMetadata/v1/",
"https://169.254.170.2/v2/credentials",
];
for url in &metadata_urls {
let result = validate_webhook_url(url);
assert!(
result.is_err(),
"Cloud metadata URL {} should be blocked",
url
);
assert!(
matches!(result.unwrap_err(), WebhookError::BlockedIpAddress(_)),
"Cloud metadata URL {} should produce BlockedIpAddress error",
url
);
}
}
#[rstest]
fn test_webhook_error_display_ssrf_variants() {
let error = WebhookError::InvalidUrl("bad-url".to_string());
assert_eq!(error.to_string(), "Invalid webhook URL: bad-url");
let error = WebhookError::SchemeNotAllowed("http".to_string());
assert_eq!(
error.to_string(),
"URL scheme not allowed: http. Only HTTPS is permitted for webhooks"
);
let error = WebhookError::BlockedIpAddress("127.0.0.1".to_string());
assert_eq!(
error.to_string(),
"Webhook URL resolves to blocked IP address: 127.0.0.1"
);
let error = WebhookError::DnsResolutionFailed("bad.host".to_string());
assert_eq!(
error.to_string(),
"DNS resolution failed for webhook URL host: bad.host"
);
}
#[rstest]
#[tokio::test]
async fn test_send_rejects_http_url_via_ssrf_validation() {
let config = WebhookConfig {
url: "http://example.com/webhook".to_string(),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig::default(),
};
let sender = HttpWebhookSender::new(config);
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: None,
error: None,
started_at: now,
completed_at: now,
duration_ms: 0,
};
let result = sender.send(&event).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
WebhookError::SchemeNotAllowed(_)
));
}
#[rstest]
#[tokio::test]
async fn test_send_rejects_private_ip_via_ssrf_validation() {
let config = WebhookConfig {
url: "https://192.168.1.1/webhook".to_string(),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig::default(),
};
let sender = HttpWebhookSender::new(config);
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "test_task".to_string(),
status: TaskStatus::Success,
result: None,
error: None,
started_at: now,
completed_at: now,
duration_ms: 0,
};
let result = sender.send(&event).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
WebhookError::BlockedIpAddress(_)
));
}
#[rstest]
#[case(1, Duration::from_millis(30), Duration::from_millis(50))]
#[case(2, Duration::from_millis(80), Duration::from_millis(50))]
#[case(3, Duration::from_millis(200), Duration::from_millis(50))]
#[tokio::test]
async fn test_webhook_retry_sleep_is_called_between_attempts(
#[case] max_retries: u32,
#[case] min_elapsed: Duration,
#[case] initial_backoff: Duration,
) {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/webhook")
.with_status(500)
.expect((max_retries + 1) as usize)
.create_async()
.await;
let config = WebhookConfig {
url: format!("{}/webhook", server.url()),
method: "POST".to_string(),
headers: HashMap::new(),
timeout: Duration::from_secs(5),
retry_config: RetryConfig {
max_retries,
initial_backoff,
max_backoff: Duration::from_secs(1),
backoff_multiplier: 2.0,
},
};
let sender = HttpWebhookSender::new(config);
let now = Utc::now();
let event = WebhookEvent {
task_id: TaskId::new(),
task_name: "regression_742".to_string(),
status: TaskStatus::Success,
result: None,
error: None,
started_at: now,
completed_at: now,
duration_ms: 0,
};
let start = std::time::Instant::now();
let result = sender.send_with_retry(&event).await;
let elapsed = start.elapsed();
assert!(
result.is_err(),
"expected MaxRetriesExceeded after all retries"
);
assert!(
elapsed >= min_elapsed,
"Regression #742: expected sleep between retries (>={:?}), got {:?}",
min_elapsed,
elapsed
);
}
}