1use bytes::Bytes;
7use cratefield_core::{
8 Clock, DbError, DrainOptions, HttpClient, HttpError, IdGen, ModuleConfig, ModuleContext,
9 Outbox, OutboxRecord, Processed, ScheduledBudget, UlidIdGen,
10};
11use hmac::{Hmac, KeyInit, Mac};
12use http::header::CONTENT_TYPE;
13use http::{Request, StatusCode};
14use serde::Serialize;
15use sha2::Sha256;
16use std::fmt::Write as _;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use std::time::Duration;
20use time::OffsetDateTime;
21
22use crate::clock::iso;
23use crate::store::{self, DeadLetterReason, DeliveryJob, GiveUp};
24
25pub const SIGNATURE_HEADER: &str = "Cratefield-Signature";
30pub const EVENT_ID_HEADER: &str = "Cratefield-Event-Id";
33pub const EVENT_TYPE_HEADER: &str = "Cratefield-Event-Type";
35
36const BACKOFF_BASE_SECS: u64 = 30;
38const BACKOFF_MAX_SECS: u64 = 3_600;
40const LEASE_SECS: i64 = 300;
42const DRAIN_BATCH: u64 = 16;
47const SUBREQUESTS_PER_DELIVERY: u32 = 1;
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub struct DrainReport {
61 pub claimed: usize,
64 pub delivered: usize,
66 pub retried: usize,
68 pub dead_lettered: usize,
70 pub dropped: usize,
73 pub failed: usize,
77 pub deferred: u64,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Filed {
86 Delivered,
88 Retried(OffsetDateTime),
90 DeadLettered,
92 Dropped,
94}
95
96#[derive(Debug, Default)]
103struct Counters {
104 delivered: AtomicUsize,
105 retried: AtomicUsize,
106 dead_lettered: AtomicUsize,
107 dropped: AtomicUsize,
108 failed: AtomicUsize,
109}
110
111#[derive(Serialize)]
115struct Envelope<'a> {
116 id: &'a str,
117 #[serde(rename = "type")]
118 event_type: &'a str,
119 subject: &'a str,
120 created_at: &'a str,
121 data: &'a serde_json::Value,
122}
123
124pub(crate) fn signature_header(secret: &str, signed_at: i64, body: &[u8]) -> Option<String> {
133 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
134 mac.update(signed_at.to_string().as_bytes());
135 mac.update(b".");
136 mac.update(body);
137 Some(format!(
138 "t={signed_at},v1={}",
139 lower_hex(&mac.finalize().into_bytes())
140 ))
141}
142
143fn lower_hex(bytes: &[u8]) -> String {
145 let mut out = String::with_capacity(bytes.len() * 2);
146 for byte in bytes {
147 let _ = write!(out, "{byte:02x}");
149 }
150 out
151}
152
153pub(crate) fn backoff(attempts_made: i64) -> Duration {
156 let doublings = u32::try_from(attempts_made.saturating_sub(1).clamp(0, 63)).unwrap_or(63);
157 Duration::from_secs(
158 BACKOFF_BASE_SECS
159 .saturating_mul(1_u64 << doublings)
160 .min(BACKOFF_MAX_SECS),
161 )
162}
163
164struct Timestamps {
168 at: OffsetDateTime,
169 iso: String,
170 unix: i64,
171}
172
173fn now_timestamps(clock: &Arc<dyn Clock>) -> Timestamps {
178 let at = clock.now();
179 Timestamps {
180 at,
181 iso: iso(at),
182 unix: at.unix_timestamp(),
183 }
184}
185
186pub(crate) async fn drain(
207 ctx: &ModuleContext,
208 max_attempts: u32,
209) -> Result<DrainReport, DeliveryError> {
210 let db = ctx
211 .ports
212 .db
213 .clone()
214 .ok_or(DeliveryError::MissingPort("Database"))?;
215 let http = ctx
216 .ports
217 .http
218 .clone()
219 .ok_or(DeliveryError::MissingPort("HttpClient"))?;
220 let clock = ctx
223 .ports
224 .clock
225 .clone()
226 .ok_or(DeliveryError::MissingPort("Clock"))?;
227 let max_attempts = i64::from(
228 ModuleConfig::new(crate::MODULE_NAME, &*ctx.config).get_u32("MAX_ATTEMPTS", max_attempts),
229 );
230
231 let counters = Counters::default();
232 let budget: &ScheduledBudget = &ctx.scheduled;
233 let core_report = Outbox::new(store::OUTBOX)
234 .drain_within(
235 &*db,
236 &*clock,
237 budget,
238 DrainOptions {
239 limit: DRAIN_BATCH,
240 lease: time::Duration::seconds(LEASE_SECS),
241 subrequests_per_item: SUBREQUESTS_PER_DELIVERY,
242 },
243 |record| {
244 let db = Arc::clone(&db);
247 let http = Arc::clone(&http);
248 let clock = Arc::clone(&clock);
249 let counters = &counters;
250 async move {
251 let now = now_timestamps(&clock);
252 match deliver_one(&*db, &*http, &record, max_attempts, &now).await {
253 Ok(Filed::Delivered) => {
254 counters.delivered.fetch_add(1, Ordering::Relaxed);
255 Processed::Done
256 }
257 Ok(Filed::Retried(at)) => {
258 counters.retried.fetch_add(1, Ordering::Relaxed);
259 Processed::RetryAt(at)
260 }
261 Ok(Filed::DeadLettered) => {
262 counters.dead_lettered.fetch_add(1, Ordering::Relaxed);
263 Processed::Done
265 }
266 Ok(Filed::Dropped) => {
267 counters.dropped.fetch_add(1, Ordering::Relaxed);
268 Processed::Done
269 }
270 Err(error) => {
276 counters.failed.fetch_add(1, Ordering::Relaxed);
277 tracing::error!(row = %record.id, %error, "delivering a webhook failed");
278 Processed::NextAt(
279 now.at.saturating_add(time::Duration::seconds(LEASE_SECS)),
280 )
281 }
282 }
283 }
284 },
285 )
286 .await?;
287
288 Ok(DrainReport {
289 claimed: usize::try_from(core_report.processed).unwrap_or(usize::MAX),
290 delivered: counters.delivered.load(Ordering::Relaxed),
291 retried: counters.retried.load(Ordering::Relaxed),
292 dead_lettered: counters.dead_lettered.load(Ordering::Relaxed),
293 dropped: counters.dropped.load(Ordering::Relaxed),
294 failed: counters.failed.load(Ordering::Relaxed),
295 deferred: core_report.released,
296 })
297}
298
299async fn deliver_one(
307 db: &dyn cratefield_core::Database,
308 http: &dyn HttpClient,
309 record: &OutboxRecord,
310 max_attempts: i64,
311 now: &Timestamps,
312) -> Result<Filed, DeliveryError> {
313 let Ok(job) = serde_json::from_str::<DeliveryJob>(&record.payload) else {
314 return dead_letter_malformed(db, record, now).await;
315 };
316
317 let Some(target) = store::delivery_target(db, &job.subject, &job.endpoint_id).await? else {
323 return Ok(Filed::Dropped);
324 };
325
326 let body = serde_json::to_vec(&Envelope {
327 id: &job.event_id,
328 event_type: &job.event_type,
329 subject: &job.subject,
330 created_at: &job.created_at,
331 data: &job.data,
332 })
333 .map_err(|error| DeliveryError::Encode(error.to_string()))?;
334
335 let attempt = record.attempts + 1;
336 match post(http, &target.url, &target.secret, &job, &body, now.unix).await {
337 Ok(status) if status.is_success() => {
338 log_attempt(
339 db,
340 &job,
341 attempt,
342 Some(i64::from(status.as_u16())),
343 None,
344 now,
345 )
346 .await?;
347 Ok(Filed::Delivered)
349 }
350 Ok(StatusCode::GONE) => {
353 reject_now(
354 db,
355 &job,
356 record,
357 attempt,
358 "endpoint answered 410 Gone",
359 Some(i64::from(StatusCode::GONE.as_u16())),
360 now,
361 )
362 .await
363 }
364 Ok(status) => {
365 let why = format!("endpoint answered {status}");
366 retry_or_give_up(
367 db,
368 &job,
369 record,
370 now,
371 max_attempts,
372 Failure {
373 attempt,
374 why: &why,
375 status_code: Some(i64::from(status.as_u16())),
376 },
377 )
378 .await
379 }
380 Err(HttpError::BlockedDestination(why)) => {
384 let why = format!("destination refused by outbound policy: {why}");
385 reject_now(db, &job, record, attempt, &why, None, now).await
386 }
387 Err(error) => {
390 let why = error.to_string();
391 retry_or_give_up(
392 db,
393 &job,
394 record,
395 now,
396 max_attempts,
397 Failure {
398 attempt,
399 why: &why,
400 status_code: None,
401 },
402 )
403 .await
404 }
405 }
406}
407
408async fn dead_letter_malformed(
412 db: &dyn cratefield_core::Database,
413 record: &OutboxRecord,
414 now: &Timestamps,
415) -> Result<Filed, DeliveryError> {
416 tracing::error!(row = %record.id, "outbox row is not a webhooks delivery job");
417 let why = "outbox payload is not a webhooks delivery job";
418 let broken = broken_job(&record.payload, &record.topic);
419 log_attempt(db, &broken, 1, None, Some(why), now).await?;
420 store::dead_letter(
421 db,
422 &UlidIdGen.ulid(),
423 &broken,
424 &GiveUp {
425 outbox_row_id: &record.id,
426 attempts: record.attempts + 1,
427 reason: DeadLetterReason::Malformed,
428 last_error: why,
429 status_code: None,
430 created_at: &broken.created_at,
431 failed_at: &now.iso,
432 },
433 )
434 .await?;
435 Ok(Filed::DeadLettered)
436}
437
438async fn reject_now(
442 db: &dyn cratefield_core::Database,
443 job: &DeliveryJob,
444 record: &OutboxRecord,
445 attempt: i64,
446 why: &str,
447 status_code: Option<i64>,
448 now: &Timestamps,
449) -> Result<Filed, DeliveryError> {
450 log_attempt(db, job, attempt, status_code, Some(why), now).await?;
451 store::dead_letter(
452 db,
453 &UlidIdGen.ulid(),
454 job,
455 &GiveUp {
456 outbox_row_id: &record.id,
457 attempts: attempt,
458 reason: DeadLetterReason::Rejected,
459 last_error: why,
460 status_code,
461 created_at: &job.created_at,
462 failed_at: &now.iso,
463 },
464 )
465 .await?;
466 Ok(Filed::DeadLettered)
467}
468
469fn broken_job(payload: &str, topic: &str) -> DeliveryJob {
474 DeliveryJob {
475 endpoint_id: String::new(),
476 event_id: String::new(),
477 event_type: topic.to_owned(),
478 subject: String::new(),
479 created_at: String::new(),
480 data: serde_json::Value::String(payload.to_owned()),
481 }
482}
483
484async fn post(
487 http: &dyn HttpClient,
488 url: &str,
489 secret: &str,
490 job: &DeliveryJob,
491 body: &[u8],
492 unix_now: i64,
493) -> Result<StatusCode, HttpError> {
494 let signature = signature_header(secret, unix_now, body)
495 .ok_or_else(|| HttpError::Transport("signing failed".to_owned()))?;
496 let request = Request::builder()
497 .method(http::Method::POST)
498 .uri(url)
499 .header(CONTENT_TYPE, "application/json")
500 .header(SIGNATURE_HEADER, signature)
501 .header(EVENT_ID_HEADER, job.event_id.as_str())
502 .header(EVENT_TYPE_HEADER, job.event_type.as_str())
503 .body(Bytes::copy_from_slice(body))
504 .map_err(|error| HttpError::Transport(error.to_string()))?;
505 Ok(http.send(request).await?.status())
506}
507
508async fn log_attempt(
510 db: &dyn cratefield_core::Database,
511 job: &DeliveryJob,
512 attempt: i64,
513 status_code: Option<i64>,
514 error: Option<&str>,
515 now: &Timestamps,
516) -> Result<(), DbError> {
517 store::record_delivery(
518 db,
519 &UlidIdGen.ulid(),
520 job,
521 attempt,
522 status_code,
523 error,
524 &now.iso,
525 )
526 .await
527}
528
529struct Failure<'a> {
531 attempt: i64,
532 why: &'a str,
533 status_code: Option<i64>,
534}
535
536async fn retry_or_give_up(
540 db: &dyn cratefield_core::Database,
541 job: &DeliveryJob,
542 record: &OutboxRecord,
543 now: &Timestamps,
544 max_attempts: i64,
545 failure: Failure<'_>,
546) -> Result<Filed, DeliveryError> {
547 let (attempt, why, status_code) = (failure.attempt, failure.why, failure.status_code);
548 log_attempt(db, job, attempt, status_code, Some(why), now).await?;
549 if attempt >= max_attempts {
550 store::dead_letter(
551 db,
552 &UlidIdGen.ulid(),
553 job,
554 &GiveUp {
555 outbox_row_id: &record.id,
556 attempts: attempt,
557 reason: DeadLetterReason::AttemptsExhausted,
558 last_error: &format!("gave up after {attempt} attempts: {why}"),
559 status_code,
560 created_at: &job.created_at,
561 failed_at: &now.iso,
562 },
563 )
564 .await?;
565 return Ok(Filed::DeadLettered);
566 }
567 let delay = backoff(attempt);
569 let seconds = i64::try_from(delay.as_secs()).unwrap_or(i64::from(u32::MAX));
570 Ok(Filed::Retried(
571 now.at.saturating_add(time::Duration::seconds(seconds)),
572 ))
573}
574
575#[derive(Debug, Clone, PartialEq, Eq)]
577pub enum DeliveryError {
578 MissingPort(&'static str),
580 Database(DbError),
582 Encode(String),
585}
586
587impl std::fmt::Display for DeliveryError {
588 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589 match self {
590 DeliveryError::MissingPort(port) => write!(f, "webhooks: missing port {port}"),
591 DeliveryError::Database(error) => write!(f, "webhooks: {error}"),
592 DeliveryError::Encode(error) => write!(f, "webhooks: {error}"),
593 }
594 }
595}
596
597impl std::error::Error for DeliveryError {}
598
599impl From<DbError> for DeliveryError {
600 fn from(error: DbError) -> Self {
601 DeliveryError::Database(error)
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn the_signature_header_carries_a_bound_timestamp_and_lowercase_hex() {
611 let header =
612 signature_header("a test secret", 1_234, b"body bytes").expect("hmac accepts any key");
613 assert!(header.starts_with("t=1234,v1="), "{header}");
614 let hex = header.trim_start_matches("t=1234,v1=");
615 assert_eq!(hex.len(), 64, "SHA-256 in hex: {header}");
616 assert!(
617 hex.chars()
618 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
619 "the hex is lowercase: {header}"
620 );
621 }
622
623 #[test]
624 fn the_signature_binds_the_body_so_a_restamp_invalidates_it() {
625 let a = signature_header("secret", 1_234, b"one body").expect("signs");
626 let b = signature_header("secret", 1_234, b"another body").expect("signs");
627 let c = signature_header("secret", 1_235, b"one body").expect("signs");
628 assert_ne!(a, b, "a different body signs differently");
629 assert_ne!(a, c, "a different timestamp signs differently");
630 }
631
632 #[test]
633 fn backoff_doubles_from_thirty_seconds_to_the_one_hour_ceiling() {
634 assert_eq!(backoff(1), Duration::from_secs(30));
635 assert_eq!(backoff(2), Duration::from_secs(60));
636 assert_eq!(backoff(3), Duration::from_secs(120));
637 assert_eq!(backoff(8), Duration::from_secs(3_600));
638 assert_eq!(backoff(20), Duration::from_secs(3_600), "capped");
639 assert_eq!(backoff(0), Duration::from_secs(30), "floor at one attempt");
640 }
641
642 #[test]
643 fn the_envelope_names_the_event_its_subject_and_its_data() {
644 let job = store::test_job();
645 let body = serde_json::to_vec(&Envelope {
646 id: &job.event_id,
647 event_type: &job.event_type,
648 subject: &job.subject,
649 created_at: "2026-09-27T00:00:00Z",
650 data: &job.data,
651 })
652 .expect("serialises");
653 let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON");
654 assert_eq!(parsed["id"], "ev");
655 assert_eq!(parsed["type"], "order.paid");
656 assert_eq!(parsed["subject"], "acct");
657 assert_eq!(parsed["created_at"], "2026-09-27T00:00:00Z");
658 assert_eq!(parsed["data"]["amount"], 1);
659 }
660
661 #[test]
664 fn a_receiver_recomputing_the_mac_verifies_the_delivery() {
665 let secret = "whsec_generated_by_create_endpoint";
666 let body = b"{\"id\":\"ev\",\"type\":\"order.paid\"}";
667 let signed_at = 1_792_000_000;
668 let header = signature_header(secret, signed_at, body).expect("signs");
669
670 let (t, v1) = header
671 .split_once(",v1=")
672 .map(|(t, v1)| (t.trim_start_matches("t="), v1))
673 .expect("the header shape");
674 assert_eq!(t, "1792000000");
675
676 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key");
677 mac.update(t.as_bytes());
678 mac.update(b".");
679 mac.update(body);
680 assert_eq!(lower_hex(&mac.finalize().into_bytes()), v1);
681 }
682
683 #[test]
684 fn dead_letter_reasons_round_trip_their_column_values() {
685 for (reason, stored) in [
686 (DeadLetterReason::AttemptsExhausted, "attempts_exhausted"),
687 (DeadLetterReason::Rejected, "rejected"),
688 (DeadLetterReason::Malformed, "malformed"),
689 ] {
690 assert_eq!(reason.as_str(), stored);
691 }
692 }
693}