Skip to main content

cratefield_module_webhooks/
deliver.rs

1//! The drain: lease due outbox rows, sign and POST each one, and file the
2//! outcome — complete, retry with backoff, or dead-letter. This is the
3//! whole delivery policy of the module, in one place.
4
5use 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
22/// The one header a delivery is verified by, Stripe-style:
23/// `t=<unix seconds>,v1=<lowercase hex>`. The same scheme
24/// `cratefield-adapter-webhook-tracker` signs with, so one receiver-side
25/// verifier covers both.
26pub const SIGNATURE_HEADER: &str = "Cratefield-Signature";
27/// The event's identity, so a receiver can dedupe the at-least-once
28/// redeliveries the outbox contract promises.
29pub const EVENT_ID_HEADER: &str = "Cratefield-Event-Id";
30/// What happened, in the caller's own vocabulary.
31pub const EVENT_TYPE_HEADER: &str = "Cratefield-Event-Type";
32
33/// The first retry delay; each further attempt doubles it.
34const BACKOFF_BASE_SECS: u64 = 30;
35/// The ceiling on the doubling.
36const BACKOFF_MAX_SECS: u64 = 3_600;
37/// How long a claimed row stays leased to one drainer.
38const LEASE_SECS: i64 = 300;
39/// Rows claimed per pass. A pass delivers sequentially — webhook
40/// receivers answer well within the [`LEASE_SECS`] the claim holds, and a
41/// sequential pass keeps the delivery log in attempt order.
42const DRAIN_BATCH: u64 = 16;
43
44/// What one drain pass did.
45///
46/// A per-row failure never aborts the pass; it is counted in `failed` and
47/// the row keeps its lease until that expires, exactly the posture
48/// `module-notifications`' drain takes.
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub struct DrainReport {
51    /// Rows this drainer won the lease on.
52    pub claimed: usize,
53    /// Answered 2xx: the row is completed.
54    pub delivered: usize,
55    /// Failed and scheduled for another attempt.
56    pub retried: usize,
57    /// Given up on, in `webhooks_dead_letters`, replayable.
58    pub dead_lettered: usize,
59    /// Dropped without an attempt: the endpoint was deleted between the
60    /// enqueue and the drain.
61    pub dropped: usize,
62    /// Rows whose delivery errored in a way this pass could not file —
63    /// the log write itself failed. The row keeps its lease.
64    pub failed: usize,
65}
66
67impl DrainReport {
68    /// One row's outcome, counted.
69    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/// The filing of one claimed row.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Outcome {
82    Delivered,
83    Retried,
84    DeadLettered,
85    Dropped,
86}
87
88/// The delivery as the receiver sees it: the event's identity, the
89/// subject it is about, when it was enqueued, and the caller's payload
90/// under `data`.
91#[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
101/// Signs `body` at `signed_at` and renders the [`SIGNATURE_HEADER`] value:
102/// `t=<unix seconds>,v1=<lowercase hex>`, both parts in one header.
103///
104/// The MAC is HMAC-SHA256 over the bytes `{signed_at}.{body}` — the
105/// timestamp is bound INTO the signature, so a captured delivery cannot be
106/// replayed with a fresh timestamp: the re-stamp invalidates the MAC. That
107/// is the reason for this scheme over a separate, unsigned timestamp
108/// header, which a replay would carry untouched.
109pub(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
120/// Lowercase hex, the case the Stripe-style verifiers expect.
121fn lower_hex(bytes: &[u8]) -> String {
122    let mut out = String::with_capacity(bytes.len() * 2);
123    for byte in bytes {
124        // Writing to a String cannot fail.
125        let _ = write!(out, "{byte:02x}");
126    }
127    out
128}
129
130/// The retry delay after `attempts_made` failed attempts: 30s doubling to
131/// a 1h ceiling.
132pub(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
141/// The clock reading one pass works in: the stored RFC 3339 spelling for
142/// every database timestamp, and Unix seconds for the signature.
143struct Timestamps {
144    iso: String,
145    unix: i64,
146}
147
148/// Now, through the deployment's `Clock` port — which [`drain`] has
149/// already hard-required, so there is no silent wall-clock fallback to
150/// mask a mis-wired context (tests would otherwise sign and stamp with a
151/// clock they never pinned).
152fn 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
160/// Delivers every due outbox row this pass can lease, through the ports of
161/// `ctx` — the entry point the venture's scheduled invocation must use
162/// (a cron invocation builds no router, so a context parked at
163/// router-build time is exactly what this must not depend on).
164///
165/// # Errors
166///
167/// [`DeliveryError`] when a port is missing or the claim itself fails. A
168/// per-row failure never aborts the pass: it is counted in
169/// [`DrainReport::failed`] and the row keeps its lease until it expires.
170pub(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    // `requires()` declares this port, so its absence is a wiring bug —
185    // refused here, not papered over with the wall clock.
186    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            // The row keeps its lease and becomes due again when that
209            // expires. Abandoning the rest of the pass over one error
210            // would leave every row behind it leased and untouched for
211            // the whole lease.
212            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
221/// One row: parse, resolve the endpoint, sign, send, file.
222async 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    // One read for URL and secret: the listable endpoint type carries no
234    // secret, so this is the only query that ever selects one. `None` is
235    // an endpoint deleted between the enqueue and the drain: nobody is
236    // subscribed at the other end any more. Dropped, not dead-lettered —
237    // a replay would have nothing to deliver to either.
238    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        // The endpoint said never again: a permanent refusal no retry
269        // fixes, so it does not burn the remaining attempts.
270        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        // A blocked destination is a permanent configuration problem (the
299        // `HttpClient` port's contract: loopback, private and metadata
300        // addresses are refused) — every retry would be blocked too.
301        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        // Transport trouble, a deadline, an oversized reply: weather.
306        // Retry with backoff to a bound.
307        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
326/// The filing when the outbox payload is not a delivery job at all: log
327/// it, dead-letter it as `malformed` — a bug or a hand-edited database,
328/// not weather — and keep the pass moving.
329async 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
356/// A failure no retry fixes (`410 Gone`, or the `HttpClient` port
357/// refusing the destination outright): log the attempt and dead-letter
358/// immediately as `rejected`, without burning the remaining attempts.
359async 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
387/// A payload that failed to parse, for the dead letter's own columns. The
388/// topic is all the identity that survives; `data` keeps the raw payload
389/// so nothing a debugger needs is silently dropped. It names no endpoint
390/// and no subject, because none could be read.
391fn 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
402/// POSTs the signed envelope. Returns the status of any answered request;
403/// transport failures are [`HttpError`]s.
404async 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
426/// Logs one attempt, whatever its outcome.
427async 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
447/// What a transient failure looked like to the attempt that saw it.
448struct Failure<'a> {
449    attempt: i64,
450    why: &'a str,
451    status_code: Option<i64>,
452}
453
454/// The transient-failure policy: back off to a bound, then dead-letter.
455/// Never a dead letter before the log row is written — the delivery log
456/// is how an operator finds out what the last attempt saw.
457async 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/// Why a whole drain pass cannot start.
494#[derive(Debug, Clone, PartialEq, Eq)]
495pub enum DeliveryError {
496    /// A port the module requires is absent from this deployment.
497    MissingPort(&'static str),
498    /// The database refused a read or a write.
499    Database(DbError),
500    /// The envelope could not be serialised. Cannot happen for a payload
501    /// that parsed on the way in, but a serde error is not a panic.
502    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    /// The receiver's side of the contract, exactly as a customer would
580    /// implement it: recompute the MAC over `{t}.{body}` and compare.
581    #[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}