use bytes::Bytes;
use cratefield_core::{
Clock, DbError, DrainOptions, HttpClient, HttpError, IdGen, ModuleConfig, ModuleContext,
Outbox, OutboxRecord, Processed, ScheduledBudget, UlidIdGen,
};
use hmac::{Hmac, KeyInit, Mac};
use http::header::CONTENT_TYPE;
use http::{Request, StatusCode};
use serde::Serialize;
use sha2::Sha256;
use std::fmt::Write as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use time::OffsetDateTime;
use crate::clock::iso;
use crate::store::{self, DeadLetterReason, DeliveryJob, GiveUp};
pub const SIGNATURE_HEADER: &str = "Cratefield-Signature";
pub const EVENT_ID_HEADER: &str = "Cratefield-Event-Id";
pub const EVENT_TYPE_HEADER: &str = "Cratefield-Event-Type";
const BACKOFF_BASE_SECS: u64 = 30;
const BACKOFF_MAX_SECS: u64 = 3_600;
const LEASE_SECS: i64 = 300;
const DRAIN_BATCH: u64 = 16;
const SUBREQUESTS_PER_DELIVERY: u32 = 1;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DrainReport {
pub claimed: usize,
pub delivered: usize,
pub retried: usize,
pub dead_lettered: usize,
pub dropped: usize,
pub failed: usize,
pub deferred: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Filed {
Delivered,
Retried(OffsetDateTime),
DeadLettered,
Dropped,
}
#[derive(Debug, Default)]
struct Counters {
delivered: AtomicUsize,
retried: AtomicUsize,
dead_lettered: AtomicUsize,
dropped: AtomicUsize,
failed: AtomicUsize,
}
#[derive(Serialize)]
struct Envelope<'a> {
id: &'a str,
#[serde(rename = "type")]
event_type: &'a str,
subject: &'a str,
created_at: &'a str,
data: &'a serde_json::Value,
}
pub(crate) fn signature_header(secret: &str, signed_at: i64, body: &[u8]) -> Option<String> {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
mac.update(signed_at.to_string().as_bytes());
mac.update(b".");
mac.update(body);
Some(format!(
"t={signed_at},v1={}",
lower_hex(&mac.finalize().into_bytes())
))
}
fn lower_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
pub(crate) fn backoff(attempts_made: i64) -> Duration {
let doublings = u32::try_from(attempts_made.saturating_sub(1).clamp(0, 63)).unwrap_or(63);
Duration::from_secs(
BACKOFF_BASE_SECS
.saturating_mul(1_u64 << doublings)
.min(BACKOFF_MAX_SECS),
)
}
struct Timestamps {
at: OffsetDateTime,
iso: String,
unix: i64,
}
fn now_timestamps(clock: &Arc<dyn Clock>) -> Timestamps {
let at = clock.now();
Timestamps {
at,
iso: iso(at),
unix: at.unix_timestamp(),
}
}
pub(crate) async fn drain(
ctx: &ModuleContext,
max_attempts: u32,
) -> Result<DrainReport, DeliveryError> {
let db = ctx
.ports
.db
.clone()
.ok_or(DeliveryError::MissingPort("Database"))?;
let http = ctx
.ports
.http
.clone()
.ok_or(DeliveryError::MissingPort("HttpClient"))?;
let clock = ctx
.ports
.clock
.clone()
.ok_or(DeliveryError::MissingPort("Clock"))?;
let max_attempts = i64::from(
ModuleConfig::new(crate::MODULE_NAME, &*ctx.config).get_u32("MAX_ATTEMPTS", max_attempts),
);
let alias = crate::signature_alias(&*ctx.config).map_err(DeliveryError::Config)?;
let signature_headers: Vec<&str> = std::iter::once(SIGNATURE_HEADER)
.chain(alias.as_deref())
.collect();
let counters = Counters::default();
let budget: &ScheduledBudget = &ctx.scheduled;
let core_report = Outbox::new(store::OUTBOX)
.drain_within(
&*db,
&*clock,
budget,
DrainOptions {
limit: DRAIN_BATCH,
lease: time::Duration::seconds(LEASE_SECS),
subrequests_per_item: SUBREQUESTS_PER_DELIVERY,
},
|record| {
let db = Arc::clone(&db);
let http = Arc::clone(&http);
let clock = Arc::clone(&clock);
let counters = &counters;
let signature_headers = signature_headers.as_slice();
async move {
let now = now_timestamps(&clock);
match deliver_one(&*db, &*http, &record, max_attempts, &now, signature_headers)
.await
{
Ok(Filed::Delivered) => {
counters.delivered.fetch_add(1, Ordering::Relaxed);
Processed::Done
}
Ok(Filed::Retried(at)) => {
counters.retried.fetch_add(1, Ordering::Relaxed);
Processed::RetryAt(at)
}
Ok(Filed::DeadLettered) => {
counters.dead_lettered.fetch_add(1, Ordering::Relaxed);
Processed::Done
}
Ok(Filed::Dropped) => {
counters.dropped.fetch_add(1, Ordering::Relaxed);
Processed::Done
}
Err(error) => {
counters.failed.fetch_add(1, Ordering::Relaxed);
tracing::error!(row = %record.id, %error, "delivering a webhook failed");
Processed::NextAt(
now.at.saturating_add(time::Duration::seconds(LEASE_SECS)),
)
}
}
}
},
)
.await?;
Ok(DrainReport {
claimed: usize::try_from(core_report.processed).unwrap_or(usize::MAX),
delivered: counters.delivered.load(Ordering::Relaxed),
retried: counters.retried.load(Ordering::Relaxed),
dead_lettered: counters.dead_lettered.load(Ordering::Relaxed),
dropped: counters.dropped.load(Ordering::Relaxed),
failed: counters.failed.load(Ordering::Relaxed),
deferred: core_report.released,
})
}
async fn deliver_one(
db: &dyn cratefield_core::Database,
http: &dyn HttpClient,
record: &OutboxRecord,
max_attempts: i64,
now: &Timestamps,
signature_headers: &[&str],
) -> Result<Filed, DeliveryError> {
let Ok(job) = serde_json::from_str::<DeliveryJob>(&record.payload) else {
return dead_letter_malformed(db, record, now).await;
};
let Some(target) = store::delivery_target(db, &job.subject, &job.endpoint_id).await? else {
return Ok(Filed::Dropped);
};
let body = serde_json::to_vec(&Envelope {
id: &job.event_id,
event_type: &job.event_type,
subject: &job.subject,
created_at: &job.created_at,
data: &job.data,
})
.map_err(|error| DeliveryError::Encode(error.to_string()))?;
let attempt = record.attempts + 1;
match post(
http,
&target.url,
&target.secret,
&job,
&body,
now.unix,
signature_headers,
)
.await
{
Ok(status) if status.is_success() => {
log_attempt(
db,
&job,
attempt,
Some(i64::from(status.as_u16())),
None,
now,
)
.await?;
Ok(Filed::Delivered)
}
Ok(StatusCode::GONE) => {
reject_now(
db,
&job,
record,
attempt,
"endpoint answered 410 Gone",
Some(i64::from(StatusCode::GONE.as_u16())),
now,
)
.await
}
Ok(status) => {
let why = format!("endpoint answered {status}");
retry_or_give_up(
db,
&job,
record,
now,
max_attempts,
Failure {
attempt,
why: &why,
status_code: Some(i64::from(status.as_u16())),
},
)
.await
}
Err(HttpError::BlockedDestination(why)) => {
let why = format!("destination refused by outbound policy: {why}");
reject_now(db, &job, record, attempt, &why, None, now).await
}
Err(error) => {
let why = error.to_string();
retry_or_give_up(
db,
&job,
record,
now,
max_attempts,
Failure {
attempt,
why: &why,
status_code: None,
},
)
.await
}
}
}
async fn dead_letter_malformed(
db: &dyn cratefield_core::Database,
record: &OutboxRecord,
now: &Timestamps,
) -> Result<Filed, DeliveryError> {
tracing::error!(row = %record.id, "outbox row is not a webhooks delivery job");
let why = "outbox payload is not a webhooks delivery job";
let broken = broken_job(&record.payload, &record.topic);
log_attempt(db, &broken, 1, None, Some(why), now).await?;
store::dead_letter(
db,
&UlidIdGen.ulid(),
&broken,
&GiveUp {
outbox_row_id: &record.id,
attempts: record.attempts + 1,
reason: DeadLetterReason::Malformed,
last_error: why,
status_code: None,
created_at: &broken.created_at,
failed_at: &now.iso,
},
)
.await?;
Ok(Filed::DeadLettered)
}
async fn reject_now(
db: &dyn cratefield_core::Database,
job: &DeliveryJob,
record: &OutboxRecord,
attempt: i64,
why: &str,
status_code: Option<i64>,
now: &Timestamps,
) -> Result<Filed, DeliveryError> {
log_attempt(db, job, attempt, status_code, Some(why), now).await?;
store::dead_letter(
db,
&UlidIdGen.ulid(),
job,
&GiveUp {
outbox_row_id: &record.id,
attempts: attempt,
reason: DeadLetterReason::Rejected,
last_error: why,
status_code,
created_at: &job.created_at,
failed_at: &now.iso,
},
)
.await?;
Ok(Filed::DeadLettered)
}
fn broken_job(payload: &str, topic: &str) -> DeliveryJob {
DeliveryJob {
endpoint_id: String::new(),
event_id: String::new(),
event_type: topic.to_owned(),
subject: String::new(),
created_at: String::new(),
data: serde_json::Value::String(payload.to_owned()),
}
}
async fn post(
http: &dyn HttpClient,
url: &str,
secret: &str,
job: &DeliveryJob,
body: &[u8],
unix_now: i64,
signature_headers: &[&str],
) -> Result<StatusCode, HttpError> {
let signature = signature_header(secret, unix_now, body)
.ok_or_else(|| HttpError::Transport("signing failed".to_owned()))?;
let mut builder = Request::builder()
.method(http::Method::POST)
.uri(url)
.header(CONTENT_TYPE, "application/json");
for name in signature_headers {
builder = builder.header(*name, signature.clone());
}
let request = builder
.header(EVENT_ID_HEADER, job.event_id.as_str())
.header(EVENT_TYPE_HEADER, job.event_type.as_str())
.body(Bytes::copy_from_slice(body))
.map_err(|error| HttpError::Transport(error.to_string()))?;
Ok(http.send(request).await?.status())
}
async fn log_attempt(
db: &dyn cratefield_core::Database,
job: &DeliveryJob,
attempt: i64,
status_code: Option<i64>,
error: Option<&str>,
now: &Timestamps,
) -> Result<(), DbError> {
store::record_delivery(
db,
&UlidIdGen.ulid(),
job,
attempt,
status_code,
error,
&now.iso,
)
.await
}
struct Failure<'a> {
attempt: i64,
why: &'a str,
status_code: Option<i64>,
}
async fn retry_or_give_up(
db: &dyn cratefield_core::Database,
job: &DeliveryJob,
record: &OutboxRecord,
now: &Timestamps,
max_attempts: i64,
failure: Failure<'_>,
) -> Result<Filed, DeliveryError> {
let (attempt, why, status_code) = (failure.attempt, failure.why, failure.status_code);
log_attempt(db, job, attempt, status_code, Some(why), now).await?;
if attempt >= max_attempts {
store::dead_letter(
db,
&UlidIdGen.ulid(),
job,
&GiveUp {
outbox_row_id: &record.id,
attempts: attempt,
reason: DeadLetterReason::AttemptsExhausted,
last_error: &format!("gave up after {attempt} attempts: {why}"),
status_code,
created_at: &job.created_at,
failed_at: &now.iso,
},
)
.await?;
return Ok(Filed::DeadLettered);
}
let delay = backoff(attempt);
let seconds = i64::try_from(delay.as_secs()).unwrap_or(i64::from(u32::MAX));
Ok(Filed::Retried(
now.at.saturating_add(time::Duration::seconds(seconds)),
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeliveryError {
MissingPort(&'static str),
Database(DbError),
Encode(String),
Config(String),
}
impl std::fmt::Display for DeliveryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeliveryError::MissingPort(port) => write!(f, "webhooks: missing port {port}"),
DeliveryError::Database(error) => write!(f, "webhooks: {error}"),
DeliveryError::Encode(error) => write!(f, "webhooks: {error}"),
DeliveryError::Config(problem) => f.write_str(problem),
}
}
}
impl std::error::Error for DeliveryError {}
impl From<DbError> for DeliveryError {
fn from(error: DbError) -> Self {
DeliveryError::Database(error)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_signature_header_carries_a_bound_timestamp_and_lowercase_hex() {
let header =
signature_header("a test secret", 1_234, b"body bytes").expect("hmac accepts any key");
assert!(header.starts_with("t=1234,v1="), "{header}");
let hex = header.trim_start_matches("t=1234,v1=");
assert_eq!(hex.len(), 64, "SHA-256 in hex: {header}");
assert!(
hex.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"the hex is lowercase: {header}"
);
}
#[test]
fn the_signature_binds_the_body_so_a_restamp_invalidates_it() {
let a = signature_header("secret", 1_234, b"one body").expect("signs");
let b = signature_header("secret", 1_234, b"another body").expect("signs");
let c = signature_header("secret", 1_235, b"one body").expect("signs");
assert_ne!(a, b, "a different body signs differently");
assert_ne!(a, c, "a different timestamp signs differently");
}
#[test]
fn backoff_doubles_from_thirty_seconds_to_the_one_hour_ceiling() {
assert_eq!(backoff(1), Duration::from_secs(30));
assert_eq!(backoff(2), Duration::from_secs(60));
assert_eq!(backoff(3), Duration::from_secs(120));
assert_eq!(backoff(8), Duration::from_secs(3_600));
assert_eq!(backoff(20), Duration::from_secs(3_600), "capped");
assert_eq!(backoff(0), Duration::from_secs(30), "floor at one attempt");
}
#[test]
fn the_envelope_names_the_event_its_subject_and_its_data() {
let job = store::test_job();
let body = serde_json::to_vec(&Envelope {
id: &job.event_id,
event_type: &job.event_type,
subject: &job.subject,
created_at: "2026-09-27T00:00:00Z",
data: &job.data,
})
.expect("serialises");
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON");
assert_eq!(parsed["id"], "ev");
assert_eq!(parsed["type"], "order.paid");
assert_eq!(parsed["subject"], "acct");
assert_eq!(parsed["created_at"], "2026-09-27T00:00:00Z");
assert_eq!(parsed["data"]["amount"], 1);
}
#[test]
fn a_receiver_recomputing_the_mac_verifies_the_delivery() {
let secret = "whsec_generated_by_create_endpoint";
let body = b"{\"id\":\"ev\",\"type\":\"order.paid\"}";
let signed_at = 1_792_000_000;
let header = signature_header(secret, signed_at, body).expect("signs");
let (t, v1) = header
.split_once(",v1=")
.map(|(t, v1)| (t.trim_start_matches("t="), v1))
.expect("the header shape");
assert_eq!(t, "1792000000");
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key");
mac.update(t.as_bytes());
mac.update(b".");
mac.update(body);
assert_eq!(lower_hex(&mac.finalize().into_bytes()), v1);
}
#[test]
fn dead_letter_reasons_round_trip_their_column_values() {
for (reason, stored) in [
(DeadLetterReason::AttemptsExhausted, "attempts_exhausted"),
(DeadLetterReason::Rejected, "rejected"),
(DeadLetterReason::Malformed, "malformed"),
] {
assert_eq!(reason.as_str(), stored);
}
}
}