1#![allow(
2 clippy::significant_drop_tightening,
3 clippy::missing_panics_doc,
4 clippy::missing_errors_doc
5)]
6use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::{Arc, RwLock};
14
15use crate::http_client::Client;
16use crate::{AppState, AutumnError, AutumnResult};
17
18const MAX_LOGGED_RESPONSE_BODY_BYTES: usize = 16 * 1024;
19const TRUNCATED_RESPONSE_BODY_SUFFIX: &str = "\n[truncated]";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum WebhookSubscriptionStatus {
25 Active,
26 Disabled,
27 Failed,
28}
29
30impl WebhookSubscriptionStatus {
31 #[must_use]
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::Active => "active",
36 Self::Disabled => "disabled",
37 Self::Failed => "failed",
38 }
39 }
40}
41
42impl std::fmt::Display for WebhookSubscriptionStatus {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.write_str(self.as_str())
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct WebhookSubscription {
51 pub id: String,
52 pub target_url: String,
53 pub event_topics: Vec<String>,
54 pub secret: String,
55 pub status: WebhookSubscriptionStatus,
56 pub consecutive_failures: u32,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct WebhookDeliveryLog {
62 pub id: String,
63 pub subscription_id: String,
64 pub topic: String,
65 pub payload: String,
66 pub request_headers: HashMap<String, String>,
67 pub response_status: Option<u16>,
68 pub response_body: Option<String>,
69 pub elapsed_ms: u64,
70 pub attempt: u32,
71 pub max_attempts: u32,
72 pub is_dlq: bool,
73 pub last_error: Option<String>,
74 pub timestamp: DateTime<Utc>,
75}
76
77pub trait OutboundWebhookHandler: Send + Sync + 'static {
79 fn get_subscriptions(
81 &self,
82 topic: &str,
83 ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>>;
84
85 fn log_delivery(
87 &self,
88 log: WebhookDeliveryLog,
89 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>;
90
91 fn replace_delivery_log(
96 &self,
97 log: WebhookDeliveryLog,
98 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>;
99
100 fn get_subscription(
102 &self,
103 id: &str,
104 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>>;
105
106 fn get_dlq_logs(
108 &self,
109 ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookDeliveryLog>>> + Send>> {
110 Box::pin(async { Ok(Vec::new()) })
111 }
112
113 fn get_delivery_log(
115 &self,
116 id: &str,
117 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>>;
118
119 fn reset_subscription_failures(
121 &self,
122 _id: &str,
123 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
124 Box::pin(async { Ok(()) })
125 }
126
127 fn reactivate_failed_subscription(
132 &self,
133 id: &str,
134 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
135 self.reset_subscription_failures(id)
136 }
137}
138
139pub use OutboundWebhookHandler as OutboundWebhookStore;
141
142#[derive(Debug, Default)]
144pub struct InMemoryOutboundWebhookHandler {
145 subscriptions: RwLock<HashMap<String, WebhookSubscription>>,
146 logs: RwLock<HashMap<String, WebhookDeliveryLog>>,
147}
148
149pub type InMemoryOutboundWebhookStore = InMemoryOutboundWebhookHandler;
151
152impl InMemoryOutboundWebhookHandler {
153 #[must_use]
155 pub fn new() -> Self {
156 Self::default()
157 }
158
159 #[allow(clippy::unused_async)]
161 pub async fn create_subscription(
162 &self,
163 sub: WebhookSubscription,
164 ) -> AutumnResult<WebhookSubscription> {
165 let mut subs = self
166 .subscriptions
167 .write()
168 .expect("subscriptions write lock poisoned");
169 subs.insert(sub.id.clone(), sub.clone());
170 Ok(sub)
171 }
172
173 #[allow(clippy::unused_async)]
175 pub async fn get_delivery_logs(&self) -> AutumnResult<Vec<WebhookDeliveryLog>> {
176 let logs = self.logs.read().expect("logs read lock poisoned");
177 let mut list: Vec<WebhookDeliveryLog> = logs.values().cloned().collect();
178 list.sort_by_key(|l| l.timestamp);
179 list.reverse();
180 Ok(list)
181 }
182
183 #[allow(clippy::unused_async)]
185 pub async fn get_subscription(&self, id: &str) -> AutumnResult<Option<WebhookSubscription>> {
186 let subs = self
187 .subscriptions
188 .read()
189 .expect("subscriptions read lock poisoned");
190 Ok(subs.get(id).cloned())
191 }
192}
193
194impl OutboundWebhookHandler for InMemoryOutboundWebhookHandler {
195 fn get_subscriptions(
196 &self,
197 topic: &str,
198 ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>> {
199 let subs = self
200 .subscriptions
201 .read()
202 .expect("subscriptions read lock poisoned");
203 let topic = topic.to_owned();
204 let list: Vec<WebhookSubscription> = subs
205 .values()
206 .filter(|sub| {
207 sub.event_topics.iter().any(|t| t == &topic)
208 && sub.status == WebhookSubscriptionStatus::Active
209 })
210 .cloned()
211 .collect();
212 Box::pin(async move { Ok(list) })
213 }
214
215 fn get_subscription(
216 &self,
217 id: &str,
218 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>> {
219 let subs = self
220 .subscriptions
221 .read()
222 .expect("subscriptions read lock poisoned");
223 let sub = subs.get(id).cloned();
224 Box::pin(async move { Ok(sub) })
225 }
226
227 fn log_delivery(
228 &self,
229 log: WebhookDeliveryLog,
230 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
231 let mut logs = self.logs.write().expect("logs write lock poisoned");
232 logs.insert(log.id.clone(), log.clone());
233
234 let mut subs = self
236 .subscriptions
237 .write()
238 .expect("subscriptions write lock poisoned");
239 if let Some(sub) = subs.get_mut(&log.subscription_id) {
240 let is_active = sub.status == WebhookSubscriptionStatus::Active;
241 if is_active {
242 if let Some(status) = log.response_status {
243 if (200..300).contains(&status) {
244 sub.consecutive_failures = 0;
245 } else {
246 sub.consecutive_failures = sub.consecutive_failures.saturating_add(1);
247 if sub.consecutive_failures >= 50 {
248 sub.status = WebhookSubscriptionStatus::Failed;
249 tracing::warn!(subscription_id = %sub.id, "Webhook subscription auto-disabled due to 50 consecutive failures");
250 }
251 }
252 } else if log.last_error.is_some() {
253 sub.consecutive_failures = sub.consecutive_failures.saturating_add(1);
254 if sub.consecutive_failures >= 50 {
255 sub.status = WebhookSubscriptionStatus::Failed;
256 tracing::warn!(subscription_id = %sub.id, "Webhook subscription auto-disabled due to 50 consecutive failures");
257 }
258 }
259 }
260 }
261
262 Box::pin(async move { Ok(()) })
263 }
264
265 fn replace_delivery_log(
266 &self,
267 log: WebhookDeliveryLog,
268 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
269 let mut logs = self.logs.write().expect("logs write lock poisoned");
270 logs.insert(log.id.clone(), log);
271 Box::pin(async move { Ok(()) })
272 }
273
274 fn get_dlq_logs(
275 &self,
276 ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookDeliveryLog>>> + Send>> {
277 let list = {
278 let logs = self.logs.read().expect("logs read lock poisoned");
279 let mut list: Vec<WebhookDeliveryLog> =
280 logs.values().filter(|l| l.is_dlq).cloned().collect();
281 list.sort_by_key(|l| l.timestamp);
282 list.reverse();
283 list
284 };
285 Box::pin(async move { Ok(list) })
286 }
287
288 fn get_delivery_log(
289 &self,
290 id: &str,
291 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>> {
292 let log = self
293 .logs
294 .read()
295 .expect("logs read lock poisoned")
296 .get(id)
297 .cloned();
298 Box::pin(async move { Ok(log) })
299 }
300
301 fn reset_subscription_failures(
302 &self,
303 id: &str,
304 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
305 {
306 let mut subs = self
307 .subscriptions
308 .write()
309 .expect("subscriptions write lock poisoned");
310 if let Some(sub) = subs.get_mut(id) {
311 sub.consecutive_failures = 0;
312 }
313 }
314 Box::pin(async move { Ok(()) })
315 }
316
317 fn reactivate_failed_subscription(
318 &self,
319 id: &str,
320 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
321 {
322 let mut subs = self
323 .subscriptions
324 .write()
325 .expect("subscriptions write lock poisoned");
326 if let Some(sub) = subs.get_mut(id) {
327 sub.consecutive_failures = 0;
328 if sub.status == WebhookSubscriptionStatus::Failed {
329 sub.status = WebhookSubscriptionStatus::Active;
330 }
331 }
332 }
333 Box::pin(async move { Ok(()) })
334 }
335}
336
337pub type WebhookDelegate = Arc<
339 dyn Fn(
340 &AppState,
341 WebhookSubscription,
342 WebhookDeliveryLog,
343 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>>
344 + Send
345 + Sync,
346>;
347
348#[derive(Clone)]
350pub struct WebhookDelegateExt(pub WebhookDelegate);
351
352#[derive(Clone)]
354pub struct WebhookOutboundManager {
355 handler: Arc<dyn OutboundWebhookHandler>,
356 client: Client,
357 initial_backoff_ms: u64,
358}
359
360impl WebhookOutboundManager {
361 pub fn new(handler: Arc<dyn OutboundWebhookHandler>) -> Self {
363 Self {
364 handler,
365 client: Client::new(),
366 initial_backoff_ms: 1000,
367 }
368 }
369
370 #[must_use]
372 pub const fn with_initial_backoff_ms(mut self, ms: u64) -> Self {
373 self.initial_backoff_ms = ms;
374 self
375 }
376
377 fn with_client_from_state(mut self, state: &AppState) -> Self {
378 self.client = Client::from_state(state);
379 self
380 }
381
382 #[must_use]
384 pub fn store(&self) -> &Arc<dyn OutboundWebhookHandler> {
385 &self.handler
386 }
387
388 #[must_use]
390 pub const fn client(&self) -> &Client {
391 &self.client
392 }
393
394 pub async fn dispatch<T: Serialize + Sync>(
400 &self,
401 state: &AppState,
402 topic: &str,
403 payload: &T,
404 ) -> AutumnResult<()> {
405 let serialized = serde_json::to_string(payload).map_err(|e| {
406 AutumnError::internal_server_error_msg(format!("failed to serialize payload: {e}"))
407 })?;
408
409 let mut errors = Vec::new();
410 let subs = self.handler.get_subscriptions(topic).await?;
411 for sub in subs {
412 if sub.status == WebhookSubscriptionStatus::Disabled {
413 continue;
414 }
415
416 let log_id = uuid::Uuid::new_v4().to_string();
417 let log = WebhookDeliveryLog {
418 id: log_id.clone(),
419 subscription_id: sub.id.clone(),
420 topic: topic.to_owned(),
421 payload: serialized.clone(),
422 request_headers: HashMap::new(),
423 response_status: None,
424 response_body: None,
425 elapsed_ms: 0,
426 attempt: 1,
427 max_attempts: 5,
428 is_dlq: false,
429 last_error: None,
430 timestamp: Utc::now(),
431 };
432
433 if let Err(e) = self.handler.log_delivery(log.clone()).await {
435 errors.push(e);
436 continue;
437 }
438
439 if let Some(delegate_ext) = state.extension::<WebhookDelegateExt>() {
441 tracing::info!(subscription_id = %sub.id, "WebhookOutboundManager::dispatch: delegating webhook delivery via runtime hook");
442 if let Err(e) = (delegate_ext.0)(state, sub, log).await {
443 errors.push(e);
444 }
445 } else {
446 tracing::debug!(subscription_id = %sub.id, "WebhookOutboundManager::dispatch: enqueuing fallback webhook delivery job");
448 if let Some(job_client) = crate::job::global_job_client() {
449 let job_payload = serde_json::json!({
450 "log_id": log.id.clone(),
451 });
452 if let Err(e) = job_client
453 .enqueue("autumn_webhook_delivery", job_payload)
454 .await
455 {
456 errors.push(
457 self.record_delivery_enqueue_failure(log, e.to_string())
458 .await,
459 );
460 }
461 } else {
462 errors.push(
463 self.record_delivery_enqueue_failure(
464 log,
465 "Global job client is unavailable; fallback webhook delivery job not enqueued"
466 .to_owned(),
467 )
468 .await,
469 );
470 }
471 }
472 }
473
474 if !errors.is_empty() {
475 return Err(errors.remove(0));
476 }
477
478 Ok(())
479 }
480
481 async fn record_delivery_enqueue_failure(
482 &self,
483 mut log: WebhookDeliveryLog,
484 message: String,
485 ) -> AutumnError {
486 log.is_dlq = true;
487 log.last_error = Some(message.clone());
488 log.timestamp = Utc::now();
489
490 if let Err(e) = self.handler.replace_delivery_log(log).await {
491 tracing::error!(
492 error = %e,
493 "Failed to mark webhook delivery log as DLQ after enqueue failure"
494 );
495 return e;
496 }
497
498 AutumnError::internal_server_error_msg(message)
499 }
500}
501
502fn install_outbound_webhook_manager(
503 state: &AppState,
504 store: Arc<dyn OutboundWebhookHandler>,
505 initial_backoff_ms: u64,
506) {
507 let manager = WebhookOutboundManager::new(store)
508 .with_initial_backoff_ms(initial_backoff_ms)
509 .with_client_from_state(state);
510 state.insert_extension(manager);
511}
512
513#[must_use]
515#[allow(clippy::redundant_closure_for_method_calls, clippy::too_many_lines)]
516pub fn deliver_webhook_job(
517 state: AppState,
518 payload: serde_json::Value,
519) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'static>> {
520 Box::pin(async move {
521 let is_replay = payload
522 .get("replay")
523 .and_then(serde_json::Value::as_bool)
524 .unwrap_or(false);
525 let manager = state.extension::<WebhookOutboundManager>().ok_or_else(|| {
526 AutumnError::internal_server_error_msg("WebhookOutboundManager not found in extensions")
527 })?;
528
529 let (sub, mut log) = if let Some(sub_val) = payload.get("subscription") {
531 let _payload_sub: WebhookSubscription = serde_json::from_value(sub_val.clone())
532 .map_err(|e| {
533 AutumnError::bad_request_msg(format!("failed to parse subscription: {e}"))
534 })?;
535 let mut log: WebhookDeliveryLog = serde_json::from_value(
536 payload
537 .get("log")
538 .cloned()
539 .ok_or_else(|| AutumnError::bad_request_msg("missing log in job payload"))?,
540 )
541 .map_err(|e| AutumnError::bad_request_msg(format!("failed to parse log: {e}")))?;
542
543 if log.response_status.is_some() || log.last_error.is_some() {
546 log.attempt = log.attempt.saturating_add(1);
547 log.response_status = None;
548 log.response_body = None;
549 log.last_error = None;
550 manager.store().log_delivery(log.clone()).await?;
551 }
552
553 let sub = load_current_subscription(&manager, &log).await?;
554 (sub, log)
555 } else {
556 let log_id = payload
557 .get("log_id")
558 .and_then(|v| v.as_str())
559 .ok_or_else(|| AutumnError::bad_request_msg("missing log_id in job payload"))?;
560
561 tracing::debug!(log_id = %log_id, "deliver_webhook_job: starting webhook delivery via log lookup");
562
563 let log_opt = manager.store().get_delivery_log(log_id).await?;
564 let mut log = log_opt.ok_or_else(|| {
565 AutumnError::not_found_msg(format!("delivery log {log_id} not found"))
566 })?;
567
568 if log.response_status.is_some() || log.last_error.is_some() {
571 log.attempt = log.attempt.saturating_add(1);
572 log.response_status = None;
573 log.response_body = None;
574 log.last_error = None;
575 manager.store().log_delivery(log.clone()).await?;
576 }
577
578 let sub = load_current_subscription(&manager, &log).await?;
580 (sub, log)
581 };
582
583 if sub.status == WebhookSubscriptionStatus::Disabled {
584 tracing::info!(subscription_id = %sub.id, "Webhook subscription is disabled; skipping delivery");
585 log.last_error = Some("Subscription is disabled".to_owned());
586 log.timestamp = Utc::now();
587 if is_replay {
588 log.is_dlq = true;
589 }
590 manager.store().log_delivery(log).await?;
591 return Ok(());
592 }
593
594 if sub.status == WebhookSubscriptionStatus::Failed && !is_replay {
595 tracing::info!(subscription_id = %sub.id, "Webhook subscription has failed; skipping delivery");
596 log.last_error = Some("Subscription has failed due to consecutive errors".to_owned());
597 log.timestamp = Utc::now();
598 manager.store().log_delivery(log).await?;
599 return Ok(());
600 }
601 if sub.status == WebhookSubscriptionStatus::Failed {
602 tracing::info!(subscription_id = %sub.id, "Replaying webhook delivery for failed subscription");
603 }
604
605 let timestamp = Utc::now().timestamp();
607 let signing_payload = format!("{timestamp}.{}", log.payload);
608 let signature = crate::security::config::hmac_sha256_hex(
609 sub.secret.as_bytes(),
610 signing_payload.as_bytes(),
611 );
612 let signature_header = format!("t={timestamp},v1={signature}");
613
614 let mut request_headers = HashMap::new();
615 request_headers.insert("Content-Type".to_owned(), "application/json".to_owned());
616 request_headers.insert("Autumn-Signature".to_owned(), signature_header.clone());
617
618 let start = std::time::Instant::now();
619 let req = manager
620 .client
621 .named(&sub.target_url)
622 .post(&sub.target_url)
623 .header("Content-Type", "application/json")
624 .header("Autumn-Signature", signature_header)
625 .text_body(log.payload.clone());
626
627 let response = req.send().await;
628 let elapsed = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
629
630 tracing::debug!(
631 log_id = %log.id,
632 status = ?response.as_ref().map(|r| r.status()),
633 "deliver_webhook_job: webhook HTTP request finished"
634 );
635
636 log.elapsed_ms = elapsed;
637 log.timestamp = Utc::now();
638 log.request_headers = request_headers;
639
640 match response {
641 Ok(res) => {
642 let status = res.status();
643 log.response_status = Some(status.as_u16());
644 let is_success = res.is_success();
645 let body_str = cap_logged_response_body(res.text());
646 log.response_body = Some(body_str);
647
648 if is_success {
649 log.last_error = None;
650 manager.store().log_delivery(log).await?;
651 reset_subscription_after_success(&manager, &sub).await;
652 Ok(())
653 } else {
654 let status_err = format!("server returned status: {status}");
655 log.last_error = Some(status_err.clone());
656 if log.attempt < log.max_attempts {
657 manager.store().log_delivery(log.clone()).await?;
658 }
659 handle_delivery_failure(&manager, &sub, log, status_err).await
660 }
661 }
662 Err(e) => {
663 let error_str = e.to_string();
664 log.last_error = Some(error_str.clone());
665 if log.attempt < log.max_attempts {
666 manager.store().log_delivery(log.clone()).await?;
667 }
668 handle_delivery_failure(&manager, &sub, log, error_str).await
669 }
670 }
671 })
672}
673
674async fn load_current_subscription(
675 manager: &WebhookOutboundManager,
676 log: &WebhookDeliveryLog,
677) -> AutumnResult<WebhookSubscription> {
678 manager
679 .store()
680 .get_subscription(&log.subscription_id)
681 .await?
682 .ok_or_else(|| {
683 AutumnError::not_found_msg(format!("subscription {} not found", log.subscription_id))
684 })
685}
686
687fn cap_logged_response_body(mut body: String) -> String {
688 if body.len() <= MAX_LOGGED_RESPONSE_BODY_BYTES {
689 return body;
690 }
691
692 let body_budget =
693 MAX_LOGGED_RESPONSE_BODY_BYTES.saturating_sub(TRUNCATED_RESPONSE_BODY_SUFFIX.len());
694 let mut cutoff = body_budget.min(body.len());
695 while cutoff > 0 && !body.is_char_boundary(cutoff) {
696 cutoff -= 1;
697 }
698 body.truncate(cutoff);
699 body.push_str(TRUNCATED_RESPONSE_BODY_SUFFIX);
700 body
701}
702
703async fn reset_subscription_after_success(
704 manager: &WebhookOutboundManager,
705 sub: &WebhookSubscription,
706) {
707 if let Err(e) = manager
708 .store()
709 .reactivate_failed_subscription(&sub.id)
710 .await
711 {
712 tracing::warn!(
713 subscription_id = %sub.id,
714 "Webhook delivery succeeded but subscription failure state could not be reset: {}",
715 e
716 );
717 }
718}
719
720async fn handle_delivery_failure(
721 manager: &WebhookOutboundManager,
722 sub: &WebhookSubscription,
723 mut log: WebhookDeliveryLog,
724 error_msg: String,
725) -> AutumnResult<()> {
726 if log.attempt < log.max_attempts {
727 Err(AutumnError::internal_server_error_msg(format!(
729 "delivery attempt {} failed, scheduled retry: {error_msg}",
730 log.attempt
731 )))
732 } else {
733 log.is_dlq = true;
734 manager.store().log_delivery(log).await?;
735 tracing::warn!(subscription_id = %sub.id, "Webhook delivery failed permanently; sent to DLQ: {}", error_msg);
737 Ok(())
738 }
739}
740
741pub struct OutboundWebhookPlugin {
743 store: Arc<dyn OutboundWebhookHandler>,
744 initial_backoff_ms: u64,
745}
746
747impl OutboundWebhookPlugin {
748 #[must_use]
750 pub fn new(store: Arc<dyn OutboundWebhookHandler>) -> Self {
751 Self {
752 store,
753 initial_backoff_ms: 1000,
754 }
755 }
756
757 #[must_use]
759 pub const fn with_initial_backoff_ms(mut self, ms: u64) -> Self {
760 self.initial_backoff_ms = ms;
761 self
762 }
763}
764
765impl crate::plugin::Plugin for OutboundWebhookPlugin {
766 fn build(self, app: crate::app::AppBuilder) -> crate::app::AppBuilder {
767 let store = self.store;
768 let initial_backoff_ms = self.initial_backoff_ms;
769
770 app.state_initializer(move |state| {
771 install_outbound_webhook_manager(state, store.clone(), initial_backoff_ms);
772 })
773 .jobs(vec![crate::job::JobInfo {
774 name: "autumn_webhook_delivery".to_string(),
775 max_attempts: 10, initial_backoff_ms,
777 queue: "default".to_string(),
778 uniqueness: None,
779 concurrency: None,
780 version: 1,
781 handler: deliver_webhook_job,
782 }])
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use crate::http_client::{HttpMockRegistryExt, MockRegistry, MockSetupBuilder};
790 use std::sync::Arc;
791 use std::sync::atomic::{AtomicUsize, Ordering};
792
793 fn mock_builder(registry: Arc<MockRegistry>, alias: &str) -> MockSetupBuilder {
794 MockSetupBuilder {
795 registry,
796 alias: alias.to_owned(),
797 method: None,
798 path: None,
799 }
800 }
801
802 fn sample_subscription(
803 id: &str,
804 target_url: &str,
805 status: WebhookSubscriptionStatus,
806 ) -> WebhookSubscription {
807 WebhookSubscription {
808 id: id.to_owned(),
809 target_url: target_url.to_owned(),
810 event_topics: vec!["orders.created".to_owned()],
811 secret: "my_webhook_signing_secret_32_bytes!!".to_owned(),
812 status,
813 consecutive_failures: if status == WebhookSubscriptionStatus::Failed {
814 50
815 } else {
816 0
817 },
818 }
819 }
820
821 fn sample_log(id: &str, subscription_id: &str) -> WebhookDeliveryLog {
822 WebhookDeliveryLog {
823 id: id.to_owned(),
824 subscription_id: subscription_id.to_owned(),
825 topic: "orders.created".to_owned(),
826 payload: serde_json::json!({ "order_id": "ord_123" }).to_string(),
827 request_headers: HashMap::new(),
828 response_status: None,
829 response_body: None,
830 elapsed_ms: 0,
831 attempt: 1,
832 max_attempts: 5,
833 is_dlq: false,
834 last_error: None,
835 timestamp: Utc::now(),
836 }
837 }
838
839 #[test]
840 fn outbound_webhook_plugin_installs_manager_without_startup_hook() {
841 let store = Arc::new(InMemoryOutboundWebhookHandler::new());
842 let builder = crate::app().plugin(OutboundWebhookPlugin::new(store));
843
844 assert!(
845 builder.startup_hooks.is_empty(),
846 "webhook manager must be installed before job workers start, not from a startup hook"
847 );
848 assert_eq!(builder.state_initializers.len(), 1);
849 }
850
851 #[tokio::test]
852 async fn replay_job_sends_failed_subscription_instead_of_skipping() {
853 let state = AppState::for_test();
854 let store = Arc::new(InMemoryOutboundWebhookHandler::new());
855 let registry = Arc::new(MockRegistry::new());
856 let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/replay")
857 .post("/webhooks/replay")
858 .respond_with(200, serde_json::json!({ "received": true }));
859 state.insert_extension(HttpMockRegistryExt(registry));
860 install_outbound_webhook_manager(&state, store.clone(), 1);
861
862 let sub = sample_subscription(
863 "sub_failed",
864 "http://mock-receiver/webhooks/replay",
865 WebhookSubscriptionStatus::Failed,
866 );
867 store.create_subscription(sub).await.unwrap();
868 store
869 .replace_delivery_log(sample_log("log_replay", "sub_failed"))
870 .await
871 .unwrap();
872
873 deliver_webhook_job(
874 state,
875 serde_json::json!({
876 "log_id": "log_replay",
877 "replay": true,
878 }),
879 )
880 .await
881 .unwrap();
882
883 mock.expect_called(1);
884 let log = store
885 .get_delivery_log("log_replay")
886 .await
887 .unwrap()
888 .expect("log should remain stored");
889 assert_eq!(log.response_status, Some(200));
890 assert!(!log.is_dlq);
891 assert!(log.last_error.is_none());
892
893 let updated_sub = store
894 .get_subscription("sub_failed")
895 .await
896 .unwrap()
897 .expect("subscription should remain stored");
898 assert_eq!(updated_sub.status, WebhookSubscriptionStatus::Active);
899 assert_eq!(updated_sub.consecutive_failures, 0);
900 }
901
902 #[tokio::test]
903 async fn replay_job_keeps_disabled_subscription_log_in_dlq() {
904 let state = AppState::for_test();
905 let store = Arc::new(InMemoryOutboundWebhookHandler::new());
906 let registry = Arc::new(MockRegistry::new());
907 let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/disabled")
908 .post("/webhooks/disabled")
909 .respond_with(200, serde_json::json!({ "received": true }));
910 state.insert_extension(HttpMockRegistryExt(registry));
911 install_outbound_webhook_manager(&state, store.clone(), 1);
912
913 let sub = sample_subscription(
914 "sub_disabled",
915 "http://mock-receiver/webhooks/disabled",
916 WebhookSubscriptionStatus::Disabled,
917 );
918 store.create_subscription(sub).await.unwrap();
919 store
920 .replace_delivery_log(sample_log("log_disabled_replay", "sub_disabled"))
921 .await
922 .unwrap();
923
924 deliver_webhook_job(
925 state,
926 serde_json::json!({
927 "log_id": "log_disabled_replay",
928 "replay": true,
929 }),
930 )
931 .await
932 .unwrap();
933
934 mock.expect_called(0);
935 let log = store
936 .get_delivery_log("log_disabled_replay")
937 .await
938 .unwrap()
939 .expect("log should remain stored");
940 assert!(log.is_dlq, "disabled replay must remain visible in DLQ");
941 assert_eq!(log.last_error.as_deref(), Some("Subscription is disabled"));
942 assert_eq!(log.response_status, None);
943 }
944
945 #[tokio::test]
946 async fn self_contained_delivery_uses_latest_subscription_state() {
947 let state = AppState::for_test();
948 let store = Arc::new(InMemoryOutboundWebhookHandler::new());
949 let registry = Arc::new(MockRegistry::new());
950 let stale_mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/stale")
951 .post("/webhooks/stale")
952 .respond_with(200, serde_json::json!({ "received": true }));
953 state.insert_extension(HttpMockRegistryExt(registry));
954 install_outbound_webhook_manager(&state, store.clone(), 1);
955
956 let stored_sub = sample_subscription(
957 "sub_refresh",
958 "http://mock-receiver/webhooks/current-disabled",
959 WebhookSubscriptionStatus::Disabled,
960 );
961 store.create_subscription(stored_sub).await.unwrap();
962 let stale_sub = sample_subscription(
963 "sub_refresh",
964 "http://mock-receiver/webhooks/stale",
965 WebhookSubscriptionStatus::Active,
966 );
967 let log = sample_log("log_refresh", "sub_refresh");
968
969 deliver_webhook_job(
970 state,
971 serde_json::json!({
972 "subscription": stale_sub,
973 "log": log,
974 }),
975 )
976 .await
977 .unwrap();
978
979 stale_mock.expect_called(0);
980 let stored = store
981 .get_delivery_log("log_refresh")
982 .await
983 .unwrap()
984 .expect("delivery log should exist");
985 assert_eq!(stored.response_status, None);
986 assert_eq!(
987 stored.last_error.as_deref(),
988 Some("Subscription is disabled")
989 );
990 }
991
992 #[tokio::test]
993 async fn dispatch_marks_log_dlq_when_fallback_enqueue_fails() {
994 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
995 crate::job::clear_global_job_client();
996
997 let state = AppState::for_test();
998 let store = Arc::new(InMemoryOutboundWebhookHandler::new());
999 let manager = WebhookOutboundManager::new(store.clone()).with_initial_backoff_ms(1);
1000 let sub = sample_subscription(
1001 "sub_enqueue_missing",
1002 "http://mock-receiver/webhooks/enqueue-missing",
1003 WebhookSubscriptionStatus::Active,
1004 );
1005 store.create_subscription(sub).await.unwrap();
1006
1007 let err = manager
1008 .dispatch(&state, "orders.created", &serde_json::json!({ "id": 42 }))
1009 .await
1010 .expect_err("dispatch should report the missing fallback job runtime");
1011 assert!(
1012 err.to_string().contains("not enqueued"),
1013 "error should describe the enqueue failure: {err}"
1014 );
1015
1016 let logs = store.get_delivery_logs().await.unwrap();
1017 assert_eq!(logs.len(), 1);
1018 let log = &logs[0];
1019 assert!(
1020 log.is_dlq,
1021 "enqueue failure must leave a replayable DLQ record"
1022 );
1023 assert!(
1024 log.last_error
1025 .as_deref()
1026 .is_some_and(|msg| msg.contains("not enqueued")),
1027 "DLQ log should record enqueue failure: {:?}",
1028 log.last_error
1029 );
1030 assert_eq!(log.response_status, None);
1031
1032 let sub = store
1033 .get_subscription("sub_enqueue_missing")
1034 .await
1035 .unwrap()
1036 .expect("subscription should remain stored");
1037 assert_eq!(sub.consecutive_failures, 0);
1038 }
1039
1040 #[tokio::test]
1041 async fn delivery_log_response_body_is_capped() {
1042 let state = AppState::for_test();
1043 let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1044 let registry = Arc::new(MockRegistry::new());
1045 let large_body = "x".repeat(MAX_LOGGED_RESPONSE_BODY_BYTES + 1024);
1046 let _mock = mock_builder(
1047 registry.clone(),
1048 "http://mock-receiver/webhooks/large-error",
1049 )
1050 .post("/webhooks/large-error")
1051 .respond_with(500, serde_json::json!({ "error": large_body }));
1052 state.insert_extension(HttpMockRegistryExt(registry));
1053 install_outbound_webhook_manager(&state, store.clone(), 1);
1054
1055 let sub = sample_subscription(
1056 "sub_large_error",
1057 "http://mock-receiver/webhooks/large-error",
1058 WebhookSubscriptionStatus::Active,
1059 );
1060 store.create_subscription(sub.clone()).await.unwrap();
1061 let mut log = sample_log("log_large_error", "sub_large_error");
1062 log.max_attempts = 1;
1063
1064 deliver_webhook_job(
1065 state,
1066 serde_json::json!({
1067 "subscription": sub,
1068 "log": log,
1069 }),
1070 )
1071 .await
1072 .unwrap();
1073
1074 let stored = store
1075 .get_delivery_log("log_large_error")
1076 .await
1077 .unwrap()
1078 .expect("delivery log should exist");
1079 let body = stored
1080 .response_body
1081 .expect("response body should be logged");
1082 assert!(
1083 body.len() <= MAX_LOGGED_RESPONSE_BODY_BYTES,
1084 "stored response body should be capped, got {} bytes",
1085 body.len()
1086 );
1087 assert!(body.ends_with("[truncated]"));
1088 }
1089
1090 struct CountingReplacementStore {
1091 log_delivery_calls: AtomicUsize,
1092 }
1093
1094 impl CountingReplacementStore {
1095 fn new() -> Self {
1096 Self {
1097 log_delivery_calls: AtomicUsize::new(0),
1098 }
1099 }
1100
1101 fn log_delivery_count(&self) -> usize {
1102 self.log_delivery_calls.load(Ordering::SeqCst)
1103 }
1104 }
1105
1106 impl OutboundWebhookHandler for CountingReplacementStore {
1107 fn get_subscriptions(
1108 &self,
1109 _topic: &str,
1110 ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>> {
1111 Box::pin(async { Ok(Vec::new()) })
1112 }
1113
1114 fn log_delivery(
1115 &self,
1116 _log: WebhookDeliveryLog,
1117 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
1118 self.log_delivery_calls.fetch_add(1, Ordering::SeqCst);
1119 Box::pin(async { Ok(()) })
1120 }
1121
1122 fn replace_delivery_log(
1123 &self,
1124 _log: WebhookDeliveryLog,
1125 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
1126 Box::pin(async { Ok(()) })
1127 }
1128
1129 fn get_subscription(
1130 &self,
1131 _id: &str,
1132 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>>
1133 {
1134 Box::pin(async { Ok(None) })
1135 }
1136
1137 fn get_delivery_log(
1138 &self,
1139 _id: &str,
1140 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>>
1141 {
1142 Box::pin(async { Ok(None) })
1143 }
1144 }
1145
1146 #[tokio::test]
1147 async fn replace_delivery_log_is_not_a_delivery_outcome() {
1148 let store = CountingReplacementStore::new();
1149 let mut log = sample_log("log_replace", "sub_replace");
1150 log.response_status = Some(500);
1151 log.last_error = Some("server returned status: 500 Internal Server Error".to_owned());
1152 log.is_dlq = true;
1153
1154 store.replace_delivery_log(log).await.unwrap();
1155
1156 assert_eq!(
1157 store.log_delivery_count(),
1158 0,
1159 "plain delivery-log replacement must not call log_delivery"
1160 );
1161 }
1162
1163 struct ResetFailingStore {
1164 inner: InMemoryOutboundWebhookHandler,
1165 }
1166
1167 impl ResetFailingStore {
1168 fn new() -> Self {
1169 Self {
1170 inner: InMemoryOutboundWebhookHandler::new(),
1171 }
1172 }
1173
1174 async fn create_subscription(&self, sub: WebhookSubscription) {
1175 self.inner.create_subscription(sub).await.unwrap();
1176 }
1177
1178 async fn delivery_log(&self, id: &str) -> WebhookDeliveryLog {
1179 self.inner
1180 .get_delivery_log(id)
1181 .await
1182 .unwrap()
1183 .expect("delivery log should exist")
1184 }
1185 }
1186
1187 impl OutboundWebhookHandler for ResetFailingStore {
1188 fn get_subscriptions(
1189 &self,
1190 topic: &str,
1191 ) -> Pin<Box<dyn Future<Output = AutumnResult<Vec<WebhookSubscription>>> + Send>> {
1192 <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::get_subscriptions(
1193 &self.inner,
1194 topic,
1195 )
1196 }
1197
1198 fn log_delivery(
1199 &self,
1200 log: WebhookDeliveryLog,
1201 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
1202 <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::log_delivery(
1203 &self.inner,
1204 log,
1205 )
1206 }
1207
1208 fn replace_delivery_log(
1209 &self,
1210 log: WebhookDeliveryLog,
1211 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
1212 <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::replace_delivery_log(
1213 &self.inner,
1214 log,
1215 )
1216 }
1217
1218 fn get_subscription(
1219 &self,
1220 id: &str,
1221 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookSubscription>>> + Send>>
1222 {
1223 <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::get_subscription(
1224 &self.inner,
1225 id,
1226 )
1227 }
1228
1229 fn get_delivery_log(
1230 &self,
1231 id: &str,
1232 ) -> Pin<Box<dyn Future<Output = AutumnResult<Option<WebhookDeliveryLog>>> + Send>>
1233 {
1234 <InMemoryOutboundWebhookHandler as OutboundWebhookHandler>::get_delivery_log(
1235 &self.inner,
1236 id,
1237 )
1238 }
1239
1240 fn reset_subscription_failures(
1241 &self,
1242 _id: &str,
1243 ) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
1244 Box::pin(async {
1245 Err(AutumnError::internal_server_error_msg(
1246 "reset backend unavailable",
1247 ))
1248 })
1249 }
1250 }
1251
1252 #[tokio::test]
1253 async fn successful_delivery_does_not_retry_when_failure_reset_fails() {
1254 let state = AppState::for_test();
1255 let store = Arc::new(ResetFailingStore::new());
1256 let registry = Arc::new(MockRegistry::new());
1257 let mock = mock_builder(registry.clone(), "http://mock-receiver/webhooks/success")
1258 .post("/webhooks/success")
1259 .respond_with(200, serde_json::json!({ "received": true }));
1260 state.insert_extension(HttpMockRegistryExt(registry));
1261 install_outbound_webhook_manager(&state, store.clone(), 1);
1262
1263 let sub = sample_subscription(
1264 "sub_success",
1265 "http://mock-receiver/webhooks/success",
1266 WebhookSubscriptionStatus::Active,
1267 );
1268 store.create_subscription(sub.clone()).await;
1269 let log = sample_log("log_success", "sub_success");
1270
1271 deliver_webhook_job(
1272 state,
1273 serde_json::json!({
1274 "subscription": sub,
1275 "log": log,
1276 }),
1277 )
1278 .await
1279 .expect("accepted webhook delivery must not be retried because counter reset failed");
1280
1281 mock.expect_called(1);
1282 let persisted = store.delivery_log("log_success").await;
1283 assert_eq!(persisted.response_status, Some(200));
1284 assert!(persisted.last_error.is_none());
1285 }
1286
1287 #[tokio::test]
1288 async fn webhook_manager_uses_http_client_config_base_urls() {
1289 let _guard = crate::job::global_job_runtime_test_lock().lock().await;
1290 crate::job::clear_global_job_client();
1291
1292 let store = Arc::new(InMemoryOutboundWebhookHandler::new());
1293 let plugin = OutboundWebhookPlugin::new(store.clone()).with_initial_backoff_ms(1);
1294 let mut config = crate::config::AutumnConfig::default();
1295 config.http.client.base_urls.insert(
1296 "hook-service".to_owned(),
1297 "http://mock-receiver/base".to_owned(),
1298 );
1299
1300 let mut app_builder = crate::test::TestApp::new().config(config).plugin(plugin);
1301 let mock = app_builder
1302 .http_mock("hook-service")
1303 .post("/base/hook-service")
1304 .respond_with(200, serde_json::json!({ "received": true }));
1305 let app = app_builder.build();
1306 let state = app.state();
1307
1308 let sub = sample_subscription(
1309 "sub_config",
1310 "hook-service",
1311 WebhookSubscriptionStatus::Active,
1312 );
1313 store.create_subscription(sub.clone()).await.unwrap();
1314 let log = sample_log("log_config", "sub_config");
1315
1316 deliver_webhook_job(
1317 state.clone(),
1318 serde_json::json!({
1319 "subscription": sub,
1320 "log": log,
1321 }),
1322 )
1323 .await
1324 .unwrap();
1325
1326 mock.expect_called(1);
1327 crate::job::clear_global_job_client();
1328 }
1329}