1use bytes::Bytes;
6use cratefield_core::{
7 Clock, DbError, HttpClient, HttpError, IdGen, ModuleConfig, ModuleContext, Outbox,
8 OutboxRecord, UlidIdGen,
9};
10use hmac::{Hmac, KeyInit, Mac};
11use http::header::CONTENT_TYPE;
12use http::{Request, StatusCode};
13use serde::Serialize;
14use sha2::Sha256;
15use std::fmt::Write as _;
16use std::sync::Arc;
17use std::time::Duration;
18
19use crate::clock::{iso, plus_secs};
20use crate::store::{self, DeadLetterReason, DeliveryJob, GiveUp};
21
22pub const SIGNATURE_HEADER: &str = "Cratefield-Signature";
27pub const EVENT_ID_HEADER: &str = "Cratefield-Event-Id";
30pub const EVENT_TYPE_HEADER: &str = "Cratefield-Event-Type";
32
33const BACKOFF_BASE_SECS: u64 = 30;
35const BACKOFF_MAX_SECS: u64 = 3_600;
37const LEASE_SECS: i64 = 300;
39const DRAIN_BATCH: u64 = 16;
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub struct DrainReport {
51 pub claimed: usize,
53 pub delivered: usize,
55 pub retried: usize,
57 pub dead_lettered: usize,
59 pub dropped: usize,
62 pub failed: usize,
65}
66
67impl DrainReport {
68 fn record(&mut self, outcome: Outcome) {
70 match outcome {
71 Outcome::Delivered => self.delivered += 1,
72 Outcome::Retried => self.retried += 1,
73 Outcome::DeadLettered => self.dead_lettered += 1,
74 Outcome::Dropped => self.dropped += 1,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Outcome {
82 Delivered,
83 Retried,
84 DeadLettered,
85 Dropped,
86}
87
88#[derive(Serialize)]
92struct Envelope<'a> {
93 id: &'a str,
94 #[serde(rename = "type")]
95 event_type: &'a str,
96 subject: &'a str,
97 created_at: &'a str,
98 data: &'a serde_json::Value,
99}
100
101pub(crate) fn signature_header(secret: &str, signed_at: i64, body: &[u8]) -> Option<String> {
110 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
111 mac.update(signed_at.to_string().as_bytes());
112 mac.update(b".");
113 mac.update(body);
114 Some(format!(
115 "t={signed_at},v1={}",
116 lower_hex(&mac.finalize().into_bytes())
117 ))
118}
119
120fn lower_hex(bytes: &[u8]) -> String {
122 let mut out = String::with_capacity(bytes.len() * 2);
123 for byte in bytes {
124 let _ = write!(out, "{byte:02x}");
126 }
127 out
128}
129
130pub(crate) fn backoff(attempts_made: i64) -> Duration {
133 let doublings = u32::try_from(attempts_made.saturating_sub(1).clamp(0, 63)).unwrap_or(63);
134 Duration::from_secs(
135 BACKOFF_BASE_SECS
136 .saturating_mul(1_u64 << doublings)
137 .min(BACKOFF_MAX_SECS),
138 )
139}
140
141struct Timestamps {
144 iso: String,
145 unix: i64,
146}
147
148fn now_timestamps(clock: &Arc<dyn Clock>) -> Timestamps {
153 let at = clock.now();
154 Timestamps {
155 iso: iso(at),
156 unix: at.unix_timestamp(),
157 }
158}
159
160pub(crate) async fn drain(
171 ctx: &ModuleContext,
172 max_attempts: u32,
173) -> Result<DrainReport, DeliveryError> {
174 let db = ctx
175 .ports
176 .db
177 .clone()
178 .ok_or(DeliveryError::MissingPort("Database"))?;
179 let http = ctx
180 .ports
181 .http
182 .clone()
183 .ok_or(DeliveryError::MissingPort("HttpClient"))?;
184 let clock = ctx
187 .ports
188 .clock
189 .clone()
190 .ok_or(DeliveryError::MissingPort("Clock"))?;
191 let max_attempts = i64::from(
192 ModuleConfig::new(crate::MODULE_NAME, &*ctx.config).get_u32("MAX_ATTEMPTS", max_attempts),
193 );
194
195 let now = now_timestamps(&clock);
196 let lease_until = plus_secs(&now.iso, LEASE_SECS);
197 let records = Outbox::new(store::OUTBOX)
198 .claim_due(&*db, &now.iso, &lease_until, DRAIN_BATCH)
199 .await?;
200
201 let mut report = DrainReport {
202 claimed: records.len(),
203 ..DrainReport::default()
204 };
205 for record in &records {
206 match deliver_one(&*db, &*http, record, max_attempts, &now).await {
207 Ok(outcome) => report.record(outcome),
208 Err(error) => {
213 report.failed += 1;
214 tracing::error!(row = %record.id, %error, "delivering a webhook failed");
215 }
216 }
217 }
218 Ok(report)
219}
220
221async fn deliver_one(
223 db: &dyn cratefield_core::Database,
224 http: &dyn HttpClient,
225 record: &OutboxRecord,
226 max_attempts: i64,
227 now: &Timestamps,
228) -> Result<Outcome, DeliveryError> {
229 let Ok(job) = serde_json::from_str::<DeliveryJob>(&record.payload) else {
230 return dead_letter_malformed(db, record, now).await;
231 };
232
233 let Some(target) = store::delivery_target(db, &job.subject, &job.endpoint_id).await? else {
239 db.execute(&store::delete_outbox_statement(&record.id))
240 .await?;
241 return Ok(Outcome::Dropped);
242 };
243
244 let body = serde_json::to_vec(&Envelope {
245 id: &job.event_id,
246 event_type: &job.event_type,
247 subject: &job.subject,
248 created_at: &job.created_at,
249 data: &job.data,
250 })
251 .map_err(|error| DeliveryError::Encode(error.to_string()))?;
252
253 let attempt = record.attempts + 1;
254 match post(http, &target.url, &target.secret, &job, &body, now.unix).await {
255 Ok(status) if status.is_success() => {
256 log_attempt(
257 db,
258 &job,
259 attempt,
260 Some(i64::from(status.as_u16())),
261 None,
262 now,
263 )
264 .await?;
265 Outbox::new(store::OUTBOX).complete(db, &record.id).await?;
266 Ok(Outcome::Delivered)
267 }
268 Ok(StatusCode::GONE) => {
271 reject_now(
272 db,
273 &job,
274 record,
275 attempt,
276 "endpoint answered 410 Gone",
277 Some(i64::from(StatusCode::GONE.as_u16())),
278 now,
279 )
280 .await
281 }
282 Ok(status) => {
283 let why = format!("endpoint answered {status}");
284 retry_or_give_up(
285 db,
286 &job,
287 record,
288 now,
289 max_attempts,
290 Failure {
291 attempt,
292 why: &why,
293 status_code: Some(i64::from(status.as_u16())),
294 },
295 )
296 .await
297 }
298 Err(HttpError::BlockedDestination(why)) => {
302 let why = format!("destination refused by outbound policy: {why}");
303 reject_now(db, &job, record, attempt, &why, None, now).await
304 }
305 Err(error) => {
308 let why = error.to_string();
309 retry_or_give_up(
310 db,
311 &job,
312 record,
313 now,
314 max_attempts,
315 Failure {
316 attempt,
317 why: &why,
318 status_code: None,
319 },
320 )
321 .await
322 }
323 }
324}
325
326async fn dead_letter_malformed(
330 db: &dyn cratefield_core::Database,
331 record: &OutboxRecord,
332 now: &Timestamps,
333) -> Result<Outcome, DeliveryError> {
334 tracing::error!(row = %record.id, "outbox row is not a webhooks delivery job");
335 let why = "outbox payload is not a webhooks delivery job";
336 let broken = broken_job(&record.payload, &record.topic);
337 log_attempt(db, &broken, 1, None, Some(why), now).await?;
338 store::dead_letter(
339 db,
340 &UlidIdGen.ulid(),
341 &broken,
342 &GiveUp {
343 outbox_row_id: &record.id,
344 attempts: record.attempts + 1,
345 reason: DeadLetterReason::Malformed,
346 last_error: why,
347 status_code: None,
348 created_at: &broken.created_at,
349 failed_at: &now.iso,
350 },
351 )
352 .await?;
353 Ok(Outcome::DeadLettered)
354}
355
356async fn reject_now(
360 db: &dyn cratefield_core::Database,
361 job: &DeliveryJob,
362 record: &OutboxRecord,
363 attempt: i64,
364 why: &str,
365 status_code: Option<i64>,
366 now: &Timestamps,
367) -> Result<Outcome, DeliveryError> {
368 log_attempt(db, job, attempt, status_code, Some(why), now).await?;
369 store::dead_letter(
370 db,
371 &UlidIdGen.ulid(),
372 job,
373 &GiveUp {
374 outbox_row_id: &record.id,
375 attempts: attempt,
376 reason: DeadLetterReason::Rejected,
377 last_error: why,
378 status_code,
379 created_at: &job.created_at,
380 failed_at: &now.iso,
381 },
382 )
383 .await?;
384 Ok(Outcome::DeadLettered)
385}
386
387fn broken_job(payload: &str, topic: &str) -> DeliveryJob {
392 DeliveryJob {
393 endpoint_id: String::new(),
394 event_id: String::new(),
395 event_type: topic.to_owned(),
396 subject: String::new(),
397 created_at: String::new(),
398 data: serde_json::Value::String(payload.to_owned()),
399 }
400}
401
402async fn post(
405 http: &dyn HttpClient,
406 url: &str,
407 secret: &str,
408 job: &DeliveryJob,
409 body: &[u8],
410 unix_now: i64,
411) -> Result<StatusCode, HttpError> {
412 let signature = signature_header(secret, unix_now, body)
413 .ok_or_else(|| HttpError::Transport("signing failed".to_owned()))?;
414 let request = Request::builder()
415 .method(http::Method::POST)
416 .uri(url)
417 .header(CONTENT_TYPE, "application/json")
418 .header(SIGNATURE_HEADER, signature)
419 .header(EVENT_ID_HEADER, job.event_id.as_str())
420 .header(EVENT_TYPE_HEADER, job.event_type.as_str())
421 .body(Bytes::copy_from_slice(body))
422 .map_err(|error| HttpError::Transport(error.to_string()))?;
423 Ok(http.send(request).await?.status())
424}
425
426async fn log_attempt(
428 db: &dyn cratefield_core::Database,
429 job: &DeliveryJob,
430 attempt: i64,
431 status_code: Option<i64>,
432 error: Option<&str>,
433 now: &Timestamps,
434) -> Result<(), DbError> {
435 store::record_delivery(
436 db,
437 &UlidIdGen.ulid(),
438 job,
439 attempt,
440 status_code,
441 error,
442 &now.iso,
443 )
444 .await
445}
446
447struct Failure<'a> {
449 attempt: i64,
450 why: &'a str,
451 status_code: Option<i64>,
452}
453
454async fn retry_or_give_up(
458 db: &dyn cratefield_core::Database,
459 job: &DeliveryJob,
460 record: &OutboxRecord,
461 now: &Timestamps,
462 max_attempts: i64,
463 failure: Failure<'_>,
464) -> Result<Outcome, DeliveryError> {
465 let (attempt, why, status_code) = (failure.attempt, failure.why, failure.status_code);
466 log_attempt(db, job, attempt, status_code, Some(why), now).await?;
467 if attempt >= max_attempts {
468 store::dead_letter(
469 db,
470 &UlidIdGen.ulid(),
471 job,
472 &GiveUp {
473 outbox_row_id: &record.id,
474 attempts: attempt,
475 reason: DeadLetterReason::AttemptsExhausted,
476 last_error: &format!("gave up after {attempt} attempts: {why}"),
477 status_code,
478 created_at: &job.created_at,
479 failed_at: &now.iso,
480 },
481 )
482 .await?;
483 return Ok(Outcome::DeadLettered);
484 }
485 let delay = backoff(attempt);
486 let seconds = i64::try_from(delay.as_secs()).unwrap_or(i64::from(u32::MAX));
487 Outbox::new(store::OUTBOX)
488 .retry_later(db, &record.id, &plus_secs(&now.iso, seconds))
489 .await?;
490 Ok(Outcome::Retried)
491}
492
493#[derive(Debug, Clone, PartialEq, Eq)]
495pub enum DeliveryError {
496 MissingPort(&'static str),
498 Database(DbError),
500 Encode(String),
503}
504
505impl std::fmt::Display for DeliveryError {
506 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507 match self {
508 DeliveryError::MissingPort(port) => write!(f, "webhooks: missing port {port}"),
509 DeliveryError::Database(error) => write!(f, "webhooks: {error}"),
510 DeliveryError::Encode(error) => write!(f, "webhooks: {error}"),
511 }
512 }
513}
514
515impl std::error::Error for DeliveryError {}
516
517impl From<DbError> for DeliveryError {
518 fn from(error: DbError) -> Self {
519 DeliveryError::Database(error)
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn the_signature_header_carries_a_bound_timestamp_and_lowercase_hex() {
529 let header =
530 signature_header("a test secret", 1_234, b"body bytes").expect("hmac accepts any key");
531 assert!(header.starts_with("t=1234,v1="), "{header}");
532 let hex = header.trim_start_matches("t=1234,v1=");
533 assert_eq!(hex.len(), 64, "SHA-256 in hex: {header}");
534 assert!(
535 hex.chars()
536 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
537 "the hex is lowercase: {header}"
538 );
539 }
540
541 #[test]
542 fn the_signature_binds_the_body_so_a_restamp_invalidates_it() {
543 let a = signature_header("secret", 1_234, b"one body").expect("signs");
544 let b = signature_header("secret", 1_234, b"another body").expect("signs");
545 let c = signature_header("secret", 1_235, b"one body").expect("signs");
546 assert_ne!(a, b, "a different body signs differently");
547 assert_ne!(a, c, "a different timestamp signs differently");
548 }
549
550 #[test]
551 fn backoff_doubles_from_thirty_seconds_to_the_one_hour_ceiling() {
552 assert_eq!(backoff(1), Duration::from_secs(30));
553 assert_eq!(backoff(2), Duration::from_secs(60));
554 assert_eq!(backoff(3), Duration::from_secs(120));
555 assert_eq!(backoff(8), Duration::from_secs(3_600));
556 assert_eq!(backoff(20), Duration::from_secs(3_600), "capped");
557 assert_eq!(backoff(0), Duration::from_secs(30), "floor at one attempt");
558 }
559
560 #[test]
561 fn the_envelope_names_the_event_its_subject_and_its_data() {
562 let job = store::test_job();
563 let body = serde_json::to_vec(&Envelope {
564 id: &job.event_id,
565 event_type: &job.event_type,
566 subject: &job.subject,
567 created_at: "2026-09-27T00:00:00Z",
568 data: &job.data,
569 })
570 .expect("serialises");
571 let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON");
572 assert_eq!(parsed["id"], "ev");
573 assert_eq!(parsed["type"], "order.paid");
574 assert_eq!(parsed["subject"], "acct");
575 assert_eq!(parsed["created_at"], "2026-09-27T00:00:00Z");
576 assert_eq!(parsed["data"]["amount"], 1);
577 }
578
579 #[test]
582 fn a_receiver_recomputing_the_mac_verifies_the_delivery() {
583 let secret = "whsec_generated_by_create_endpoint";
584 let body = b"{\"id\":\"ev\",\"type\":\"order.paid\"}";
585 let signed_at = 1_792_000_000;
586 let header = signature_header(secret, signed_at, body).expect("signs");
587
588 let (t, v1) = header
589 .split_once(",v1=")
590 .map(|(t, v1)| (t.trim_start_matches("t="), v1))
591 .expect("the header shape");
592 assert_eq!(t, "1792000000");
593
594 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key");
595 mac.update(t.as_bytes());
596 mac.update(b".");
597 mac.update(body);
598 assert_eq!(lower_hex(&mac.finalize().into_bytes()), v1);
599 }
600
601 #[test]
602 fn dead_letter_reasons_round_trip_their_column_values() {
603 for (reason, stored) in [
604 (DeadLetterReason::AttemptsExhausted, "attempts_exhausted"),
605 (DeadLetterReason::Rejected, "rejected"),
606 (DeadLetterReason::Malformed, "malformed"),
607 ] {
608 assert_eq!(reason.as_str(), stored);
609 }
610 }
611}