Skip to main content

cratefield_module_webhooks/
deliver.rs

1//! The drain: lease due outbox rows within the invocation's
2//! [`ScheduledBudget`] (ADR 0023), sign and POST each one, and file the
3//! outcome — complete, retry with backoff, or dead-letter. This is the
4//! whole delivery policy of the module, in one place.
5
6use 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
25/// The one header a delivery is verified by, Stripe-style:
26/// `t=<unix seconds>,v1=<lowercase hex>`. The same scheme
27/// `cratefield-adapter-webhook-tracker` signs with, so one receiver-side
28/// verifier covers both.
29pub const SIGNATURE_HEADER: &str = "Cratefield-Signature";
30/// The event's identity, so a receiver can dedupe the at-least-once
31/// redeliveries the outbox contract promises.
32pub const EVENT_ID_HEADER: &str = "Cratefield-Event-Id";
33/// What happened, in the caller's own vocabulary.
34pub const EVENT_TYPE_HEADER: &str = "Cratefield-Event-Type";
35
36/// The first retry delay; each further attempt doubles it.
37const BACKOFF_BASE_SECS: u64 = 30;
38/// The ceiling on the doubling.
39const BACKOFF_MAX_SECS: u64 = 3_600;
40/// How long a claimed row stays leased to one drainer.
41const LEASE_SECS: i64 = 300;
42/// Rows claimed per pass, before the budget caps it further. A pass
43/// delivers sequentially — webhook receivers answer well within the
44/// [`LEASE_SECS`] the claim holds, and a sequential pass keeps the
45/// delivery log in attempt order.
46const DRAIN_BATCH: u64 = 16;
47/// What the [`ScheduledBudget`] charges per row processed: one subrequest
48/// per delivery attempt — at most one outbound POST — a conservative count
49/// (ADR 0023 counts the unit of work, not the database writes around it).
50const SUBREQUESTS_PER_DELIVERY: u32 = 1;
51
52/// What one drain pass did.
53///
54/// A per-row failure never aborts the pass; it is counted in `failed` and
55/// the row is due again when its five-minute lease would have lapsed,
56/// exactly the posture `module-notifications`' drain takes. The pass does
57/// stop early — releasing the rest, still due, for the next tick — when
58/// the invocation's [`ScheduledBudget`] runs out, which `deferred` counts.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub struct DrainReport {
61    /// Rows this pass processed — one subrequest charged each; the budget
62    /// allowed this many.
63    pub claimed: usize,
64    /// Answered 2xx: the row is completed.
65    pub delivered: usize,
66    /// Failed and scheduled for another attempt.
67    pub retried: usize,
68    /// Given up on, in `webhooks_dead_letters`, replayable.
69    pub dead_lettered: usize,
70    /// Dropped without an attempt: the endpoint was deleted between the
71    /// enqueue and the drain.
72    pub dropped: usize,
73    /// Rows whose delivery errored in a way this pass could not file —
74    /// the log write itself failed. The row is due again when its lease
75    /// would have lapsed.
76    pub failed: usize,
77    /// Claimed rows released untouched because the budget ran out: still
78    /// queued, due immediately, for the next tick.
79    pub deferred: u64,
80}
81
82/// The filing of one claimed row: what the report counts, and what the
83/// drain maps to a [`Processed`] for the outbox.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Filed {
86    /// 2xx: the row completes.
87    Delivered,
88    /// Transient failure: retry at this instant, counting one attempt.
89    Retried(OffsetDateTime),
90    /// Terminal: dead-lettered, the row already moved out of the outbox.
91    DeadLettered,
92    /// The endpoint is gone: the row completes — nobody is subscribed.
93    Dropped,
94}
95
96/// The handler's outcome counters.
97///
98/// Atomics rather than a `Cell`/`RefCell`: the `scheduled` future is `Send`
99/// (core's `BoxFuture` carries `+ Send`) and a `&RefCell<T>` is not, so the
100/// handler cannot hold one across an await. `&Counters` is `Send + Sync`,
101/// and the handler runs one row at a time, so nothing contends.
102#[derive(Debug, Default)]
103struct Counters {
104    delivered: AtomicUsize,
105    retried: AtomicUsize,
106    dead_lettered: AtomicUsize,
107    dropped: AtomicUsize,
108    failed: AtomicUsize,
109}
110
111/// The delivery as the receiver sees it: the event's identity, the
112/// subject it is about, when it was enqueued, and the caller's payload
113/// under `data`.
114#[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
124/// Signs `body` at `signed_at` and renders the [`SIGNATURE_HEADER`] value:
125/// `t=<unix seconds>,v1=<lowercase hex>`, both parts in one header.
126///
127/// The MAC is HMAC-SHA256 over the bytes `{signed_at}.{body}` — the
128/// timestamp is bound INTO the signature, so a captured delivery cannot be
129/// replayed with a fresh timestamp: the re-stamp invalidates the MAC. That
130/// is the reason for this scheme over a separate, unsigned timestamp
131/// header, which a replay would carry untouched.
132pub(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
143/// Lowercase hex, the case the Stripe-style verifiers expect.
144fn lower_hex(bytes: &[u8]) -> String {
145    let mut out = String::with_capacity(bytes.len() * 2);
146    for byte in bytes {
147        // Writing to a String cannot fail.
148        let _ = write!(out, "{byte:02x}");
149    }
150    out
151}
152
153/// The retry delay after `attempts_made` failed attempts: 30s doubling to
154/// a 1h ceiling.
155pub(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
164/// The clock reading one pass works in: the stored RFC 3339 spelling for
165/// every database timestamp, Unix seconds for the signature, and the
166/// `OffsetDateTime` itself for the timestamps the outbox applies.
167struct Timestamps {
168    at: OffsetDateTime,
169    iso: String,
170    unix: i64,
171}
172
173/// Now, through the deployment's `Clock` port — which [`drain`] has
174/// already hard-required, so there is no silent wall-clock fallback to
175/// mask a mis-wired context (tests would otherwise sign and stamp with a
176/// clock they never pinned).
177fn 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
186/// Delivers as many due outbox rows as this pass can lease **within the
187/// invocation's [`ScheduledBudget`]** (ADR 0023), through the ports of
188/// `ctx` — the entry point the venture's scheduled invocation must use
189/// (a cron invocation builds no router, so a context parked at
190/// router-build time is exactly what this must not depend on).
191///
192/// Each row is signed and delivered once per pass: 2xx completes it,
193/// anything else retries with exponential backoff (410 Gone and
194/// SSRF-blocked destinations dead-letter immediately), and `MAX_ATTEMPTS`
195/// failures dead-letter it for [`Webhooks::replay`]. One delivery attempt
196/// spends one subrequest; when the budget runs out the rows it never
197/// reached are released, still due, for the next tick and counted in
198/// [`DrainReport::deferred`].
199///
200/// # Errors
201///
202/// [`DeliveryError`] when a port is missing, the claim fails, the outbox
203/// refuses a completion write, or `WEBHOOKS_SIGNATURE_ALIAS` is not usable.
204/// The alias is checked before any row is claimed, so a refused pass leaves
205/// the queue untouched. A per-row *delivery* failure never aborts the pass:
206/// it is counted in [`DrainReport::failed`] and the row is due again when
207/// its lease would have lapsed.
208pub(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    // `requires()` declares this port, so its absence is a wiring bug —
223    // refused here, not papered over with the wall clock.
224    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    // Read from the config this pass was handed, so the wire always matches
233    // it, whichever constructor built the module.
234    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                // Clone the ports into the future: a `FnMut` handler cannot
253                // hand out borrows of its captures across an await.
254                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                            // The dead letter already dropped the outbox row.
275                            Processed::Done
276                        }
277                        Ok(Filed::Dropped) => {
278                            counters.dropped.fetch_add(1, Ordering::Relaxed);
279                            Processed::Done
280                        }
281                        // The row keeps its lease: released due when that
282                        // lapses, without counting an attempt. Abandoning
283                        // the rest of the pass over one error would leave
284                        // every row behind it leased and untouched for the
285                        // whole lease.
286                        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
310/// One row: parse, resolve the endpoint, sign, send, and report how it was
311/// filed. The outbox row itself is left to [`drain_within`], which applies
312/// the [`Processed`] the caller maps this [`Filed`] to — except the
313/// dead-letter and drop paths, which move or remove the row here (a dead
314/// letter must be atomic with its row's removal).
315///
316/// [`drain_within`]: cratefield_core::Outbox::drain_within
317async 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    // One read for URL and secret: the listable endpoint type carries no
330    // secret, so this is the only query that ever selects one. `None` is
331    // an endpoint deleted between the enqueue and the drain: nobody is
332    // subscribed at the other end any more. Dropped, not dead-lettered —
333    // a replay would have nothing to deliver to either.
334    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            // Completed by the drain, off this outcome.
370            Ok(Filed::Delivered)
371        }
372        // The endpoint said never again: a permanent refusal no retry
373        // fixes, so it does not burn the remaining attempts.
374        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        // A blocked destination is a permanent configuration problem (the
403        // `HttpClient` port's contract: loopback, private and metadata
404        // addresses are refused) — every retry would be blocked too.
405        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        // Transport trouble, a deadline, an oversized reply: weather.
410        // Retry with backoff to a bound.
411        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
430/// The filing when the outbox payload is not a delivery job at all: log
431/// it, dead-letter it as `malformed` — a bug or a hand-edited database,
432/// not weather — and keep the pass moving.
433async 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
460/// A failure no retry fixes (`410 Gone`, or the `HttpClient` port
461/// refusing the destination outright): log the attempt and dead-letter
462/// immediately as `rejected`, without burning the remaining attempts.
463async 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
491/// A payload that failed to parse, for the dead letter's own columns. The
492/// topic is all the identity that survives; `data` keeps the raw payload
493/// so nothing a debugger needs is silently dropped. It names no endpoint
494/// and no subject, because none could be read.
495fn 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
506/// POSTs the signed envelope. Returns the status of any answered request;
507/// transport failures are [`HttpError`]s.
508async 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    // One MAC, under every signature name: the alias carries the same value.
524    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
535/// Logs one attempt, whatever its outcome.
536async 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
556/// What a transient failure looked like to the attempt that saw it.
557struct Failure<'a> {
558    attempt: i64,
559    why: &'a str,
560    status_code: Option<i64>,
561}
562
563/// The transient-failure policy: back off to a bound, then dead-letter.
564/// Never a dead letter before the log row is written — the delivery log
565/// is how an operator finds out what the last attempt saw.
566async 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    // The outbox counts this attempt when it applies `Processed::RetryAt`.
595    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/// Why a whole drain pass cannot start.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum DeliveryError {
605    /// A port the module requires is absent from this deployment.
606    MissingPort(&'static str),
607    /// The database refused a read or a write.
608    Database(DbError),
609    /// The envelope could not be serialised. Cannot happen for a payload
610    /// that parsed on the way in, but a serde error is not a panic.
611    Encode(String),
612    /// The deployment's configuration cannot be honoured, as its problem
613    /// text (already prefixed `webhooks:`).
614    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    /// The receiver's side of the contract, exactly as a customer would
693    /// implement it: recompute the MAC over `{t}.{body}` and compare.
694    #[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}