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(
209 ctx: &ModuleContext,
210 max_attempts: u32,
211) -> Result<DrainReport, DeliveryError> {
212 let db = ctx
213 .ports
214 .db
215 .clone()
216 .ok_or(DeliveryError::MissingPort("Database"))?;
217 let http = ctx
218 .ports
219 .http
220 .clone()
221 .ok_or(DeliveryError::MissingPort("HttpClient"))?;
222 let clock = ctx
225 .ports
226 .clock
227 .clone()
228 .ok_or(DeliveryError::MissingPort("Clock"))?;
229 let max_attempts = i64::from(
230 ModuleConfig::new(crate::MODULE_NAME, &*ctx.config).get_u32("MAX_ATTEMPTS", max_attempts),
231 );
232 let alias = crate::signature_alias(&*ctx.config).map_err(DeliveryError::Config)?;
235 let signature_headers: Vec<&str> = std::iter::once(SIGNATURE_HEADER)
236 .chain(alias.as_deref())
237 .collect();
238
239 let counters = Counters::default();
240 let budget: &ScheduledBudget = &ctx.scheduled;
241 let core_report = Outbox::new(store::OUTBOX)
242 .drain_within(
243 &*db,
244 &*clock,
245 budget,
246 DrainOptions {
247 limit: DRAIN_BATCH,
248 lease: time::Duration::seconds(LEASE_SECS),
249 subrequests_per_item: SUBREQUESTS_PER_DELIVERY,
250 },
251 |record| {
252 let db = Arc::clone(&db);
255 let http = Arc::clone(&http);
256 let clock = Arc::clone(&clock);
257 let counters = &counters;
258 let signature_headers = signature_headers.as_slice();
259 async move {
260 let now = now_timestamps(&clock);
261 match deliver_one(&*db, &*http, &record, max_attempts, &now, signature_headers)
262 .await
263 {
264 Ok(Filed::Delivered) => {
265 counters.delivered.fetch_add(1, Ordering::Relaxed);
266 Processed::Done
267 }
268 Ok(Filed::Retried(at)) => {
269 counters.retried.fetch_add(1, Ordering::Relaxed);
270 Processed::RetryAt(at)
271 }
272 Ok(Filed::DeadLettered) => {
273 counters.dead_lettered.fetch_add(1, Ordering::Relaxed);
274 Processed::Done
276 }
277 Ok(Filed::Dropped) => {
278 counters.dropped.fetch_add(1, Ordering::Relaxed);
279 Processed::Done
280 }
281 Err(error) => {
287 counters.failed.fetch_add(1, Ordering::Relaxed);
288 tracing::error!(row = %record.id, %error, "delivering a webhook failed");
289 Processed::NextAt(
290 now.at.saturating_add(time::Duration::seconds(LEASE_SECS)),
291 )
292 }
293 }
294 }
295 },
296 )
297 .await?;
298
299 Ok(DrainReport {
300 claimed: usize::try_from(core_report.processed).unwrap_or(usize::MAX),
301 delivered: counters.delivered.load(Ordering::Relaxed),
302 retried: counters.retried.load(Ordering::Relaxed),
303 dead_lettered: counters.dead_lettered.load(Ordering::Relaxed),
304 dropped: counters.dropped.load(Ordering::Relaxed),
305 failed: counters.failed.load(Ordering::Relaxed),
306 deferred: core_report.released,
307 })
308}
309
310async fn deliver_one(
318 db: &dyn cratefield_core::Database,
319 http: &dyn HttpClient,
320 record: &OutboxRecord,
321 max_attempts: i64,
322 now: &Timestamps,
323 signature_headers: &[&str],
324) -> Result<Filed, DeliveryError> {
325 let Ok(job) = serde_json::from_str::<DeliveryJob>(&record.payload) else {
326 return dead_letter_malformed(db, record, now).await;
327 };
328
329 let Some(target) = store::delivery_target(db, &job.subject, &job.endpoint_id).await? else {
335 return Ok(Filed::Dropped);
336 };
337
338 let body = serde_json::to_vec(&Envelope {
339 id: &job.event_id,
340 event_type: &job.event_type,
341 subject: &job.subject,
342 created_at: &job.created_at,
343 data: &job.data,
344 })
345 .map_err(|error| DeliveryError::Encode(error.to_string()))?;
346
347 let attempt = record.attempts + 1;
348 match post(
349 http,
350 &target.url,
351 &target.secret,
352 &job,
353 &body,
354 now.unix,
355 signature_headers,
356 )
357 .await
358 {
359 Ok(status) if status.is_success() => {
360 log_attempt(
361 db,
362 &job,
363 attempt,
364 Some(i64::from(status.as_u16())),
365 None,
366 now,
367 )
368 .await?;
369 Ok(Filed::Delivered)
371 }
372 Ok(StatusCode::GONE) => {
375 reject_now(
376 db,
377 &job,
378 record,
379 attempt,
380 "endpoint answered 410 Gone",
381 Some(i64::from(StatusCode::GONE.as_u16())),
382 now,
383 )
384 .await
385 }
386 Ok(status) => {
387 let why = format!("endpoint answered {status}");
388 retry_or_give_up(
389 db,
390 &job,
391 record,
392 now,
393 max_attempts,
394 Failure {
395 attempt,
396 why: &why,
397 status_code: Some(i64::from(status.as_u16())),
398 },
399 )
400 .await
401 }
402 Err(HttpError::BlockedDestination(why)) => {
406 let why = format!("destination refused by outbound policy: {why}");
407 reject_now(db, &job, record, attempt, &why, None, now).await
408 }
409 Err(error) => {
412 let why = error.to_string();
413 retry_or_give_up(
414 db,
415 &job,
416 record,
417 now,
418 max_attempts,
419 Failure {
420 attempt,
421 why: &why,
422 status_code: None,
423 },
424 )
425 .await
426 }
427 }
428}
429
430async fn dead_letter_malformed(
434 db: &dyn cratefield_core::Database,
435 record: &OutboxRecord,
436 now: &Timestamps,
437) -> Result<Filed, DeliveryError> {
438 tracing::error!(row = %record.id, "outbox row is not a webhooks delivery job");
439 let why = "outbox payload is not a webhooks delivery job";
440 let broken = broken_job(&record.payload, &record.topic);
441 log_attempt(db, &broken, 1, None, Some(why), now).await?;
442 store::dead_letter(
443 db,
444 &UlidIdGen.ulid(),
445 &broken,
446 &GiveUp {
447 outbox_row_id: &record.id,
448 attempts: record.attempts + 1,
449 reason: DeadLetterReason::Malformed,
450 last_error: why,
451 status_code: None,
452 created_at: &broken.created_at,
453 failed_at: &now.iso,
454 },
455 )
456 .await?;
457 Ok(Filed::DeadLettered)
458}
459
460async fn reject_now(
464 db: &dyn cratefield_core::Database,
465 job: &DeliveryJob,
466 record: &OutboxRecord,
467 attempt: i64,
468 why: &str,
469 status_code: Option<i64>,
470 now: &Timestamps,
471) -> Result<Filed, DeliveryError> {
472 log_attempt(db, job, attempt, status_code, Some(why), now).await?;
473 store::dead_letter(
474 db,
475 &UlidIdGen.ulid(),
476 job,
477 &GiveUp {
478 outbox_row_id: &record.id,
479 attempts: attempt,
480 reason: DeadLetterReason::Rejected,
481 last_error: why,
482 status_code,
483 created_at: &job.created_at,
484 failed_at: &now.iso,
485 },
486 )
487 .await?;
488 Ok(Filed::DeadLettered)
489}
490
491fn broken_job(payload: &str, topic: &str) -> DeliveryJob {
496 DeliveryJob {
497 endpoint_id: String::new(),
498 event_id: String::new(),
499 event_type: topic.to_owned(),
500 subject: String::new(),
501 created_at: String::new(),
502 data: serde_json::Value::String(payload.to_owned()),
503 }
504}
505
506async fn post(
509 http: &dyn HttpClient,
510 url: &str,
511 secret: &str,
512 job: &DeliveryJob,
513 body: &[u8],
514 unix_now: i64,
515 signature_headers: &[&str],
516) -> Result<StatusCode, HttpError> {
517 let signature = signature_header(secret, unix_now, body)
518 .ok_or_else(|| HttpError::Transport("signing failed".to_owned()))?;
519 let mut builder = Request::builder()
520 .method(http::Method::POST)
521 .uri(url)
522 .header(CONTENT_TYPE, "application/json");
523 for name in signature_headers {
525 builder = builder.header(*name, signature.clone());
526 }
527 let request = builder
528 .header(EVENT_ID_HEADER, job.event_id.as_str())
529 .header(EVENT_TYPE_HEADER, job.event_type.as_str())
530 .body(Bytes::copy_from_slice(body))
531 .map_err(|error| HttpError::Transport(error.to_string()))?;
532 Ok(http.send(request).await?.status())
533}
534
535async fn log_attempt(
537 db: &dyn cratefield_core::Database,
538 job: &DeliveryJob,
539 attempt: i64,
540 status_code: Option<i64>,
541 error: Option<&str>,
542 now: &Timestamps,
543) -> Result<(), DbError> {
544 store::record_delivery(
545 db,
546 &UlidIdGen.ulid(),
547 job,
548 attempt,
549 status_code,
550 error,
551 &now.iso,
552 )
553 .await
554}
555
556struct Failure<'a> {
558 attempt: i64,
559 why: &'a str,
560 status_code: Option<i64>,
561}
562
563async fn retry_or_give_up(
567 db: &dyn cratefield_core::Database,
568 job: &DeliveryJob,
569 record: &OutboxRecord,
570 now: &Timestamps,
571 max_attempts: i64,
572 failure: Failure<'_>,
573) -> Result<Filed, DeliveryError> {
574 let (attempt, why, status_code) = (failure.attempt, failure.why, failure.status_code);
575 log_attempt(db, job, attempt, status_code, Some(why), now).await?;
576 if attempt >= max_attempts {
577 store::dead_letter(
578 db,
579 &UlidIdGen.ulid(),
580 job,
581 &GiveUp {
582 outbox_row_id: &record.id,
583 attempts: attempt,
584 reason: DeadLetterReason::AttemptsExhausted,
585 last_error: &format!("gave up after {attempt} attempts: {why}"),
586 status_code,
587 created_at: &job.created_at,
588 failed_at: &now.iso,
589 },
590 )
591 .await?;
592 return Ok(Filed::DeadLettered);
593 }
594 let delay = backoff(attempt);
596 let seconds = i64::try_from(delay.as_secs()).unwrap_or(i64::from(u32::MAX));
597 Ok(Filed::Retried(
598 now.at.saturating_add(time::Duration::seconds(seconds)),
599 ))
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum DeliveryError {
605 MissingPort(&'static str),
607 Database(DbError),
609 Encode(String),
612 Config(String),
615}
616
617impl std::fmt::Display for DeliveryError {
618 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619 match self {
620 DeliveryError::MissingPort(port) => write!(f, "webhooks: missing port {port}"),
621 DeliveryError::Database(error) => write!(f, "webhooks: {error}"),
622 DeliveryError::Encode(error) => write!(f, "webhooks: {error}"),
623 DeliveryError::Config(problem) => f.write_str(problem),
624 }
625 }
626}
627
628impl std::error::Error for DeliveryError {}
629
630impl From<DbError> for DeliveryError {
631 fn from(error: DbError) -> Self {
632 DeliveryError::Database(error)
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 #[test]
641 fn the_signature_header_carries_a_bound_timestamp_and_lowercase_hex() {
642 let header =
643 signature_header("a test secret", 1_234, b"body bytes").expect("hmac accepts any key");
644 assert!(header.starts_with("t=1234,v1="), "{header}");
645 let hex = header.trim_start_matches("t=1234,v1=");
646 assert_eq!(hex.len(), 64, "SHA-256 in hex: {header}");
647 assert!(
648 hex.chars()
649 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
650 "the hex is lowercase: {header}"
651 );
652 }
653
654 #[test]
655 fn the_signature_binds_the_body_so_a_restamp_invalidates_it() {
656 let a = signature_header("secret", 1_234, b"one body").expect("signs");
657 let b = signature_header("secret", 1_234, b"another body").expect("signs");
658 let c = signature_header("secret", 1_235, b"one body").expect("signs");
659 assert_ne!(a, b, "a different body signs differently");
660 assert_ne!(a, c, "a different timestamp signs differently");
661 }
662
663 #[test]
664 fn backoff_doubles_from_thirty_seconds_to_the_one_hour_ceiling() {
665 assert_eq!(backoff(1), Duration::from_secs(30));
666 assert_eq!(backoff(2), Duration::from_secs(60));
667 assert_eq!(backoff(3), Duration::from_secs(120));
668 assert_eq!(backoff(8), Duration::from_secs(3_600));
669 assert_eq!(backoff(20), Duration::from_secs(3_600), "capped");
670 assert_eq!(backoff(0), Duration::from_secs(30), "floor at one attempt");
671 }
672
673 #[test]
674 fn the_envelope_names_the_event_its_subject_and_its_data() {
675 let job = store::test_job();
676 let body = serde_json::to_vec(&Envelope {
677 id: &job.event_id,
678 event_type: &job.event_type,
679 subject: &job.subject,
680 created_at: "2026-09-27T00:00:00Z",
681 data: &job.data,
682 })
683 .expect("serialises");
684 let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON");
685 assert_eq!(parsed["id"], "ev");
686 assert_eq!(parsed["type"], "order.paid");
687 assert_eq!(parsed["subject"], "acct");
688 assert_eq!(parsed["created_at"], "2026-09-27T00:00:00Z");
689 assert_eq!(parsed["data"]["amount"], 1);
690 }
691
692 #[test]
695 fn a_receiver_recomputing_the_mac_verifies_the_delivery() {
696 let secret = "whsec_generated_by_create_endpoint";
697 let body = b"{\"id\":\"ev\",\"type\":\"order.paid\"}";
698 let signed_at = 1_792_000_000;
699 let header = signature_header(secret, signed_at, body).expect("signs");
700
701 let (t, v1) = header
702 .split_once(",v1=")
703 .map(|(t, v1)| (t.trim_start_matches("t="), v1))
704 .expect("the header shape");
705 assert_eq!(t, "1792000000");
706
707 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key");
708 mac.update(t.as_bytes());
709 mac.update(b".");
710 mac.update(body);
711 assert_eq!(lower_hex(&mac.finalize().into_bytes()), v1);
712 }
713
714 #[test]
715 fn dead_letter_reasons_round_trip_their_column_values() {
716 for (reason, stored) in [
717 (DeadLetterReason::AttemptsExhausted, "attempts_exhausted"),
718 (DeadLetterReason::Rejected, "rejected"),
719 (DeadLetterReason::Malformed, "malformed"),
720 ] {
721 assert_eq!(reason.as_str(), stored);
722 }
723 }
724}