use crate::events::{EventFilter, EventManager, EventSubscription, JobLifecycleEvent};
use crate::task_tracker::{ListenerHandle, TaskTracker, clamp_permits};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::{
sync::{OwnedSemaphorePermit, RwLock, Semaphore, broadcast, oneshot},
time::{Instant, sleep, timeout_at},
};
use uuid::Uuid;
mod uuid_string {
use serde::{Deserialize, Deserializer, Serializer};
use uuid::Uuid;
pub fn serialize<S>(uuid: &Uuid, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&uuid.to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Uuid, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let s = String::deserialize(deserializer)?;
Uuid::parse_str(&s).map_err(D::Error::custom)
}
}
#[derive(Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WebhookConfig {
#[serde(with = "uuid_string")]
pub id: Uuid,
pub name: String,
pub url: String,
pub method: HttpMethod,
pub headers: HashMap<String, String>,
pub filter: EventFilter,
pub retry_policy: RetryPolicy,
pub auth: Option<WebhookAuth>,
#[serde(default)]
pub timeout_secs: u64,
pub enabled: bool,
pub secret: Option<String>,
pub payload_template: Option<String>,
}
impl Default for WebhookConfig {
fn default() -> Self {
Self {
id: Uuid::new_v4(),
name: "Default Webhook".to_string(),
url: "https://example.com/webhook".to_string(),
method: HttpMethod::Post,
headers: HashMap::new(),
filter: EventFilter::default(),
retry_policy: RetryPolicy::default(),
auth: None,
timeout_secs: 30,
enabled: true,
secret: None,
payload_template: None,
}
}
}
impl std::fmt::Debug for WebhookConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut header_names: Vec<&String> = self.headers.keys().collect();
header_names.sort();
f.debug_struct("WebhookConfig")
.field("id", &self.id)
.field("name", &self.name)
.field("url", &crate::config::redact_url_path(&self.url))
.field("method", &self.method)
.field("headers", &header_names)
.field("filter", &self.filter)
.field("retry_policy", &self.retry_policy)
.field("auth", &self.auth)
.field("timeout_secs", &self.timeout_secs)
.field("enabled", &self.enabled)
.field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
.field("payload_template", &self.payload_template)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[non_exhaustive]
pub enum HttpMethod {
Post,
Put,
Patch,
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpMethod::Post => write!(f, "POST"),
HttpMethod::Put => write!(f, "PUT"),
HttpMethod::Patch => write!(f, "PATCH"),
}
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
#[non_exhaustive]
pub enum WebhookAuth {
Bearer { token: String },
Basic { username: String, password: String },
ApiKey {
header_name: String,
api_key: String,
},
Custom { headers: HashMap<String, String> },
}
impl std::fmt::Debug for WebhookAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
const REDACTED: &str = "[REDACTED]";
match self {
WebhookAuth::Bearer { .. } => {
f.debug_struct("Bearer").field("token", &REDACTED).finish()
}
WebhookAuth::Basic { username, .. } => f
.debug_struct("Basic")
.field("username", username)
.field("password", &REDACTED)
.finish(),
WebhookAuth::ApiKey { header_name, .. } => f
.debug_struct("ApiKey")
.field("header_name", header_name)
.field("api_key", &REDACTED)
.finish(),
WebhookAuth::Custom { headers } => {
let mut names: Vec<&String> = headers.keys().collect();
names.sort();
f.debug_struct("Custom").field("headers", &names).finish()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RetryPolicy {
pub max_attempts: u32,
pub initial_delay_secs: u64,
pub max_delay_secs: u64,
pub backoff_multiplier: f64,
pub retry_on_status_codes: Vec<u16>,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
initial_delay_secs: 1,
max_delay_secs: 300, backoff_multiplier: 2.0,
retry_on_status_codes: vec![408, 429, 500, 502, 503, 504],
}
}
}
impl RetryPolicy {
pub fn calculate_delay(&self, attempt: u32) -> Duration {
let delay_secs = (self.initial_delay_secs as f64)
* self
.backoff_multiplier
.powi(attempt.min(i32::MAX as u32) as i32);
let delay_secs = delay_secs.min(self.max_delay_secs as f64);
Duration::from_secs(delay_secs as u64)
}
pub fn should_retry_status(&self, status_code: u16) -> bool {
self.retry_on_status_codes.contains(&status_code)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WebhookDelivery {
#[serde(with = "uuid_string")]
pub delivery_id: Uuid,
#[serde(with = "uuid_string")]
pub webhook_id: Uuid,
#[serde(with = "uuid_string")]
pub event_id: Uuid,
pub status_code: Option<u16>,
pub response_body: Option<String>,
pub error_message: Option<String>,
pub attempted_at: DateTime<Utc>,
pub duration_ms: Option<u64>,
pub attempt_number: u32,
pub success: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WebhookStats {
#[serde(with = "uuid_string")]
pub webhook_id: Uuid,
pub total_attempts: u64,
#[serde(default)]
pub failed_attempts: u64,
pub successful_deliveries: u64,
pub failed_deliveries: u64,
pub success_rate: f64,
pub avg_response_time_ms: f64,
pub last_success_at: Option<DateTime<Utc>>,
pub last_failure_at: Option<DateTime<Utc>>,
#[serde(default)]
pub dropped_events: u64,
pub calculated_at: DateTime<Utc>,
}
impl WebhookStats {
pub(crate) fn empty(webhook_id: Uuid) -> Self {
Self {
webhook_id,
total_attempts: 0,
failed_attempts: 0,
successful_deliveries: 0,
failed_deliveries: 0,
success_rate: 0.0,
avg_response_time_ms: 0.0,
last_success_at: None,
last_failure_at: None,
dropped_events: 0,
calculated_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WebhookManagerStats {
pub total_webhooks: usize,
pub active_webhooks: usize,
pub total_attempts: u64,
#[serde(default)]
pub failed_attempts: u64,
pub successful_deliveries: u64,
pub failed_deliveries: u64,
}
pub struct WebhookManager {
http_client: Client,
webhooks: Arc<RwLock<HashMap<Uuid, WebhookConfig>>>,
subscriptions: Arc<RwLock<HashMap<Uuid, ListenerHandle>>>,
event_manager: Arc<EventManager>,
delivery_semaphore: Arc<Semaphore>,
stats: Arc<RwLock<HashMap<Uuid, WebhookStats>>>,
config: WebhookManagerConfig,
listeners: TaskTracker,
deliveries: TaskTracker,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WebhookManagerConfig {
pub max_concurrent_deliveries: usize,
pub max_response_body_size: usize,
pub log_deliveries: bool,
pub default_timeout_secs: u64,
pub max_pending_deliveries: usize,
pub user_agent: String,
}
impl Default for WebhookManagerConfig {
fn default() -> Self {
Self {
max_concurrent_deliveries: 100,
max_response_body_size: 64 * 1024, log_deliveries: true,
default_timeout_secs: 30,
max_pending_deliveries: 1_000,
user_agent: format!("hammerwork-webhooks/{}", env!("CARGO_PKG_VERSION")),
}
}
}
impl From<&crate::config::WebhookGlobalSettings> for WebhookManagerConfig {
fn from(settings: &crate::config::WebhookGlobalSettings) -> Self {
Self {
max_concurrent_deliveries: settings.max_concurrent_deliveries,
max_response_body_size: settings.max_response_body_size,
log_deliveries: settings.log_deliveries,
default_timeout_secs: settings.default_timeout_secs,
max_pending_deliveries: settings.max_pending_deliveries,
user_agent: settings.user_agent.clone(),
}
}
}
fn clamp_delivery_permits(configured: usize) -> usize {
clamp_permits(configured, "max_concurrent_deliveries")
}
fn attempt_timeout(webhook: &WebhookConfig, config: &WebhookManagerConfig) -> Duration {
let secs = if webhook.timeout_secs == 0 {
config.default_timeout_secs
} else {
webhook.timeout_secs
};
Duration::from_secs(secs)
}
async fn read_body_limited(
mut response: reqwest::Response,
max_bytes: usize,
) -> reqwest::Result<String> {
let mut body = Vec::new();
let mut truncated = false;
while let Some(chunk) = response.chunk().await? {
let room = max_bytes.saturating_sub(body.len());
if chunk.len() > room {
body.extend_from_slice(&chunk[..room]);
truncated = true;
break;
}
body.extend_from_slice(&chunk);
}
Ok(response_body_text(body, truncated))
}
fn response_body_text(mut body: Vec<u8>, truncated: bool) -> String {
if truncated
&& let Err(e) = std::str::from_utf8(&body)
&& e.error_len().is_none()
{
body.truncate(e.valid_up_to());
}
let mut text = String::from_utf8_lossy(&body).into_owned();
if truncated {
text.push_str("... [truncated]");
}
text
}
impl WebhookManager {
pub fn new(
event_manager: Arc<EventManager>,
config: WebhookManagerConfig,
) -> crate::Result<Self> {
let http_client = Client::builder()
.user_agent(&config.user_agent)
.build()
.map_err(|e| crate::error::HammerworkError::Webhook {
message: format!("Failed to create webhook HTTP client: {e}"),
})?;
let permits = clamp_delivery_permits(config.max_concurrent_deliveries);
Ok(Self {
http_client,
webhooks: Arc::new(RwLock::new(HashMap::new())),
subscriptions: Arc::new(RwLock::new(HashMap::new())),
event_manager,
delivery_semaphore: Arc::new(Semaphore::new(permits)),
stats: Arc::new(RwLock::new(HashMap::new())),
config,
listeners: TaskTracker::new("webhook listener"),
deliveries: TaskTracker::new("webhook delivery"),
})
}
pub async fn shutdown(&self, grace: Duration) -> usize {
self.listeners.close();
self.listeners.abort_all().await;
let handles: Vec<_> = self.subscriptions.write().await.drain().collect();
for (_, handle) in handles {
if let Err(e) = self.event_manager.unsubscribe(handle.subscription_id).await {
tracing::warn!("Failed to unsubscribe a listener during shutdown: {e}");
}
}
let aborted = self.deliveries.drain(grace).await;
if self.config.log_deliveries {
tracing::info!("Webhook manager shut down ({aborted} deliveries aborted)");
}
aborted
}
pub fn task_panics(&self) -> u64 {
self.listeners.panic_count() + self.deliveries.panic_count()
}
pub fn new_default(event_manager: Arc<EventManager>) -> crate::Result<Self> {
Self::new(event_manager, WebhookManagerConfig::default())
}
pub async fn from_config(
event_manager: Arc<EventManager>,
config: &crate::config::WebhookConfigs,
) -> crate::Result<Self> {
let manager = Self::new(event_manager, (&config.global_settings).into())?;
for webhook in &config.webhooks {
manager.add_webhook(webhook.clone()).await?;
}
Ok(manager)
}
pub async fn add_webhook(&self, webhook: WebhookConfig) -> crate::Result<()> {
webhook.validate()?;
let webhook_id = webhook.id;
let subscription = self.event_manager.subscribe(webhook.filter.clone()).await?;
let (handle, stop) = ListenerHandle::new(subscription.id);
{
let mut webhooks = self.webhooks.write().await;
webhooks.insert(webhook_id, webhook.clone());
}
{
let mut stats = self.stats.write().await;
stats.insert(webhook_id, WebhookStats::empty(webhook_id));
}
let replaced = self.subscriptions.write().await.insert(webhook_id, handle);
if let Some(replaced) = replaced {
self.event_manager
.unsubscribe(replaced.subscription_id)
.await?;
}
self.start_webhook_delivery_task(webhook_id, subscription, stop);
if self.config.log_deliveries {
tracing::info!(
"Added webhook: {} -> {}",
webhook.name,
crate::config::redact_url_path(&webhook.url)
);
}
Ok(())
}
pub async fn remove_webhook(&self, webhook_id: Uuid) -> crate::Result<()> {
{
let mut webhooks = self.webhooks.write().await;
webhooks.remove(&webhook_id);
}
let handle = self.subscriptions.write().await.remove(&webhook_id);
if let Some(handle) = handle {
self.event_manager
.unsubscribe(handle.subscription_id)
.await?;
}
{
let mut stats = self.stats.write().await;
stats.remove(&webhook_id);
}
if self.config.log_deliveries {
tracing::info!("Removed webhook: {}", webhook_id);
}
Ok(())
}
pub async fn get_webhook(&self, webhook_id: Uuid) -> Option<WebhookConfig> {
let webhooks = self.webhooks.read().await;
webhooks.get(&webhook_id).cloned()
}
pub async fn list_webhooks(&self) -> Vec<WebhookConfig> {
let webhooks = self.webhooks.read().await;
webhooks.values().cloned().collect()
}
pub async fn enable_webhook(&self, webhook_id: Uuid) -> crate::Result<()> {
let mut webhooks = self.webhooks.write().await;
if let Some(webhook) = webhooks.get_mut(&webhook_id) {
webhook.enabled = true;
Ok(())
} else {
Err(crate::HammerworkError::Queue {
message: format!("Webhook {} not found", webhook_id),
})
}
}
pub async fn disable_webhook(&self, webhook_id: Uuid) -> crate::Result<()> {
let mut webhooks = self.webhooks.write().await;
if let Some(webhook) = webhooks.get_mut(&webhook_id) {
webhook.enabled = false;
Ok(())
} else {
Err(crate::HammerworkError::Queue {
message: format!("Webhook {} not found", webhook_id),
})
}
}
pub async fn update_webhook(&self, webhook: WebhookConfig) -> crate::Result<()> {
webhook.validate()?;
{
let mut webhooks = self.webhooks.write().await;
if let Some(existing) = webhooks.get_mut(&webhook.id) {
*existing = webhook;
return Ok(());
}
}
self.add_webhook(webhook).await
}
pub async fn get_webhook_stats(&self, webhook_id: Uuid) -> Option<WebhookStats> {
let stats = self.stats.read().await;
stats.get(&webhook_id).cloned()
}
pub async fn get_all_webhook_stats(&self) -> Vec<WebhookStats> {
let stats = self.stats.read().await;
stats.values().cloned().collect()
}
pub async fn get_stats(&self) -> WebhookManagerStats {
let webhooks = self.webhooks.read().await;
let stats = self.stats.read().await;
let total_webhooks = webhooks.len();
let active_webhooks = webhooks.values().filter(|w| w.enabled).count();
let total_attempts = stats.values().map(|s| s.total_attempts).sum();
let failed_attempts = stats.values().map(|s| s.failed_attempts).sum();
let successful_deliveries = stats.values().map(|s| s.successful_deliveries).sum();
let failed_deliveries = stats.values().map(|s| s.failed_deliveries).sum();
WebhookManagerStats {
total_webhooks,
active_webhooks,
total_attempts,
failed_attempts,
successful_deliveries,
failed_deliveries,
}
}
fn start_webhook_delivery_task(
&self,
webhook_id: Uuid,
mut subscription: EventSubscription,
mut stop: oneshot::Receiver<()>,
) {
let webhooks = self.webhooks.clone();
let http_client = self.http_client.clone();
let stats = self.stats.clone();
let delivery_semaphore = self.delivery_semaphore.clone();
let config = self.config.clone();
let deliveries = self.deliveries.clone();
let event_manager = self.event_manager.clone();
let pending = Arc::new(Semaphore::new(clamp_permits(
config.max_pending_deliveries,
"max_pending_deliveries",
)));
self.listeners.spawn("listener", async move {
let mut paused = false;
loop {
let received = tokio::select! {
biased;
_ = &mut stop => break,
received = subscription.receiver.recv() => received,
};
let mut event = match received {
Ok(event) => event,
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(
"Webhook {webhook_id} listener fell behind and dropped {skipped} \
events; the endpoint may be slow or down, or the event buffer \
too small"
);
if let Some(webhook_stats) = stats.write().await.get_mut(&webhook_id) {
webhook_stats.dropped_events += skipped;
}
continue;
}
Err(broadcast::error::RecvError::Closed) => break,
};
let webhook = match webhooks.read().await.get(&webhook_id) {
Some(current) => current.clone(),
None => break,
};
if !webhook.enabled || !webhook.filter.matches(&event) {
continue;
}
if !event_manager.delivers_payload(&webhook.filter) {
event.payload = None;
}
let full = pending.available_permits() == 0;
if full && !paused && config.log_deliveries {
tracing::warn!(
"Webhook {} has {} deliveries pending; pausing its listener",
webhook.name,
config.max_pending_deliveries
);
}
paused = full;
let slot = tokio::select! {
biased;
_ = &mut stop => break,
slot = pending.clone().acquire_owned() => match slot {
Ok(slot) => slot,
Err(_) => break,
},
};
let http_client = http_client.clone();
let stats = stats.clone();
let config = config.clone();
let delivery_semaphore = delivery_semaphore.clone();
deliveries.spawn("delivery", async move {
Self::deliver_webhook_event(
webhook,
event,
http_client,
stats,
config,
delivery_semaphore,
slot,
)
.await;
});
}
});
}
async fn deliver_webhook_event(
webhook: WebhookConfig,
event: JobLifecycleEvent,
http_client: Client,
stats: Arc<RwLock<HashMap<Uuid, WebhookStats>>>,
config: WebhookManagerConfig,
delivery_semaphore: Arc<Semaphore>,
_pending: OwnedSemaphorePermit,
) {
let mut attempt = 0;
let max_attempts = webhook.retry_policy.max_attempts.max(1);
let mut outcome: Option<bool> = None;
while attempt < max_attempts {
attempt += 1;
let delivery_result = {
let Ok(_permit) = delivery_semaphore.acquire().await else {
tracing::error!("webhook delivery semaphore closed");
return;
};
Self::attempt_webhook_delivery(&webhook, &event, &http_client, &config, attempt)
.await
};
Self::update_webhook_stats(&webhook.id, &delivery_result, stats.clone()).await;
outcome = Some(delivery_result.success);
if delivery_result.success {
if config.log_deliveries {
tracing::debug!(
"Webhook delivery successful: {} -> {} (attempt {})",
webhook.name,
crate::config::redact_url_path(&webhook.url),
attempt
);
}
break;
} else if attempt < max_attempts {
let should_retry = delivery_result
.status_code
.map(|code| webhook.retry_policy.should_retry_status(code))
.unwrap_or(true);
if should_retry {
let delay = webhook.retry_policy.calculate_delay(attempt - 1);
if config.log_deliveries {
tracing::warn!(
"Webhook delivery failed, retrying in {:?}: {} -> {} (attempt {}/{})",
delay,
webhook.name,
crate::config::redact_url_path(&webhook.url),
attempt,
max_attempts
);
}
sleep(delay).await;
} else {
if config.log_deliveries {
tracing::error!(
"Webhook delivery failed with non-retryable status {}: {} -> {}",
delivery_result.status_code.unwrap_or(0),
webhook.name,
crate::config::redact_url_path(&webhook.url)
);
}
break;
}
} else if config.log_deliveries {
tracing::error!(
"Webhook delivery failed after {} attempts: {} -> {}",
max_attempts,
webhook.name,
crate::config::redact_url_path(&webhook.url)
);
}
}
if let Some(success) = outcome {
Self::record_delivery_outcome(&webhook.id, success, stats).await;
}
}
async fn attempt_webhook_delivery(
webhook: &WebhookConfig,
event: &JobLifecycleEvent,
http_client: &Client,
config: &WebhookManagerConfig,
attempt_number: u32,
) -> WebhookDelivery {
let delivery_id = Uuid::new_v4();
let start_time = std::time::Instant::now();
let elapsed_ms = || u64::try_from(start_time.elapsed().as_millis()).unwrap_or(u64::MAX);
let failure = |status_code: Option<u16>, error: String, duration_ms: u64| WebhookDelivery {
delivery_id,
webhook_id: webhook.id,
event_id: event.event_id,
status_code,
response_body: None,
error_message: Some(error),
attempted_at: Utc::now(),
duration_ms: Some(duration_ms),
attempt_number,
success: false,
};
let payload = match Self::build_payload(webhook, event) {
Ok(payload) => payload,
Err(e) => return failure(None, format!("failed to build payload: {e}"), 0),
};
let mut request = match webhook.method {
HttpMethod::Post => http_client.post(&webhook.url),
HttpMethod::Put => http_client.put(&webhook.url),
HttpMethod::Patch => http_client.patch(&webhook.url),
};
for (key, value) in &webhook.headers {
request = request.header(key, value);
}
if let Some(ref auth) = webhook.auth {
request = Self::add_auth_headers(request, auth);
}
if let Some(ref secret) = webhook.secret {
let signature = Self::calculate_hmac_signature(secret, &payload);
request = request.header("X-Hammerwork-Signature", signature);
}
request = request
.header("Content-Type", "application/json")
.json(&payload);
let deadline = Instant::now() + attempt_timeout(webhook, config);
let response = match timeout_at(deadline, request.send()).await {
Ok(Ok(response)) => response,
Ok(Err(err)) => return failure(None, err.to_string(), elapsed_ms()),
Err(_) => return failure(Some(408), "Request timeout".to_string(), elapsed_ms()),
};
let status_code = response.status().as_u16();
let success = response.status().is_success();
let response_body = match timeout_at(
deadline,
read_body_limited(response, config.max_response_body_size),
)
.await
{
Ok(Ok(body)) => Some(body),
Ok(Err(e)) => {
tracing::debug!(
"Webhook {}: failed to read response body: {e}",
webhook.name
);
None
}
Err(_) => {
tracing::debug!(
"Webhook {}: timed out reading the response body",
webhook.name
);
None
}
};
WebhookDelivery {
delivery_id,
webhook_id: webhook.id,
event_id: event.event_id,
status_code: Some(status_code),
response_body,
error_message: if success {
None
} else {
Some(format!("HTTP {}", status_code))
},
attempted_at: Utc::now(),
duration_ms: Some(elapsed_ms()),
attempt_number,
success,
}
}
fn build_payload(
webhook: &WebhookConfig,
event: &JobLifecycleEvent,
) -> crate::Result<serde_json::Value> {
match &webhook.payload_template {
Some(template) => PayloadTemplate::parse(template)?.render(event),
None => Ok(serde_json::to_value(event)?),
}
}
fn add_auth_headers(
mut request: reqwest::RequestBuilder,
auth: &WebhookAuth,
) -> reqwest::RequestBuilder {
match auth {
WebhookAuth::Bearer { token } => {
request = request.header("Authorization", format!("Bearer {}", token));
}
WebhookAuth::Basic { username, password } => {
request = request.basic_auth(username, Some(password));
}
WebhookAuth::ApiKey {
header_name,
api_key,
} => {
request = request.header(header_name, api_key);
}
WebhookAuth::Custom { headers } => {
for (key, value) in headers {
request = request.header(key, value);
}
}
}
request
}
fn calculate_hmac_signature(secret: &str, payload: &serde_json::Value) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let payload_bytes = serde_json::to_string(payload).unwrap_or_default();
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(payload_bytes.as_bytes());
let result = mac.finalize();
format!("sha256={}", hex::encode(result.into_bytes()))
}
async fn update_webhook_stats(
webhook_id: &Uuid,
delivery: &WebhookDelivery,
stats: Arc<RwLock<HashMap<Uuid, WebhookStats>>>,
) {
let mut stats_map = stats.write().await;
if let Some(webhook_stats) = stats_map.get_mut(webhook_id) {
webhook_stats.total_attempts += 1;
if delivery.success {
webhook_stats.last_success_at = Some(delivery.attempted_at);
} else {
webhook_stats.failed_attempts += 1;
webhook_stats.last_failure_at = Some(delivery.attempted_at);
}
if let Some(duration) = delivery.duration_ms {
let total_time =
webhook_stats.avg_response_time_ms * (webhook_stats.total_attempts - 1) as f64;
webhook_stats.avg_response_time_ms =
(total_time + duration as f64) / webhook_stats.total_attempts as f64;
}
webhook_stats.calculated_at = Utc::now();
}
}
async fn record_delivery_outcome(
webhook_id: &Uuid,
success: bool,
stats: Arc<RwLock<HashMap<Uuid, WebhookStats>>>,
) {
let mut stats_map = stats.write().await;
if let Some(webhook_stats) = stats_map.get_mut(webhook_id) {
if success {
webhook_stats.successful_deliveries += 1;
} else {
webhook_stats.failed_deliveries += 1;
}
let finished = webhook_stats.successful_deliveries + webhook_stats.failed_deliveries;
webhook_stats.success_rate =
webhook_stats.successful_deliveries as f64 / finished as f64;
webhook_stats.calculated_at = Utc::now();
}
}
}
impl WebhookConfig {
pub fn new(name: String, url: String) -> Self {
Self {
id: Uuid::new_v4(),
name,
url,
method: HttpMethod::Post,
headers: HashMap::new(),
filter: EventFilter::default(),
retry_policy: RetryPolicy::default(),
auth: None,
timeout_secs: 30,
enabled: true,
secret: None,
payload_template: None,
}
}
pub fn with_method(mut self, method: HttpMethod) -> Self {
self.method = method;
self
}
pub fn with_header(mut self, key: String, value: String) -> Self {
self.headers.insert(key, value);
self
}
pub fn with_filter(mut self, filter: EventFilter) -> Self {
self.filter = filter;
self
}
pub fn with_retry_policy(mut self, retry_policy: RetryPolicy) -> Self {
self.retry_policy = retry_policy;
self
}
pub fn with_auth(mut self, auth: WebhookAuth) -> Self {
self.auth = Some(auth);
self
}
pub fn with_timeout_secs(mut self, timeout_secs: u64) -> Self {
self.timeout_secs = timeout_secs;
self
}
pub fn with_secret(mut self, secret: String) -> Self {
self.secret = Some(secret);
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn with_payload_template(mut self, template: impl Into<String>) -> Self {
self.payload_template = Some(template.into());
self
}
pub fn validate(&self) -> crate::Result<()> {
if let Some(template) = &self.payload_template {
PayloadTemplate::parse(template).map_err(|e| {
crate::HammerworkError::Config(format!("webhook '{}': {}", self.name, e))
})?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct PayloadTemplate {
root: serde_json::Value,
}
enum TemplatePart<'a> {
Text(&'a str),
Placeholder(Vec<&'a str>),
}
impl PayloadTemplate {
pub fn parse(template: &str) -> crate::Result<Self> {
let root: serde_json::Value = serde_json::from_str(template).map_err(|e| {
crate::HammerworkError::Config(format!("payload_template is not valid JSON: {e}"))
})?;
Self::validate_value(&root)?;
Ok(Self { root })
}
pub fn render(&self, event: &JobLifecycleEvent) -> crate::Result<serde_json::Value> {
let event_value = serde_json::to_value(event)?;
Ok(Self::render_value(&self.root, &event_value))
}
fn validate_value(value: &serde_json::Value) -> crate::Result<()> {
match value {
serde_json::Value::String(text) => {
for part in Self::split(text)? {
if let TemplatePart::Placeholder(path) = part {
Self::validate_path(&path)?;
}
}
Ok(())
}
serde_json::Value::Array(items) => items.iter().try_for_each(Self::validate_value),
serde_json::Value::Object(map) => {
for (key, value) in map {
if key.contains("{{") {
return Err(crate::HammerworkError::Config(format!(
"payload_template: placeholders are not allowed in object keys \
('{key}')"
)));
}
Self::validate_value(value)?;
}
Ok(())
}
_ => Ok(()),
}
}
fn validate_path(path: &[&str]) -> crate::Result<()> {
let unknown = || {
crate::HammerworkError::Config(format!(
"payload_template: unknown placeholder '{{{{{}}}}}'",
path.join(".")
))
};
if path.iter().any(|segment| segment.is_empty()) {
return Err(unknown());
}
match path {
["event"] => Ok(()),
["event", field, rest @ ..] => match *field {
"event_id" | "job_id" | "queue_name" | "event_type" | "priority" | "timestamp"
| "processing_time_ms"
if rest.is_empty() =>
{
Ok(())
}
"error" => match rest {
[] | ["message" | "error_type" | "details" | "retry_attempt"] => Ok(()),
_ => Err(unknown()),
},
"metadata" if rest.len() <= 1 => Ok(()),
"payload" => Ok(()),
_ => Err(unknown()),
},
_ => Err(unknown()),
}
}
fn split(text: &str) -> crate::Result<Vec<TemplatePart<'_>>> {
let mut parts = Vec::new();
let mut rest = text;
while let Some(start) = rest.find("{{") {
if start > 0 {
parts.push(TemplatePart::Text(&rest[..start]));
}
let after = &rest[start + 2..];
let Some(end) = after.find("}}") else {
return Err(crate::HammerworkError::Config(format!(
"payload_template: unterminated '{{{{' in \"{text}\""
)));
};
let path = after[..end].trim();
parts.push(TemplatePart::Placeholder(
path.split('.').map(str::trim).collect(),
));
rest = &after[end + 2..];
}
if !rest.is_empty() {
parts.push(TemplatePart::Text(rest));
}
Ok(parts)
}
fn render_value(value: &serde_json::Value, event: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(text) => Self::render_string(text, event),
serde_json::Value::Array(items) => serde_json::Value::Array(
items
.iter()
.map(|item| Self::render_value(item, event))
.collect(),
),
serde_json::Value::Object(map) => serde_json::Value::Object(
map.iter()
.map(|(key, value)| (key.clone(), Self::render_value(value, event)))
.collect(),
),
other => other.clone(),
}
}
fn render_string(text: &str, event: &serde_json::Value) -> serde_json::Value {
let Ok(parts) = Self::split(text) else {
return serde_json::Value::String(text.to_string());
};
if let [TemplatePart::Placeholder(path)] = parts.as_slice() {
return Self::lookup(event, path)
.cloned()
.unwrap_or(serde_json::Value::Null);
}
let mut rendered = String::with_capacity(text.len());
for part in parts {
match part {
TemplatePart::Text(text) => rendered.push_str(text),
TemplatePart::Placeholder(path) => match Self::lookup(event, &path) {
None | Some(serde_json::Value::Null) => {}
Some(serde_json::Value::String(s)) => rendered.push_str(s),
Some(other) => rendered.push_str(&other.to_string()),
},
}
}
serde_json::Value::String(rendered)
}
fn lookup<'v>(event: &'v serde_json::Value, path: &[&str]) -> Option<&'v serde_json::Value> {
let mut current = event;
for segment in path.get(1..)? {
current = match current {
serde_json::Value::Object(map) => map.get(*segment)?,
serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(current)
}
}
pub fn generate_hmac_signature(secret: &str, payload: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(payload);
let result = mac.finalize();
hex::encode(result.into_bytes())
}
pub fn verify_hmac_signature(secret: &str, payload: &[u8], signature: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let signature = signature.trim();
let hex_signature = signature.strip_prefix("sha256=").unwrap_or(signature);
let Ok(signature_bytes) = hex::decode(hex_signature) else {
return false;
};
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(payload);
mac.verify_slice(&signature_bytes).is_ok()
}
with_setters! {
RetryPolicy {
with_max_attempts(max_attempts): u32,
with_initial_delay_secs(initial_delay_secs): u64,
with_max_delay_secs(max_delay_secs): u64,
with_backoff_multiplier(backoff_multiplier): f64,
with_retry_on_status_codes(retry_on_status_codes): Vec<u16>,
}
}
with_setters! {
WebhookManagerConfig {
with_max_concurrent_deliveries(max_concurrent_deliveries): usize,
with_max_response_body_size(max_response_body_size): usize,
with_log_deliveries(log_deliveries): bool,
with_default_timeout_secs(default_timeout_secs): u64,
with_max_pending_deliveries(max_pending_deliveries): usize,
with_user_agent(user_agent): Into(String),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_hmac_signature_accepts_the_delivered_signature_header() {
let secret = "webhook-secret";
let payload = serde_json::json!({"event": "job.completed", "job_id": "123"});
let body = serde_json::to_vec(&payload).unwrap();
let header = WebhookManager::calculate_hmac_signature(secret, &payload);
assert!(header.starts_with("sha256="));
assert!(verify_hmac_signature(secret, &body, &header));
assert!(verify_hmac_signature(
secret,
&body,
&generate_hmac_signature(secret, &body)
));
assert!(!verify_hmac_signature("other", &body, &header));
assert!(!verify_hmac_signature(secret, b"{}", &header));
assert!(!verify_hmac_signature(secret, &body, "sha256=zz"));
assert!(!verify_hmac_signature(secret, &body, ""));
assert!(!verify_hmac_signature(secret, &body, "sha256="));
}
#[test]
fn test_debug_does_not_print_webhook_secrets() {
let mut config = WebhookConfig::new(
"chat".to_string(),
"https://hooks.slack.com/services/T0/B0/url-hunter2".to_string(),
)
.with_secret("sign-hunter2".to_string());
config
.headers
.insert("X-Api-Token".to_string(), "header-hunter2".to_string());
for auth in [
WebhookAuth::Bearer {
token: "bearer-hunter2".to_string(),
},
WebhookAuth::Basic {
username: "svc".to_string(),
password: "basic-hunter2".to_string(),
},
WebhookAuth::ApiKey {
header_name: "X-Key".to_string(),
api_key: "api-hunter2".to_string(),
},
WebhookAuth::Custom {
headers: HashMap::from([("X-Custom".to_string(), "custom-hunter2".to_string())]),
},
] {
config.auth = Some(auth);
let debug = format!("{:?}", config);
assert!(!debug.contains("hunter2"), "{debug}");
assert!(debug.contains("https://hooks.slack.com/***"), "{debug}");
assert!(debug.contains("X-Api-Token"), "{debug}");
}
}
#[tokio::test]
async fn test_webhook_manager_from_config() {
let mut config = crate::config::WebhookConfigs::default();
config.global_settings.max_concurrent_deliveries = 7;
config.global_settings.user_agent = "custom-agent".to_string();
config.webhooks.push(WebhookConfig::new(
"hook".to_string(),
"https://example.com/webhook".to_string(),
));
let manager_config = WebhookManagerConfig::from(&config.global_settings);
assert_eq!(manager_config.max_concurrent_deliveries, 7);
assert_eq!(manager_config.user_agent, "custom-agent");
let manager = WebhookManager::from_config(Arc::new(EventManager::new_default()), &config)
.await
.unwrap();
assert_eq!(manager.list_webhooks().await.len(), 1);
}
#[test]
fn test_webhook_config_creation() {
let webhook = WebhookConfig::new(
"test-webhook".to_string(),
"https://example.com/webhook".to_string(),
)
.with_method(HttpMethod::Post)
.with_header("X-Custom-Header".to_string(), "custom-value".to_string())
.with_timeout_secs(60)
.with_secret("secret-key".to_string());
assert_eq!(webhook.name, "test-webhook");
assert_eq!(webhook.url, "https://example.com/webhook");
assert_eq!(webhook.method, HttpMethod::Post);
assert_eq!(webhook.timeout_secs, 60);
assert_eq!(webhook.secret, Some("secret-key".to_string()));
assert!(webhook.enabled);
assert_eq!(
webhook.headers.get("X-Custom-Header"),
Some(&"custom-value".to_string())
);
}
#[test]
fn test_retry_policy_delay_calculation() {
let policy = RetryPolicy {
initial_delay_secs: 2,
backoff_multiplier: 2.0,
max_delay_secs: 60,
..Default::default()
};
assert_eq!(policy.calculate_delay(0), Duration::from_secs(2));
assert_eq!(policy.calculate_delay(1), Duration::from_secs(4));
assert_eq!(policy.calculate_delay(2), Duration::from_secs(8));
assert_eq!(policy.calculate_delay(10), Duration::from_secs(60)); }
#[test]
fn test_retry_policy_status_codes() {
let policy = RetryPolicy::default();
assert!(policy.should_retry_status(500));
assert!(policy.should_retry_status(502));
assert!(policy.should_retry_status(429));
assert!(!policy.should_retry_status(200));
assert!(!policy.should_retry_status(400));
assert!(!policy.should_retry_status(404));
}
#[test]
fn test_webhook_auth_types() {
let bearer = WebhookAuth::Bearer {
token: "token123".to_string(),
};
let basic = WebhookAuth::Basic {
username: "user".to_string(),
password: "pass".to_string(),
};
let api_key = WebhookAuth::ApiKey {
header_name: "X-API-Key".to_string(),
api_key: "key123".to_string(),
};
match bearer {
WebhookAuth::Bearer { token } => assert_eq!(token, "token123"),
_ => panic!("Wrong auth type"),
}
match basic {
WebhookAuth::Basic { username, password } => {
assert_eq!(username, "user");
assert_eq!(password, "pass");
}
_ => panic!("Wrong auth type"),
}
match api_key {
WebhookAuth::ApiKey {
header_name,
api_key,
} => {
assert_eq!(header_name, "X-API-Key");
assert_eq!(api_key, "key123");
}
_ => panic!("Wrong auth type"),
}
}
#[test]
fn test_http_method_display() {
assert_eq!(HttpMethod::Post.to_string(), "POST");
assert_eq!(HttpMethod::Put.to_string(), "PUT");
assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
}
#[test]
fn test_webhook_delivery_structure() {
let delivery = WebhookDelivery {
delivery_id: Uuid::new_v4(),
webhook_id: Uuid::new_v4(),
event_id: Uuid::new_v4(),
status_code: Some(200),
response_body: Some("OK".to_string()),
error_message: None,
attempted_at: Utc::now(),
duration_ms: Some(150),
attempt_number: 1,
success: true,
};
assert!(delivery.success);
assert_eq!(delivery.status_code, Some(200));
assert_eq!(delivery.attempt_number, 1);
assert!(delivery.error_message.is_none());
}
#[test]
fn test_webhook_stats_calculation() {
let mut stats = WebhookStats::empty(Uuid::new_v4());
stats.total_attempts = 1;
stats.successful_deliveries = 1;
stats.success_rate = 1.0;
stats.avg_response_time_ms = 100.0;
assert_eq!(stats.success_rate, 1.0);
assert_eq!(stats.failed_deliveries, 0);
stats.total_attempts = 2;
stats.failed_deliveries = 1;
stats.success_rate = 0.5;
assert_eq!(stats.success_rate, 0.5);
assert_eq!(stats.total_attempts, 2);
}
#[test]
fn test_clamp_delivery_permits() {
assert_eq!(clamp_delivery_permits(0), 1);
assert_eq!(clamp_delivery_permits(7), 7);
assert_eq!(clamp_delivery_permits(usize::MAX), Semaphore::MAX_PERMITS);
}
#[tokio::test]
async fn test_new_with_extreme_concurrency_does_not_panic() {
for value in [0, usize::MAX] {
let config = WebhookManagerConfig {
max_concurrent_deliveries: value,
..Default::default()
};
let manager =
WebhookManager::new(Arc::new(EventManager::new_default()), config).unwrap();
let permits = manager.delivery_semaphore.available_permits();
assert!((1..=Semaphore::MAX_PERMITS).contains(&permits));
}
}
#[test]
fn test_new_rejects_invalid_user_agent() {
let config = WebhookManagerConfig {
user_agent: "bad\nagent".to_string(),
..Default::default()
};
let result = WebhookManager::new(Arc::new(EventManager::new_default()), config);
assert!(matches!(
result,
Err(crate::HammerworkError::Webhook { .. })
));
}
#[test]
fn test_retry_policy_huge_attempt_does_not_panic() {
let policy = RetryPolicy::default();
assert_eq!(
policy.calculate_delay(u32::MAX),
Duration::from_secs(policy.max_delay_secs)
);
}
#[tokio::test]
async fn test_webhook_manager_creation() {
let config = WebhookManagerConfig::default();
let event_manager = Arc::new(EventManager::new_default());
let manager = WebhookManager::new(event_manager, config).unwrap();
let stats = manager.get_stats().await;
assert_eq!(stats.total_webhooks, 0);
assert_eq!(stats.active_webhooks, 0);
}
#[tokio::test]
async fn test_webhook_manager_add_remove_webhook() {
let config = WebhookManagerConfig::default();
let event_manager = Arc::new(EventManager::new_default());
let manager = WebhookManager::new(event_manager, config).unwrap();
let webhook_config = WebhookConfig {
id: Uuid::new_v4(),
name: "test-webhook".to_string(),
url: "https://api.example.com/webhook".to_string(),
method: HttpMethod::Post,
headers: HashMap::new(),
auth: None,
secret: Some("test-secret".to_string()),
filter: EventFilter::new(),
retry_policy: RetryPolicy::default(),
enabled: true,
timeout_secs: 30,
payload_template: None,
};
let webhook_id = webhook_config.id;
manager.add_webhook(webhook_config).await.unwrap();
let stats = manager.get_stats().await;
assert_eq!(stats.total_webhooks, 1);
assert_eq!(stats.active_webhooks, 1);
manager.remove_webhook(webhook_id).await.unwrap();
let stats = manager.get_stats().await;
assert_eq!(stats.total_webhooks, 0);
assert_eq!(stats.active_webhooks, 0);
}
#[tokio::test]
async fn test_webhook_manager_enable_disable() {
let config = WebhookManagerConfig::default();
let event_manager = Arc::new(EventManager::new_default());
let manager = WebhookManager::new(event_manager, config).unwrap();
let webhook_config = WebhookConfig {
id: Uuid::new_v4(),
name: "test-webhook".to_string(),
url: "https://api.example.com/webhook".to_string(),
method: HttpMethod::Post,
headers: HashMap::new(),
auth: None,
secret: None,
filter: EventFilter::new(),
retry_policy: RetryPolicy::default(),
enabled: true,
timeout_secs: 30,
payload_template: None,
};
let webhook_id = webhook_config.id;
manager.add_webhook(webhook_config).await.unwrap();
let stats = manager.get_stats().await;
assert_eq!(stats.active_webhooks, 1);
manager.disable_webhook(webhook_id).await.unwrap();
let stats = manager.get_stats().await;
assert_eq!(stats.active_webhooks, 0);
assert_eq!(stats.total_webhooks, 1);
manager.enable_webhook(webhook_id).await.unwrap();
let stats = manager.get_stats().await;
assert_eq!(stats.active_webhooks, 1);
}
#[test]
fn test_hmac_signature_generation() {
let secret = "test-secret";
let payload = b"test payload";
let signature1 = generate_hmac_signature(secret, payload);
let signature2 = generate_hmac_signature(secret, payload);
assert_eq!(signature1, signature2);
let different_payload = b"different payload";
let signature3 = generate_hmac_signature(secret, different_payload);
assert_ne!(signature1, signature3);
assert!(signature1.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_hmac_signature_verification() {
let secret = "test-secret";
let payload = b"test payload";
let signature = generate_hmac_signature(secret, payload);
assert!(verify_hmac_signature(secret, payload, &signature));
assert!(!verify_hmac_signature(secret, payload, "wrong-signature"));
assert!(!verify_hmac_signature("wrong-secret", payload, &signature));
assert!(!verify_hmac_signature(secret, b"wrong payload", &signature));
}
#[test]
fn test_webhook_config_serialization() {
let webhook_config = WebhookConfig {
id: Uuid::new_v4(),
name: "test-webhook".to_string(),
url: "https://api.example.com/webhook".to_string(),
method: HttpMethod::Post,
headers: {
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
headers
},
auth: Some(WebhookAuth::Bearer {
token: "token123".to_string(),
}),
secret: Some("webhook-secret".to_string()),
filter: EventFilter::new()
.with_event_types(vec![crate::events::JobLifecycleEventType::Completed]),
retry_policy: RetryPolicy {
max_attempts: 5,
initial_delay_secs: 1,
max_delay_secs: 300,
backoff_multiplier: 2.0,
retry_on_status_codes: vec![500, 502, 503],
},
enabled: true,
timeout_secs: 60,
payload_template: Some(r#"{"job": "{{event.job_id}}"}"#.to_string()),
};
let serialized = serde_json::to_string(&webhook_config).unwrap();
let deserialized: WebhookConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.name, webhook_config.name);
assert_eq!(deserialized.url, webhook_config.url);
assert_eq!(deserialized.enabled, webhook_config.enabled);
assert_eq!(deserialized.timeout_secs, webhook_config.timeout_secs);
assert!(deserialized.auth.is_some());
assert!(deserialized.secret.is_some());
}
#[test]
fn test_webhook_manager_config_defaults() {
let config = WebhookManagerConfig::default();
assert_eq!(config.max_concurrent_deliveries, 100);
assert_eq!(config.default_timeout_secs, 30);
assert!(config.log_deliveries);
assert_eq!(config.max_response_body_size, 64 * 1024);
assert!(config.user_agent.contains("hammerwork-webhooks"));
}
#[test]
fn test_webhook_manager_config_customization() {
let config = WebhookManagerConfig {
max_concurrent_deliveries: 50,
default_timeout_secs: 60,
log_deliveries: false,
..Default::default()
};
assert_eq!(config.max_concurrent_deliveries, 50);
assert_eq!(config.default_timeout_secs, 60);
assert!(!config.log_deliveries);
}
#[tokio::test]
async fn test_webhook_delivery_timeout() {
let delivery = WebhookDelivery {
delivery_id: Uuid::new_v4(),
webhook_id: Uuid::new_v4(),
event_id: Uuid::new_v4(),
status_code: None,
response_body: None,
error_message: Some("Request timeout".to_string()),
attempted_at: Utc::now(),
duration_ms: Some(30000), attempt_number: 1,
success: false,
};
assert!(!delivery.success);
assert!(delivery.error_message.is_some());
assert_eq!(delivery.duration_ms, Some(30000));
assert!(delivery.status_code.is_none());
}
#[test]
fn test_webhook_filter_integration() {
let filter = EventFilter::new()
.with_event_types(vec![crate::events::JobLifecycleEventType::Completed])
.with_queue_names(vec!["important".to_string()])
.with_priorities(vec![crate::priority::JobPriority::High]);
let matching_event = crate::events::JobLifecycleEvent {
event_id: Uuid::new_v4(),
job_id: Uuid::new_v4(),
queue_name: "important".to_string(),
event_type: crate::events::JobLifecycleEventType::Completed,
priority: crate::priority::JobPriority::High,
timestamp: Utc::now(),
processing_time_ms: Some(1000),
error: None,
payload: None,
metadata: HashMap::new(),
};
let non_matching_event = crate::events::JobLifecycleEvent {
event_id: Uuid::new_v4(),
job_id: Uuid::new_v4(),
queue_name: "unimportant".to_string(),
event_type: crate::events::JobLifecycleEventType::Failed,
priority: crate::priority::JobPriority::Low,
timestamp: Utc::now(),
processing_time_ms: None,
error: None,
payload: None,
metadata: HashMap::new(),
};
assert!(filter.matches(&matching_event));
assert!(!filter.matches(&non_matching_event));
}
#[test]
fn test_retry_policy_edge_cases() {
let policy = RetryPolicy {
max_attempts: 3,
initial_delay_secs: 1,
max_delay_secs: 10,
backoff_multiplier: 3.0,
retry_on_status_codes: vec![500, 502],
};
assert_eq!(policy.calculate_delay(0), Duration::from_secs(1));
assert_eq!(policy.calculate_delay(1), Duration::from_secs(3));
assert_eq!(policy.calculate_delay(2), Duration::from_secs(9));
assert_eq!(policy.calculate_delay(3), Duration::from_secs(10));
assert!(policy.should_retry_status(500));
assert!(policy.should_retry_status(502));
assert!(!policy.should_retry_status(503)); assert!(!policy.should_retry_status(200)); assert!(!policy.should_retry_status(400)); }
#[test]
fn test_webhook_delivery_serialization() {
let delivery = WebhookDelivery {
delivery_id: Uuid::new_v4(),
webhook_id: Uuid::new_v4(),
event_id: Uuid::new_v4(),
status_code: Some(201),
response_body: Some("{\"status\":\"success\"}".to_string()),
error_message: None,
attempted_at: Utc::now(),
duration_ms: Some(250),
attempt_number: 2,
success: true,
};
let serialized = serde_json::to_string(&delivery).unwrap();
let deserialized: WebhookDelivery = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.delivery_id, delivery.delivery_id);
assert_eq!(deserialized.status_code, delivery.status_code);
assert_eq!(deserialized.success, delivery.success);
assert_eq!(deserialized.attempt_number, delivery.attempt_number);
}
#[tokio::test]
async fn test_webhook_stats_aggregation() {
let webhook_id = Uuid::new_v4();
let mut stats = WebhookStats {
webhook_id,
total_attempts: 10,
failed_attempts: 3,
successful_deliveries: 7,
failed_deliveries: 3,
success_rate: 0.7,
avg_response_time_ms: 150.5,
last_success_at: Some(Utc::now() - chrono::Duration::minutes(5)),
last_failure_at: Some(Utc::now() - chrono::Duration::minutes(10)),
dropped_events: 0,
calculated_at: Utc::now(),
};
assert_eq!(
stats.total_attempts,
stats.successful_deliveries + stats.failed_deliveries
);
assert!((stats.success_rate - 0.7).abs() < f64::EPSILON);
assert!(stats.last_success_at.is_some());
assert!(stats.last_failure_at.is_some());
stats.total_attempts += 1;
stats.successful_deliveries += 1;
stats.success_rate = stats.successful_deliveries as f64 / stats.total_attempts as f64;
assert_eq!(stats.total_attempts, 11);
assert_eq!(stats.successful_deliveries, 8);
assert!((stats.success_rate - 8.0 / 11.0).abs() < f64::EPSILON);
}
#[test]
fn test_response_body_text_respects_utf8_boundaries() {
let cut = |body: &str, max: usize| {
let bytes = body.as_bytes();
let truncated = bytes.len() > max;
response_body_text(bytes[..max.min(bytes.len())].to_vec(), truncated)
};
assert_eq!(cut("ok", 10), "ok");
let body = "aé😀b"; assert_eq!(cut(body, 2), "a... [truncated]");
assert_eq!(cut(body, 3), "aé... [truncated]");
for max in 0..body.len() {
let truncated = cut(body, max);
assert!(truncated.ends_with("... [truncated]"));
assert!(truncated.len() <= max + "... [truncated]".len());
}
assert_eq!(cut(body, body.len()), body);
assert_eq!(
response_body_text(vec![b'a', 0xff, b'b'], false),
"a\u{fffd}b"
);
}
fn completed_event() -> JobLifecycleEvent {
JobLifecycleEvent {
event_id: Uuid::new_v4(),
job_id: Uuid::new_v4(),
queue_name: "shutdown_queue".to_string(),
event_type: crate::events::JobLifecycleEventType::Completed,
priority: crate::priority::JobPriority::Normal,
timestamp: Utc::now(),
processing_time_ms: Some(1),
error: None,
payload: None,
metadata: HashMap::new(),
}
}
async fn slow_http_server(delay: Duration) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/hook", listener.local_addr().unwrap());
let received = Arc::new(AtomicUsize::new(0));
let counter = received.clone();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
let counter = counter.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 16 * 1024];
let _ = socket.read(&mut buf).await;
counter.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(delay).await;
let _ = socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await;
});
}
});
(url, received)
}
async fn wait_for(received: &std::sync::atomic::AtomicUsize, count: usize) {
for _ in 0..200 {
if received.load(std::sync::atomic::Ordering::SeqCst) >= count {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("server did not receive {count} request(s)");
}
#[tokio::test]
async fn test_shutdown_waits_for_in_flight_delivery() {
let (url, received) = slow_http_server(Duration::from_millis(300)).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook = WebhookConfig::new("slow".to_string(), url);
let webhook_id = webhook.id;
manager.add_webhook(webhook).await.unwrap();
events.publish_event(completed_event()).await.unwrap();
wait_for(&received, 1).await;
assert_eq!(manager.shutdown(Duration::from_secs(10)).await, 0);
let stats = manager.get_webhook_stats(webhook_id).await.unwrap();
assert_eq!(stats.successful_deliveries, 1);
assert_eq!(manager.task_panics(), 0);
}
#[tokio::test]
async fn test_shutdown_aborts_deliveries_after_grace_period() {
let (url, received) = slow_http_server(Duration::from_secs(30)).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
manager
.add_webhook(WebhookConfig::new("stuck".to_string(), url))
.await
.unwrap();
events.publish_event(completed_event()).await.unwrap();
wait_for(&received, 1).await;
let started = std::time::Instant::now();
assert_eq!(manager.shutdown(Duration::from_millis(100)).await, 1);
assert!(started.elapsed() < Duration::from_secs(5));
}
#[tokio::test]
async fn test_no_deliveries_after_shutdown() {
let (url, received) = slow_http_server(Duration::ZERO).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
manager
.add_webhook(WebhookConfig::new("late".to_string(), url))
.await
.unwrap();
manager.shutdown(Duration::from_secs(1)).await;
events.publish_event(completed_event()).await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(received.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[tokio::test]
async fn test_listener_delivers_every_event_in_a_burst() {
let (url, received) = slow_http_server(Duration::ZERO).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
manager
.add_webhook(WebhookConfig::new("burst".to_string(), url))
.await
.unwrap();
for _ in 0..100 {
events.publish_event(completed_event()).await.unwrap();
}
wait_for(&received, 100).await;
manager.shutdown(Duration::from_secs(5)).await;
assert_eq!(received.load(std::sync::atomic::Ordering::SeqCst), 100);
}
#[tokio::test]
async fn test_listener_keeps_delivering_after_falling_behind() {
let (url, received) = slow_http_server(Duration::ZERO).await;
let events = Arc::new(EventManager::new(crate::events::EventConfig {
max_buffer_size: 4,
..Default::default()
}));
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook = WebhookConfig::new("lagging".to_string(), url);
let id = webhook.id;
manager.add_webhook(webhook).await.unwrap();
for _ in 0..50 {
events.publish_event(completed_event()).await.unwrap();
}
let mut accounted = false;
for _ in 0..400 {
let dropped = manager.get_webhook_stats(id).await.unwrap().dropped_events;
let delivered = received.load(std::sync::atomic::Ordering::SeqCst) as u64;
if dropped > 0 && dropped + delivered == 50 {
accounted = true;
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(accounted, "dropped events are counted");
let before = received.load(std::sync::atomic::Ordering::SeqCst);
assert!(before >= 1, "some events from the burst must be delivered");
events.publish_event(completed_event()).await.unwrap();
wait_for(&received, before + 1).await;
manager.shutdown(Duration::from_secs(5)).await;
}
#[tokio::test]
async fn test_panicking_background_task_is_observed() {
let manager = WebhookManager::new_default(Arc::new(EventManager::new_default())).unwrap();
assert_eq!(manager.task_panics(), 0);
manager.deliveries.spawn("delivery", async {
panic!("expected test panic in a delivery task");
});
manager.listeners.spawn("listener", async {
panic!("expected test panic in a listener task");
});
for _ in 0..200 {
if manager.task_panics() == 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
manager.shutdown(Duration::from_secs(5)).await;
assert_eq!(manager.task_panics(), 2);
}
fn template_event() -> JobLifecycleEvent {
let mut metadata = HashMap::new();
metadata.insert("region".to_string(), "eu-west-1".to_string());
JobLifecycleEvent {
event_id: Uuid::new_v4(),
job_id: Uuid::new_v4(),
queue_name: "emails".to_string(),
event_type: crate::events::JobLifecycleEventType::Failed,
priority: crate::priority::JobPriority::High,
timestamp: Utc::now(),
processing_time_ms: Some(42),
error: Some(crate::events::JobError {
message: "SMTP timeout".to_string(),
error_type: None,
details: None,
retry_attempt: Some(2),
}),
payload: Some(serde_json::json!({
"user": {"id": 7, "tags": ["a", "b"]},
"note": "said \"hi\""
})),
metadata,
}
}
#[test]
fn test_payload_template_renders_values_with_their_types() {
let event = template_event();
let template = PayloadTemplate::parse(
r#"{
"job": "{{ event.job_id }}",
"queue": "{{event.queue_name}}",
"took": "{{event.processing_time_ms}}",
"attempt": "{{event.error.retry_attempt}}",
"user": "{{event.payload.user}}",
"second_tag": "{{event.payload.user.tags.1}}",
"region": "{{event.metadata.region}}",
"missing": "{{event.payload.nope}}",
"details": "{{event.error.details}}",
"constant": [1, true, null, "plain"],
"whole": "{{event}}"
}"#,
)
.unwrap();
let rendered = template.render(&event).unwrap();
assert_eq!(rendered["job"], event.job_id.to_string());
assert_eq!(rendered["queue"], "emails");
assert_eq!(rendered["took"], 42);
assert_eq!(rendered["attempt"], 2);
assert_eq!(
rendered["user"],
serde_json::json!({"id": 7, "tags": ["a", "b"]})
);
assert_eq!(rendered["second_tag"], "b");
assert_eq!(rendered["region"], "eu-west-1");
assert_eq!(rendered["missing"], serde_json::Value::Null);
assert_eq!(rendered["details"], serde_json::Value::Null);
assert_eq!(
rendered["constant"],
serde_json::json!([1, true, null, "plain"])
);
assert_eq!(rendered["whole"], serde_json::to_value(&event).unwrap());
}
#[test]
fn test_payload_template_interpolates_text() {
let event = template_event();
let template = PayloadTemplate::parse(
r#"{"text": "Job {{event.job_id}} in {{event.queue_name}} is {{event.event_type}} after {{event.processing_time_ms}}ms ({{event.payload.user.tags}}){{event.payload.nope}}"}"#,
)
.unwrap();
let rendered = template.render(&event).unwrap();
assert_eq!(
rendered["text"],
format!(
"Job {} in emails is failed after 42ms ([\"a\",\"b\"])",
event.job_id
)
);
}
#[test]
fn test_payload_template_escapes_event_data() {
let mut event = template_event();
event.queue_name = r#"q", "injected": true, "x": "\"#.to_string();
let template = PayloadTemplate::parse(
r#"{"queue": "{{event.queue_name}}", "text": "queue={{event.queue_name}} note={{event.payload.note}}"}"#,
)
.unwrap();
let rendered = template.render(&event).unwrap();
let body = serde_json::to_string(&rendered).unwrap();
let reparsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(reparsed, rendered);
let object = reparsed.as_object().unwrap();
assert_eq!(object.len(), 2);
assert!(!object.contains_key("injected"));
assert_eq!(object["queue"], event.queue_name.as_str());
assert_eq!(
object["text"],
format!("queue={} note=said \"hi\"", event.queue_name)
);
}
#[test]
fn test_payload_template_rejects_invalid_templates() {
let invalid = [
("not json", "not valid JSON"),
(
r#"{"a": "{{event.nope}}"}"#,
"unknown placeholder '{{event.nope}}'",
),
(r#"{"a": "{{job.id}}"}"#, "unknown placeholder"),
(r#"{"a": "{{event.queue_name.x}}"}"#, "unknown placeholder"),
(r#"{"a": "{{event.error.stack}}"}"#, "unknown placeholder"),
(r#"{"a": "{{event.metadata.a.b}}"}"#, "unknown placeholder"),
(r#"{"a": "{{event.}}"}"#, "unknown placeholder"),
(r#"{"a": "{{}}"}"#, "unknown placeholder"),
(r#"{"a": ["ok", "{{event.job_id"]}"#, "unterminated"),
(r#"{"{{event.job_id}}": 1}"#, "object keys"),
];
for (template, expected) in invalid {
match PayloadTemplate::parse(template) {
Err(crate::HammerworkError::Config(message)) => {
assert!(message.contains(expected), "{template}: {message}");
}
other => panic!("{template}: expected a config error, got {other:?}"),
}
}
assert!(PayloadTemplate::parse(r#"{"static": [1, 2, 3]}"#).is_ok());
assert!(PayloadTemplate::parse(r#""{{event}}""#).is_ok());
}
#[tokio::test]
async fn test_add_webhook_rejects_invalid_template() {
let manager = WebhookManager::new_default(Arc::new(EventManager::new_default())).unwrap();
let webhook = WebhookConfig::new("bad".to_string(), "http://127.0.0.1:1/".to_string())
.with_payload_template(r#"{"a": "{{event.unknown}}"}"#);
match manager.add_webhook(webhook).await {
Err(crate::HammerworkError::Config(message)) => {
assert!(message.contains("webhook 'bad'"), "{message}");
assert!(message.contains("unknown placeholder"), "{message}");
}
other => panic!("expected a config error, got {other:?}"),
}
assert!(manager.list_webhooks().await.is_empty());
let good = WebhookConfig::new("good".to_string(), "http://127.0.0.1:1/".to_string());
let id = good.id;
manager.add_webhook(good.clone()).await.unwrap();
let mut bad_update = good.with_payload_template("{");
bad_update.id = id;
assert!(manager.update_webhook(bad_update).await.is_err());
let current = manager.get_webhook(id).await.unwrap();
assert!(current.payload_template.is_none());
manager.shutdown(Duration::from_secs(1)).await;
}
async fn capturing_http_server() -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/hook", listener.local_addr().unwrap());
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
let tx = tx.clone();
tokio::spawn(async move {
let mut data = Vec::new();
let mut buf = [0u8; 4096];
loop {
let Ok(n) = socket.read(&mut buf).await else {
return;
};
if n == 0 {
return;
}
data.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&data).to_string();
let Some(header_end) = text.find("\r\n\r\n") else {
continue;
};
let length = text[..header_end]
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())?
})
.unwrap_or(0);
if data.len() >= header_end + 4 + length {
let body = String::from_utf8_lossy(
&data[header_end + 4..header_end + 4 + length],
)
.to_string();
let _ = tx.send(body);
let _ = socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await;
return;
}
}
});
}
});
(url, rx)
}
#[tokio::test]
async fn test_delivery_sends_rendered_template() {
let (url, mut bodies) = capturing_http_server().await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook = WebhookConfig::new("templated".to_string(), url).with_payload_template(
r#"{"text": "{{event.queue_name}} job {{event.job_id}}", "job_id": "{{event.job_id}}"}"#,
);
manager.add_webhook(webhook).await.unwrap();
let event = completed_event();
events.publish_event(event.clone()).await.unwrap();
let body = tokio::time::timeout(Duration::from_secs(10), bodies.recv())
.await
.expect("webhook was not delivered")
.unwrap();
manager.shutdown(Duration::from_secs(5)).await;
let body: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
body,
serde_json::json!({
"text": format!("shutdown_queue job {}", event.job_id),
"job_id": event.job_id.to_string(),
})
);
}
#[derive(Debug)]
struct Recorded {
request_line: String,
headers: HashMap<String, String>,
body: String,
}
async fn recording_http_server(
status: u16,
delay: Duration,
) -> (String, tokio::sync::mpsc::UnboundedReceiver<Recorded>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/hook", listener.local_addr().unwrap());
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let tx = tx.clone();
tokio::spawn(async move {
let mut buffer = Vec::new();
let mut chunk = [0u8; 4096];
let (head, body) = loop {
if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buffer[..end]).to_string();
let length: usize = head
.lines()
.find_map(|l| {
l.to_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().to_string())
})
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if buffer.len() >= end + 4 + length {
break (head, buffer[end + 4..end + 4 + length].to_vec());
}
}
match socket.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(n) => buffer.extend_from_slice(&chunk[..n]),
}
};
let mut lines = head.lines();
let request_line = lines.next().unwrap_or_default().to_string();
let headers = lines
.filter_map(|l| l.split_once(':'))
.map(|(k, v)| (k.trim().to_lowercase(), v.trim().to_string()))
.collect();
let _ = tx.send(Recorded {
request_line,
headers,
body: String::from_utf8_lossy(&body).to_string(),
});
tokio::time::sleep(delay).await;
let response = format!(
"HTTP/1.1 {status} X\r\ncontent-length: 4\r\nconnection: close\r\n\r\nnope"
);
let _ = socket.write_all(response.as_bytes()).await;
});
}
});
(url, rx)
}
async fn next_recorded(rx: &mut tokio::sync::mpsc::UnboundedReceiver<Recorded>) -> Recorded {
tokio::time::timeout(Duration::from_secs(10), rx.recv())
.await
.expect("no request received")
.unwrap()
}
fn no_retries() -> RetryPolicy {
RetryPolicy {
max_attempts: 1,
..RetryPolicy::default()
}
}
async fn deliver_one(
webhook: WebhookConfig,
url_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Recorded>,
) -> Recorded {
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
manager.add_webhook(webhook).await.unwrap();
events.publish_event(completed_event()).await.unwrap();
let recorded = next_recorded(url_rx).await;
manager.shutdown(Duration::from_secs(5)).await;
recorded
}
#[tokio::test]
async fn a_zero_max_attempts_still_delivers_once() {
let (url, mut rx) = recording_http_server(200, Duration::ZERO).await;
let webhook = WebhookConfig::new("zero".to_string(), url).with_retry_policy(RetryPolicy {
max_attempts: 0,
..RetryPolicy::default()
});
let recorded = deliver_one(webhook, &mut rx).await;
assert!(recorded.request_line.starts_with("POST"));
}
#[tokio::test]
async fn test_auth_methods_and_signature() {
let (url, mut rx) = recording_http_server(200, Duration::ZERO).await;
let cases = [
(
WebhookAuth::Bearer {
token: "t0ken".to_string(),
},
"authorization",
"Bearer t0ken".to_string(),
),
(
WebhookAuth::Basic {
username: "user".to_string(),
password: "pass".to_string(),
},
"authorization",
"Basic dXNlcjpwYXNz".to_string(),
),
(
WebhookAuth::ApiKey {
header_name: "X-Api-Key".to_string(),
api_key: "k123".to_string(),
},
"x-api-key",
"k123".to_string(),
),
(
WebhookAuth::Custom {
headers: HashMap::from([("X-Custom".to_string(), "c".to_string())]),
},
"x-custom",
"c".to_string(),
),
];
for (auth, header, expected) in cases {
let webhook = WebhookConfig::new("auth".to_string(), url.clone())
.with_auth(auth)
.with_retry_policy(no_retries());
let recorded = deliver_one(webhook, &mut rx).await;
assert_eq!(
recorded.headers.get(header),
Some(&expected),
"{recorded:?}"
);
}
let webhook = WebhookConfig::new("signed".to_string(), url.clone())
.with_method(HttpMethod::Put)
.with_header("X-Team".to_string(), "queues".to_string())
.with_secret("s3cret".to_string())
.with_retry_policy(no_retries());
let recorded = deliver_one(webhook, &mut rx).await;
assert!(
recorded.request_line.starts_with("PUT /hook"),
"{recorded:?}"
);
assert_eq!(
recorded.headers.get("x-team").map(String::as_str),
Some("queues")
);
use hmac::{Hmac, Mac};
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(b"s3cret").unwrap();
let body: serde_json::Value = serde_json::from_str(&recorded.body).unwrap();
mac.update(serde_json::to_string(&body).unwrap().as_bytes());
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
assert_eq!(
recorded.headers.get("x-hammerwork-signature"),
Some(&expected)
);
let webhook = WebhookConfig::new("patch".to_string(), url)
.with_method(HttpMethod::Patch)
.with_retry_policy(no_retries());
let recorded = deliver_one(webhook, &mut rx).await;
assert!(recorded.request_line.starts_with("PATCH /hook"));
}
#[tokio::test]
async fn test_non_retryable_status_and_timeout_are_failures() {
let (url, mut rx) = recording_http_server(404, Duration::ZERO).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook = WebhookConfig::new("404".to_string(), url).with_retry_policy(RetryPolicy {
max_attempts: 3,
initial_delay_secs: 0,
..RetryPolicy::default()
});
let id = webhook.id;
manager.add_webhook(webhook).await.unwrap();
events.publish_event(completed_event()).await.unwrap();
next_recorded(&mut rx).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(rx.try_recv().is_err(), "a 404 is not retried");
let stats = manager.get_webhook_stats(id).await.unwrap();
assert_eq!((stats.total_attempts, stats.failed_deliveries), (1, 1));
assert!(stats.last_failure_at.is_some());
assert_eq!(manager.get_all_webhook_stats().await.len(), 1);
manager.shutdown(Duration::from_secs(5)).await;
let (url, mut rx) = recording_http_server(200, Duration::from_secs(5)).await;
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook = WebhookConfig::new("slow".to_string(), url)
.with_timeout_secs(1)
.with_retry_policy(no_retries());
let id = webhook.id;
manager.add_webhook(webhook).await.unwrap();
events.publish_event(completed_event()).await.unwrap();
next_recorded(&mut rx).await;
let mut failed = false;
for _ in 0..100 {
if manager
.get_webhook_stats(id)
.await
.is_some_and(|s| s.failed_deliveries == 1)
{
failed = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(failed, "the timed-out delivery is recorded as failed");
manager.shutdown(Duration::from_secs(5)).await;
}
#[tokio::test]
async fn test_enable_disable_update_webhook() {
let (url, mut rx) = recording_http_server(200, Duration::ZERO).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook =
WebhookConfig::new("toggle".to_string(), url.clone()).with_retry_policy(no_retries());
let id = webhook.id;
manager.add_webhook(webhook.clone()).await.unwrap();
manager.disable_webhook(id).await.unwrap();
assert_eq!(manager.get_stats().await.active_webhooks, 0);
events.publish_event(completed_event()).await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(rx.try_recv().is_err(), "disabled webhooks send nothing");
manager.enable_webhook(id).await.unwrap();
let event = completed_event();
let event_id = event.event_id;
events.publish_event(event).await.unwrap();
let recorded = next_recorded(&mut rx).await;
assert!(
recorded.body.contains(&event_id.to_string()),
"{recorded:?}"
);
manager
.update_webhook(webhook.with_method(HttpMethod::Put))
.await
.unwrap();
assert_eq!(
manager.get_webhook(id).await.unwrap().method,
HttpMethod::Put
);
events.publish_event(completed_event()).await.unwrap();
assert!(next_recorded(&mut rx).await.request_line.starts_with("PUT"));
let unknown = Uuid::new_v4();
for result in [
manager.enable_webhook(unknown).await,
manager.disable_webhook(unknown).await,
] {
assert!(result.unwrap_err().to_string().contains("not found"));
}
manager.shutdown(Duration::from_secs(5)).await;
}
async fn eventually<F, Fut>(what: &str, mut condition: F)
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = bool>,
{
for _ in 0..1000 {
if condition().await {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("timed out waiting until {what}");
}
#[tokio::test]
async fn test_stats_count_final_outcomes_not_attempts() {
let id = Uuid::new_v4();
let stats = Arc::new(RwLock::new(HashMap::from([(id, WebhookStats::empty(id))])));
let attempt = |success: bool| WebhookDelivery {
delivery_id: Uuid::new_v4(),
webhook_id: id,
event_id: Uuid::new_v4(),
status_code: Some(if success { 200 } else { 500 }),
response_body: None,
error_message: None,
attempted_at: Utc::now(),
duration_ms: Some(10),
attempt_number: 1,
success,
};
for success in [false, false, true] {
WebhookManager::update_webhook_stats(&id, &attempt(success), stats.clone()).await;
}
WebhookManager::record_delivery_outcome(&id, true, stats.clone()).await;
{
let stats = stats.read().await;
let s = &stats[&id];
assert_eq!((s.total_attempts, s.failed_attempts), (3, 2));
assert_eq!((s.successful_deliveries, s.failed_deliveries), (1, 0));
assert_eq!(s.success_rate, 1.0);
assert!(s.last_success_at.is_some() && s.last_failure_at.is_some());
}
for _ in 0..3 {
WebhookManager::update_webhook_stats(&id, &attempt(false), stats.clone()).await;
}
WebhookManager::record_delivery_outcome(&id, false, stats.clone()).await;
let stats = stats.read().await;
let s = &stats[&id];
assert_eq!((s.total_attempts, s.failed_attempts), (6, 5));
assert_eq!((s.successful_deliveries, s.failed_deliveries), (1, 1));
assert_eq!(s.success_rate, 0.5);
}
#[tokio::test]
async fn test_shutdown_unsubscribes_listeners() {
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
for name in ["a", "b"] {
manager
.add_webhook(WebhookConfig::new(
name.to_string(),
"http://127.0.0.1:1/hook".to_string(),
))
.await
.unwrap();
}
assert_eq!(events.subscription_count().await, 2);
manager.shutdown(Duration::from_secs(1)).await;
assert_eq!(events.subscription_count().await, 0);
}
#[tokio::test]
async fn test_event_published_right_after_add_is_delivered() {
let (url, mut rx) = recording_http_server(200, Duration::ZERO).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
let event = completed_event();
manager
.add_webhook(WebhookConfig::new("immediate".to_string(), url))
.await
.unwrap();
events.publish_event(event.clone()).await.unwrap();
let recorded = next_recorded(&mut rx).await;
assert!(recorded.body.contains(&event.event_id.to_string()));
manager.shutdown(Duration::from_secs(5)).await;
}
#[tokio::test]
async fn test_updates_do_not_duplicate_delivery() {
let (url, mut rx) = recording_http_server(200, Duration::ZERO).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook =
WebhookConfig::new("updated".to_string(), url.clone()).with_retry_policy(no_retries());
let id = webhook.id;
manager.add_webhook(webhook.clone()).await.unwrap();
for i in 0..5 {
manager
.update_webhook(
webhook
.clone()
.with_header("X-Version".to_string(), i.to_string()),
)
.await
.unwrap();
}
manager.add_webhook(webhook.clone()).await.unwrap();
manager.remove_webhook(id).await.unwrap();
manager
.add_webhook(webhook.with_header("X-Version".to_string(), "last".to_string()))
.await
.unwrap();
assert_eq!(events.subscription_count().await, 1);
let event = completed_event();
events.publish_event(event.clone()).await.unwrap();
let recorded = next_recorded(&mut rx).await;
assert_eq!(
recorded.headers.get("x-version").map(String::as_str),
Some("last")
);
assert!(recorded.body.contains(&event.event_id.to_string()));
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
rx.try_recv().is_err(),
"the event was delivered more than once"
);
assert_eq!(
manager.get_webhook_stats(id).await.unwrap().total_attempts,
1
);
manager.shutdown(Duration::from_secs(5)).await;
}
#[tokio::test]
async fn test_update_keeps_stats_and_applies_new_filter() {
let (url, mut rx) = recording_http_server(200, Duration::ZERO).await;
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook =
WebhookConfig::new("filtered".to_string(), url).with_retry_policy(no_retries());
let id = webhook.id;
manager.add_webhook(webhook.clone()).await.unwrap();
events.publish_event(completed_event()).await.unwrap();
next_recorded(&mut rx).await;
eventually("the first delivery is recorded", || async {
manager.get_webhook_stats(id).await.unwrap().total_attempts == 1
})
.await;
manager
.update_webhook(
webhook.with_filter(
EventFilter::new()
.with_event_types(vec![crate::events::JobLifecycleEventType::Failed]),
),
)
.await
.unwrap();
events.publish_event(completed_event()).await.unwrap();
let mut failed = completed_event();
failed.event_type = crate::events::JobLifecycleEventType::Failed;
events.publish_event(failed.clone()).await.unwrap();
let recorded = next_recorded(&mut rx).await;
assert!(recorded.body.contains(&failed.event_id.to_string()));
eventually("the second delivery is recorded", || async {
manager.get_webhook_stats(id).await.unwrap().total_attempts == 2
})
.await;
manager.shutdown(Duration::from_secs(5)).await;
}
async fn gated_http_server(
gate: Arc<Semaphore>,
) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/hook", listener.local_addr().unwrap());
let received = Arc::new(AtomicUsize::new(0));
let counter = received.clone();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let counter = counter.clone();
let gate = gate.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 16 * 1024];
let _ = socket.read(&mut buf).await;
counter.fetch_add(1, Ordering::SeqCst);
if let Ok(permit) = gate.acquire().await {
permit.forget();
}
let _ = socket
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await;
});
}
});
(url, received)
}
#[tokio::test]
async fn test_pending_deliveries_are_bounded() {
use std::sync::atomic::Ordering;
let gate = Arc::new(Semaphore::new(0));
let (url, received) = gated_http_server(gate.clone()).await;
let events = Arc::new(EventManager::new(crate::events::EventConfig {
max_buffer_size: 4,
..Default::default()
}));
let manager = WebhookManager::new(
events.clone(),
WebhookManagerConfig {
max_pending_deliveries: 2,
..Default::default()
},
)
.unwrap();
let webhook = WebhookConfig::new("stuck".to_string(), url).with_retry_policy(no_retries());
let id = webhook.id;
manager.add_webhook(webhook).await.unwrap();
for _ in 0..20 {
events.publish_event(completed_event()).await.unwrap();
tokio::task::yield_now().await;
}
eventually("two requests are in flight", || async {
received.load(Ordering::SeqCst) == 2
})
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(received.load(Ordering::SeqCst), 2);
assert!(
manager.deliveries.len() <= 2,
"{}",
manager.deliveries.len()
);
gate.add_permits(1_000);
eventually("every event is delivered or dropped", || async {
let stats = manager.get_webhook_stats(id).await.unwrap();
stats.successful_deliveries + stats.dropped_events == 20
})
.await;
let stats = manager.get_webhook_stats(id).await.unwrap();
assert!(stats.dropped_events > 0, "{stats:?}");
manager.shutdown(Duration::from_secs(5)).await;
}
#[tokio::test]
async fn test_timeout_covers_the_response_body() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/hook", listener.local_addr().unwrap());
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buf = vec![0u8; 16 * 1024];
let _ = socket.read(&mut buf).await;
let _ = socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\npartial")
.await;
tokio::time::sleep(Duration::from_secs(60)).await;
});
}
});
let events = Arc::new(EventManager::new_default());
let manager = WebhookManager::new_default(events.clone()).unwrap();
let webhook = WebhookConfig::new("stalling".to_string(), url)
.with_timeout_secs(1)
.with_retry_policy(no_retries());
let id = webhook.id;
manager.add_webhook(webhook).await.unwrap();
let started = std::time::Instant::now();
events.publish_event(completed_event()).await.unwrap();
eventually("the attempt finishes", || async {
manager.get_webhook_stats(id).await.unwrap().total_attempts == 1
})
.await;
assert!(started.elapsed() < Duration::from_secs(5));
let stats = manager.get_webhook_stats(id).await.unwrap();
assert_eq!(stats.successful_deliveries, 1);
assert_eq!(
manager.shutdown(Duration::ZERO).await,
0,
"nothing still running"
);
}
#[tokio::test]
async fn test_response_body_read_is_bounded() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/", listener.local_addr().unwrap());
tokio::spawn(async move {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
let mut buf = vec![0u8; 16 * 1024];
let _ = socket.read(&mut buf).await;
let _ = socket
.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
.await;
while socket.write_all(b"400\r\n").await.is_ok()
&& socket.write_all(&[b'x'; 1024]).await.is_ok()
&& socket.write_all(b"\r\n").await.is_ok()
{}
});
let response = Client::new().get(&url).send().await.unwrap();
let body = tokio::time::timeout(Duration::from_secs(5), read_body_limited(response, 10))
.await
.expect("reading stops at the limit")
.unwrap();
assert_eq!(body, "xxxxxxxxxx... [truncated]");
}
#[test]
fn test_zero_timeout_uses_manager_default() {
let config = WebhookManagerConfig {
default_timeout_secs: 7,
..Default::default()
};
let webhook = WebhookConfig::new("w".to_string(), "http://x".to_string());
assert_eq!(
attempt_timeout(&webhook.clone().with_timeout_secs(3), &config),
Duration::from_secs(3)
);
assert_eq!(
attempt_timeout(&webhook.clone().with_timeout_secs(0), &config),
Duration::from_secs(7)
);
let mut value = serde_json::to_value(&webhook).unwrap();
value.as_object_mut().unwrap().remove("timeout_secs");
let parsed: WebhookConfig = serde_json::from_value(value).unwrap();
assert_eq!(parsed.timeout_secs, 0);
assert_eq!(attempt_timeout(&parsed, &config), Duration::from_secs(7));
let settings = crate::config::WebhookGlobalSettings {
default_timeout_secs: 9,
max_pending_deliveries: 5,
..Default::default()
};
let from_settings = WebhookManagerConfig::from(&settings);
assert_eq!(from_settings.default_timeout_secs, 9);
assert_eq!(from_settings.max_pending_deliveries, 5);
}
#[test]
fn test_webhook_config_builders() {
let filter = EventFilter::new().with_queue_names(vec!["q".to_string()]);
let webhook = WebhookConfig::new("w".to_string(), "http://x".to_string())
.with_filter(filter)
.with_retry_policy(no_retries())
.enabled(false);
assert_eq!(webhook.filter.queue_names, vec!["q".to_string()]);
assert_eq!(webhook.retry_policy.max_attempts, 1);
assert!(!webhook.enabled);
}
#[test]
fn test_webhook_settings_builders() {
let retry = RetryPolicy::default()
.with_max_attempts(2)
.with_initial_delay_secs(1)
.with_max_delay_secs(5)
.with_backoff_multiplier(3.0)
.with_retry_on_status_codes(vec![503]);
assert_eq!(
(
retry.max_attempts,
retry.initial_delay_secs,
retry.max_delay_secs
),
(2, 1, 5)
);
assert_eq!(retry.backoff_multiplier, 3.0);
assert!(retry.should_retry_status(503) && !retry.should_retry_status(500));
let manager = WebhookManagerConfig::default()
.with_max_concurrent_deliveries(3)
.with_max_response_body_size(100)
.with_log_deliveries(false)
.with_default_timeout_secs(4)
.with_max_pending_deliveries(6)
.with_user_agent("acme");
assert_eq!(manager.max_concurrent_deliveries, 3);
assert_eq!(manager.max_response_body_size, 100);
assert!(!manager.log_deliveries);
assert_eq!(manager.default_timeout_secs, 4);
assert_eq!(manager.max_pending_deliveries, 6);
assert_eq!(manager.user_agent, "acme");
}
}