dwara_core/events/
webhook.rs

1//! Webhook delivery (DW-044, feature analysis section 5 "Platform"):
2//! drains the event bus and POSTs each event to the configured targets.
3//!
4//! # Envelope (stable shape)
5//!
6//! One JSON object per delivery, serialized once per event and shared by
7//! every matching target:
8//!
9//! ```json
10//! {
11//!   "id": "evt-18f3c2a1b9d0-00000a",
12//!   "kind": "breaker_opened",
13//!   "timestamp": "2026-08-27T09:00:00.123Z",
14//!   "gateway": "dwara-8213-18f3c2910b07",
15//!   "payload": { "upstream": "billing", "detail": "error_ratio" }
16//! }
17//! ```
18//!
19//! `id` is process-unique and monotonic (event correlation, not
20//! ordering across processes), `kind` is the closed
21//! [`EventKind`] spelling, `timestamp` is RFC 3339 UTC
22//! with millisecond precision, `gateway` identifies the emitting
23//! process, and `payload` carries only the bounded labels and numbers
24//! of [`EventPayload`] — no request-derived data,
25//! no secrets. The envelope is byte-capped at [`MAX_ENVELOPE_BYTES`]
26//! (an over-cap envelope — only possible via absurd config label
27//! lengths — is dropped and counted, never truncated: a receiver should
28//! never see a malformed event).
29//!
30//! # Delivery contract
31//!
32//! One delivery = one target + one event. Every attempt of a delivery
33//! (the first POST plus its retries) shares ONE total budget of
34//! `timeout_ms`, the OTLP exporter's model (#133): a flapping or hung
35//! target cannot stretch a delivery past the budget any more than a
36//! slow one could, which is the whole failure-isolation guarantee. The
37//! retry shape is equally borrowed from that client:
38//!
39//! - retryable outcomes: transport failures and the transient status
40//!   set 429/502/503/504. A seconds-form `Retry-After` replaces the
41//!   computed backoff for that wait (the HTTP-date form is deliberately
42//!   uninterpreted); `Retry-After: 0` falls back to the computed
43//!   backoff so a hostile zero cannot busy-loop.
44//! - every other non-2xx answer (4xx, 500, 3xx — redirects are NOT
45//!   followed) fails the delivery immediately: retrying cannot fix an
46//!   answer that is this delivery's fault.
47//! - backoff is exponential from `backoff_base_ms`, doubling up to
48//!   `backoff_cap_ms`; a wait that would exhaust the remaining budget
49//!   gives up instead of sleeping past it.
50//!
51//! Outcomes are counted in `dwara_webhook_events_total{kind,outcome}`:
52//! `delivered` (2xx on some attempt), `failed` (retries exhausted,
53//! non-transient answer, or budget spent — the target was tried and did
54//! not take it), and `dropped` (never tried: envelope over the byte
55//! cap, or the delivery-concurrency semaphore saturated). Queue drops
56//! at EMIT time are the separate `dwara_events_dropped_total` gauge
57//! (see the parent module). Cardinality is bounded: `kind` is the
58//! closed event set, `outcome` is these three spellings.
59//!
60//! # Isolation from the dataplane
61//!
62//! [`run_deliverer`] is one background task; each delivery is a further
63//! task, and at most [`MAX_CONCURRENT_DELIVERIES`] run at once
64//! (a saturated semaphore drops-and-counts rather than queueing — an
65//! unbounded delivery queue would just move the unbounded buffer from
66//! the bus to the deliverer). The emit path (`try_send` + atomics) is
67//! unaffected by everything here: a dead target costs its semaphore
68//! slot for at most `timeout_ms`, a hung one exactly the same.
69//!
70//! # Egress posture
71//!
72//! Webhook URLs are OPERATOR configuration, exactly like upstream
73//! endpoints and JWKS URLs: the gateway dials what its config names, so
74//! there is no SSRF boundary to enforce against the config author.
75//! There is deliberately no private-address egress filter in v1 (an
76//! internal alerting listener on 127.0.0.1 or 10/8 is a normal shape);
77//! `https://` targets verify against the public webpki root set with no
78//! `trusted_ca_file` override (v1 scope — the alerting fan-out is
79//! public SaaS), and HTTP/1.1 only. Header values may carry secrets
80//! via `${...}` references (DW-045): they are resolved at compile time,
81//! held only by the compiled target, and NEVER logged (delivery logs
82//! name the URL and the outcome, never the headers).
83//!
84//! # Target lifecycle
85//!
86//! Targets are compiled per config generation (secret references
87//! re-resolved on every publish) and pushed to the deliverer over a
88//! `tokio::sync::watch` channel by the dataplane's refresh — a config
89//! change applies to the NEXT event, with no deliverer restart.
90
91use std::sync::Arc;
92use std::time::{Duration, Instant};
93
94use bytes::Bytes;
95use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
96use tokio::sync::{watch, OwnedSemaphorePermit, Semaphore};
97
98use crate::config::credentials::resolve_configured_secret;
99use crate::config::ssrf::SsrfFilter;
100use crate::config::Webhook;
101use crate::observability::Observability;
102
103use super::{Event, EventKind, EventPayload};
104
105/// Hard cap on one serialized envelope (bytes). Payload fields are
106/// bounded config labels, so this only binds absurd label lengths; an
107/// over-cap envelope is dropped and counted, never sent truncated.
108pub const MAX_ENVELOPE_BYTES: usize = 16 * 1024;
109/// At most this many deliveries in flight across every target (per
110/// gateway process). Over it: drop and count — a stuck fleet of
111/// targets must not accumulate an unbounded task pile.
112pub const MAX_CONCURRENT_DELIVERIES: usize = 32;
113/// Response-head read bound while locating the blank line (bytes); the
114/// body is never read (only the status line and `Retry-After` matter).
115const MAX_RESPONSE_HEAD_BYTES: usize = 8 * 1024;
116/// `User-Agent` stamped on every delivery.
117const USER_AGENT: &str = "dwara-webhook";
118
119/// One compiled `gateway.webhooks[]` entry (DW-044): the URL decomposed
120/// for dialing, the event-kind filter, and the RESOLVED header set.
121/// Built per config generation by [`WebhookTarget::compile`]; held by
122/// the deliverer via the watch channel. Header values may be secret
123/// material (a `${...}` reference): they are never logged and never
124/// appear in `Debug` (the impl prints names only).
125#[derive(Clone)]
126pub struct WebhookTarget {
127    url: String,
128    /// Host as it appears in an HTTP `Host` header (IPv6 literals stay
129    /// bracketed, as the URI grammar requires).
130    host_header: String,
131    /// Host for dialing/SNI (IPv6 brackets stripped).
132    dial_host: String,
133    port: u16,
134    tls: bool,
135    path_and_query: String,
136    events: Vec<EventKind>,
137    headers: Vec<(String, String)>,
138    timeout: Duration,
139    attempts: u32,
140    backoff_base: Duration,
141    backoff_cap: Duration,
142    /// SEC-13: SSRF egress filter. Checked at connect time against the
143    /// resolved IP. Disabled (accepts all) when not configured.
144    ssrf_filter: SsrfFilter,
145}
146
147impl std::fmt::Debug for WebhookTarget {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        // Header VALUES may be secrets; names only.
150        f.debug_struct("WebhookTarget")
151            .field("url", &self.url)
152            .field(
153                "events",
154                &self.events.iter().map(|k| k.as_str()).collect::<Vec<_>>(),
155            )
156            .field(
157                "headers",
158                &self
159                    .headers
160                    .iter()
161                    .map(|(n, _)| n.as_str())
162                    .collect::<Vec<_>>(),
163            )
164            .field("timeout_ms", &(self.timeout.as_millis() as u64))
165            .field("attempts", &self.attempts)
166            .finish_non_exhaustive()
167    }
168}
169
170impl WebhookTarget {
171    /// Compile one config entry: decompose the URL, resolve every
172    /// header value (literals pass through; `${...}` references resolve
173    /// NOW — the DW-045 compile-time contract), and resolve the retry
174    /// knobs to their defaults. Fails with a message safe for logs
175    /// (names the URL or the reference, never a resolved value).
176    /// Validation (`snapshot::validate`) has already checked the shape;
177    /// this is the compile-time re-resolution whose failure skips the
178    /// target loudly (the microsecond-race backstop, same as the
179    /// authenticator's credential resolution).
180    pub fn compile(cfg: &Webhook, ssrf_filter: SsrfFilter) -> Result<Self, String> {
181        let mut target = Self::compile_endpoint(
182            &cfg.url,
183            &cfg.headers,
184            cfg.timeout_ms,
185            cfg.max_attempts,
186            cfg.backoff_base_ms,
187            cfg.backoff_cap_ms,
188            ssrf_filter,
189        )?;
190        let mut events = Vec::with_capacity(cfg.events.len());
191        for e in &cfg.events {
192            let Some(kind) = EventKind::from_config(e) else {
193                return Err(format!(
194                    "webhook url '{}' lists unknown event kind '{e}'",
195                    cfg.url
196                ));
197            };
198            events.push(kind);
199        }
200        target.events = events;
201        Ok(target)
202    }
203
204    /// Compile the endpoint half of a target — URL decomposition,
205    /// secret-reference header resolution (with the header-value
206    /// legality re-check), and the retry knobs — without any event-kind
207    /// filter. The shared bottom of [`WebhookTarget::compile`] and the
208    /// DW-121 record-stream sink's compilation
209    /// (`events::stream::WebhookRecordSink`), which delivers to an
210    /// endpoint with no kind filter at all. Same failure contract:
211    /// log-safe messages, fail closed, never a resolved value in the
212    /// error.
213    pub(super) fn compile_endpoint(
214        url: &str,
215        headers: &std::collections::BTreeMap<String, String>,
216        timeout_ms: u64,
217        max_attempts: u32,
218        backoff_base_ms: u64,
219        backoff_cap_ms: u64,
220        ssrf_filter: SsrfFilter,
221    ) -> Result<Self, String> {
222        let uri: hyper::Uri = url
223            .parse()
224            .map_err(|e| format!("webhook url '{url}' does not parse: {e}"))?;
225        let tls = match uri.scheme_str() {
226            Some("http") => false,
227            Some("https") => true,
228            other => {
229                return Err(format!(
230                    "webhook url '{url}' must be http:// or https://, got {other:?}"
231                ))
232            }
233        };
234        let host = uri
235            .host()
236            .ok_or_else(|| format!("webhook url '{url}' has no host"))?
237            .to_string();
238        let default_port = if tls { 443 } else { 80 };
239        let port = uri.port_u16().unwrap_or(default_port);
240        let host_header = if port == default_port {
241            host.clone()
242        } else {
243            format!("{host}:{port}")
244        };
245        let dial_host = host
246            .strip_prefix('[')
247            .and_then(|h| h.strip_suffix(']'))
248            .unwrap_or(&host)
249            .to_string();
250        let path_and_query = uri
251            .path_and_query()
252            .map(|p| p.as_str().to_string())
253            .unwrap_or_else(|| "/".to_string());
254        let mut compiled_headers = Vec::with_capacity(headers.len());
255        for (name, value) in headers {
256            let resolved = resolve_configured_secret(value)
257                .map_err(|e| format!("webhook url '{url}' header '{name}': {e}"))?;
258            // The request head is serialized by hand, so the resolved
259            // bytes must be a legal single header value. Validation
260            // checked this against the compile-time resolution; a secret
261            // file that changed in between could otherwise smuggle CR/LF
262            // into the head (request splitting). Fail closed, skip the
263            // target — the error names the header, never the value.
264            if hyper::header::HeaderValue::from_str(&resolved).is_err() {
265                return Err(format!(
266                    "webhook url '{url}' header '{name}': the resolved value contains \
267                     characters that cannot appear in an HTTP header value",
268                ));
269            }
270            compiled_headers.push((name.clone(), resolved));
271        }
272        Ok(WebhookTarget {
273            url: url.to_string(),
274            host_header,
275            dial_host,
276            port,
277            tls,
278            path_and_query,
279            events: Vec::new(),
280            headers: compiled_headers,
281            timeout: Duration::from_millis(timeout_ms),
282            attempts: max_attempts,
283            backoff_base: Duration::from_millis(backoff_base_ms),
284            backoff_cap: Duration::from_millis(backoff_cap_ms),
285            ssrf_filter,
286        })
287    }
288
289    /// The configured URL (operator config, safe to log).
290    pub fn url(&self) -> &str {
291        &self.url
292    }
293
294    /// Whether this target receives `kind`.
295    pub fn wants(&self, kind: EventKind) -> bool {
296        self.events.contains(&kind)
297    }
298}
299
300/// The stable wire envelope (see the module docs for the shape).
301#[derive(serde::Serialize)]
302struct Envelope<'a> {
303    id: &'a str,
304    kind: &'a str,
305    timestamp: String,
306    gateway: &'a str,
307    payload: &'a EventPayload,
308}
309
310/// Serialize one event into its envelope JSON.
311pub fn envelope_json(event: &Event) -> String {
312    let envelope = Envelope {
313        id: &event.id,
314        kind: event.kind.as_str(),
315        timestamp: rfc3339_ms(event.timestamp_ms),
316        gateway: &event.gateway,
317        payload: &event.payload,
318    };
319    serde_json::to_string(&envelope).expect("envelope fields serialize (strings and numbers)")
320}
321
322/// RFC 3339 UTC with millisecond precision from Unix milliseconds
323/// (`civil_from_days`, the standard fully-specified conversion — no
324/// datetime dependency, stable across toolchains like the FNV ring
325/// hash). UTC by definition of the `Z` suffix.
326pub fn rfc3339_ms(unix_ms: u64) -> String {
327    let secs = (unix_ms / 1000) as i64;
328    let millis = unix_ms % 1000;
329    let days = secs.div_euclid(86_400);
330    let sod = secs.rem_euclid(86_400);
331    let (year, month, day) = civil_from_days(days);
332    format!(
333        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z",
334        sod / 3_600,
335        (sod % 3_600) / 60,
336        sod % 60
337    )
338}
339
340/// Days since 1970-01-01 to (year, month, day) — Howard Hinnant's
341/// `civil_from_days`, arithmetic on `i64` so pre-1970 inputs (never
342/// produced by the wall clock, but reachable in tests) stay correct.
343fn civil_from_days(days: i64) -> (i64, i64, i64) {
344    let z = days + 719_468;
345    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
346    let doe = z - era * 146_097; // [0, 146096]
347    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
348    let y = yoe + era * 400;
349    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
350    let mp = (5 * doy + 2) / 153; // [0, 11]
351    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
352    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
353    (if m <= 2 { y + 1 } else { y }, m, d)
354}
355
356/// One exchange attempt's outcome classification (the OTLP client's
357/// triage, #133, verbatim in shape).
358enum Attempt {
359    /// 2xx: accepted.
360    Accepted,
361    /// Not accepted, but a bounded retry may help: transient status or
362    /// transport failure, carrying the target's seconds-form
363    /// `Retry-After` when it sent one.
364    Retry {
365        retry_after: Option<Duration>,
366        error: String,
367    },
368    /// Retrying cannot help (unsupported endpoint, non-transient
369    /// status, budget spent).
370    Fatal(String),
371}
372
373/// Whether a status is in the transient retry set (429 quota, 502/503/
374/// 504 brief unavailability). Deliberately narrow, like the OTLP set:
375/// 500 is a receiver bug, other 4xx are this delivery's fault.
376fn is_retryable_status(status: u16) -> bool {
377    matches!(status, 429 | 502 | 503 | 504)
378}
379
380/// Seconds-form `Retry-After` only (the HTTP-date form is deliberately
381/// uninterpreted; absent or unparseable falls back to the computed
382/// backoff).
383fn retry_after_seconds(head: &str) -> Option<Duration> {
384    let value = head.lines().find_map(|l| {
385        let (name, value) = l.split_once(':')?;
386        name.trim()
387            .eq_ignore_ascii_case("retry-after")
388            .then(|| value.trim().to_string())
389    })?;
390    let seconds: u64 = value.parse().ok()?;
391    Some(Duration::from_secs(seconds))
392}
393
394/// Object-safe alias over the two transports a delivery can run on
395/// (`Box<dyn AsyncRead + AsyncWrite>` is not a legal trait object; an
396/// alias trait with a blanket impl is the standard shape).
397trait Io: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
398impl<T> Io for T where T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
399
400/// Time left against the delivery deadline; `None` once spent.
401fn remaining(deadline: Instant) -> Option<Duration> {
402    deadline
403        .checked_duration_since(Instant::now())
404        .filter(|d| !d.is_zero())
405}
406
407/// Perform ONE exchange attempt against the shared `deadline`: dial
408/// (DNS + TCP + optional TLS), write the request, read the response
409/// head. The whole attempt runs inside one `tokio::time::timeout` of
410/// the remaining budget, so no phase of it — resolution included — can
411/// outlive the delivery's total. `content_type` and `user_agent` name
412/// the body the caller is POSTing (the alert envelope and the DW-121
413/// record batch use different media types and agents; the transport
414/// shape is shared).
415async fn post_once(
416    target: &WebhookTarget,
417    body: &[u8],
418    deadline: Instant,
419    content_type: &str,
420    user_agent: &str,
421) -> Attempt {
422    let Some(budget) = remaining(deadline) else {
423        return Attempt::Fatal("delivery budget spent before the attempt".to_string());
424    };
425    let attempt =
426        async {
427            // SEC-13: SSRF egress filter. Resolve the host and check
428            // every resolved IP against the deny set BEFORE connecting.
429            // DNS rebinding mitigation: the check runs at connect time,
430            // not at config compile time. A host that resolves to a
431            // denied IP is rejected (fail-closed).
432            if target.ssrf_filter.is_enabled() {
433                let addrs =
434                    match tokio::net::lookup_host((target.dial_host.as_str(), target.port)).await {
435                        Ok(addrs) => addrs.collect::<Vec<_>>(),
436                        Err(e) => {
437                            return Err(format!(
438                                "webhook {} DNS resolution failed: {e}",
439                                target.host_header
440                            ));
441                        }
442                    };
443                for addr in &addrs {
444                    if let Err(reason) = target.ssrf_filter.check(addr.ip()) {
445                        return Err(format!(
446                            "webhook {} SSRF egress filter rejected target: {reason}",
447                            target.host_header
448                        ));
449                    }
450                }
451            }
452            let stream = tokio::net::TcpStream::connect((target.dial_host.as_str(), target.port))
453                .await
454                .map_err(|e| format!("webhook {} connect failed: {e}", target.host_header))?;
455            let mut io: Box<dyn Io> = if target.tls {
456                let name = rustls::pki_types::ServerName::try_from(target.dial_host.clone())
457                    .map_err(|_| {
458                        format!(
459                            "webhook host '{}' is not a usable TLS server name",
460                            target.dial_host
461                        )
462                    })?;
463                let connector = tokio_rustls::TlsConnector::from(webhook_tls_config());
464                let tls = connector.connect(name, stream).await.map_err(|e| {
465                    format!("webhook {} TLS handshake failed: {e}", target.host_header)
466                })?;
467                Box::new(tls)
468            } else {
469                Box::new(stream)
470            };
471            // Serialize the request head. Ours: request line, Host,
472            // Content-Length, Connection: close, User-Agent, Content-Type —
473            // the configured headers ride along and may override
474            // Content-Type/User-Agent by appearing later.
475            let mut head = format!(
476                "POST {} HTTP/1.1\r\nhost: {}\r\ncontent-length: {}\r\n\
477             connection: close\r\nuser-agent: {user_agent}\r\n\
478             content-type: {content_type}\r\n",
479                target.path_and_query,
480                target.host_header,
481                body.len()
482            );
483            for (name, value) in &target.headers {
484                head.push_str(&format!("{name}: {value}\r\n"));
485            }
486            head.push_str("\r\n");
487            io.write_all(head.as_bytes())
488                .await
489                .map_err(|e| format!("webhook {} write failed: {e}", target.host_header))?;
490            io.write_all(body)
491                .await
492                .map_err(|e| format!("webhook {} write failed: {e}", target.host_header))?;
493            io.flush()
494                .await
495                .map_err(|e| format!("webhook {} flush failed: {e}", target.host_header))?;
496            // Read the head only: status line + headers up to the blank
497            // line, bounded. The body is never read (the receiver closes).
498            let mut buf = Vec::with_capacity(512);
499            let mut chunk = [0u8; 1024];
500            loop {
501                if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
502                    let head = String::from_utf8_lossy(&buf[..pos]).into_owned();
503                    let status = head
504                        .split_whitespace()
505                        .nth(1)
506                        .and_then(|s| s.parse::<u16>().ok());
507                    let status = match status {
508                        Some(s) => s,
509                        None => {
510                            return Err(format!(
511                                "webhook {} answered an unparseable status line",
512                                target.host_header
513                            ))
514                        }
515                    };
516                    return Ok((status, head));
517                }
518                if buf.len() > MAX_RESPONSE_HEAD_BYTES {
519                    return Err(format!(
520                        "webhook {} response head exceeded the {} byte cap",
521                        target.host_header, MAX_RESPONSE_HEAD_BYTES
522                    ));
523                }
524                let n = io.read(&mut chunk).await.map_err(|e| {
525                    format!("webhook {} response read failed: {e}", target.host_header)
526                })?;
527                if n == 0 {
528                    return Err(format!(
529                        "webhook {} closed the connection before answering",
530                        target.host_header
531                    ));
532                }
533                buf.extend_from_slice(&chunk[..n]);
534            }
535        };
536    match tokio::time::timeout(budget, attempt).await {
537        // Map the inner Result<(status, head), String> into an Attempt.
538        Ok(Ok((status, head))) => {
539            if (200..300).contains(&status) {
540                Attempt::Accepted
541            } else if is_retryable_status(status) {
542                Attempt::Retry {
543                    retry_after: retry_after_seconds(&head),
544                    error: format!("webhook {} answered {status}", target.host_header),
545                }
546            } else {
547                Attempt::Fatal(format!(
548                    "webhook {} answered {status} (not retryable)",
549                    target.host_header
550                ))
551            }
552        }
553        Ok(Err(error)) => Attempt::Retry {
554            retry_after: None,
555            error,
556        },
557        Err(_) => Attempt::Fatal(format!(
558            "webhook {} delivery exceeded its timeout budget",
559            target.host_header
560        )),
561    }
562}
563
564/// The TLS client config for `https://` webhook targets: public webpki
565/// roots, no client certificate, HTTP/1.1 ALPN. Built once per attempt
566/// (a rustls `ClientConfig` is cheap to build and the alerting rate is
567/// trivial; see the module docs for the no-`trusted_ca_file` scope).
568fn webhook_tls_config() -> Arc<rustls::ClientConfig> {
569    let mut cfg = rustls::ClientConfig::builder()
570        .with_root_certificates(webpki_root_store())
571        .with_no_client_auth();
572    cfg.alpn_protocols = vec![b"http/1.1".to_vec()];
573    Arc::new(cfg)
574}
575
576/// Public webpki root store for webhook TLS verification. Local
577/// re-statement of `security::tls::webpki_root_store` (the events
578/// domain must not import the security domain; the roots are the same
579/// public set the pooled connector defaults to).
580fn webpki_root_store() -> rustls::RootCertStore {
581    let mut roots = rustls::RootCertStore::empty();
582    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
583    roots
584}
585
586/// Terminal outcome of one body's retry cycle through the shared
587/// delivery engine. The engine itself counts nothing and logs only
588/// debug-level retry traces — each CALLER owns the outcome logging and
589/// the metric family it reports into (alert events:
590/// `dwara_webhook_events_total`; DW-121 record batches: the stream
591/// families).
592pub(super) enum DeliveryEnd {
593    /// A 2xx answer arrived on the `attempts`-th attempt.
594    Delivered { attempts: u32 },
595    /// Retries exhausted, a non-transient answer, or the budget spent.
596    Failed { attempts: u32, error: String },
597}
598
599/// The shared delivery engine (DW-044's retry/budget shape): one body,
600/// one target, ONE total timeout shared by every attempt, exponential
601/// backoff doubling to the target's cap, seconds-form `Retry-After`
602/// honored in place of the computed wait. This is the whole
603/// failure-isolation guarantee — a slow, hung, or flapping receiver
604/// costs at most `timeout` — and it is deliberately extractor-shared by
605/// the two POST-based pipelines in this domain: the alert-event
606/// deliverer and the DW-121 access-record batch sink. The caller
607/// decides how the outcome is counted.
608pub(super) async fn deliver_with_retry(
609    target: &WebhookTarget,
610    body: Bytes,
611    content_type: &str,
612    user_agent: &str,
613) -> DeliveryEnd {
614    let deadline = Instant::now() + target.timeout;
615    let mut backoff = target.backoff_base;
616    let mut attempt = 1;
617    loop {
618        match post_once(target, &body, deadline, content_type, user_agent).await {
619            Attempt::Accepted => {
620                return DeliveryEnd::Delivered { attempts: attempt };
621            }
622            Attempt::Fatal(error) => {
623                return DeliveryEnd::Failed {
624                    attempts: attempt,
625                    error,
626                };
627            }
628            Attempt::Retry { retry_after, error } => {
629                if attempt >= target.attempts {
630                    return DeliveryEnd::Failed {
631                        attempts: attempt,
632                        error: format!("still not accepted after {attempt} attempts: {error}"),
633                    };
634                }
635                // Retry-After replaces the computed backoff for this
636                // wait; a demanded zero falls back to the computed value
637                // so a hostile zero cannot busy-loop.
638                let wait = match retry_after {
639                    Some(ra) if !ra.is_zero() => ra,
640                    _ => backoff,
641                };
642                backoff = backoff.saturating_mul(2).min(target.backoff_cap);
643                let Some(left) = remaining(deadline) else {
644                    return DeliveryEnd::Failed {
645                        attempts: attempt,
646                        error: format!("delivery budget spent before retry: {error}"),
647                    };
648                };
649                if wait >= left {
650                    return DeliveryEnd::Failed {
651                        attempts: attempt,
652                        error: format!(
653                            "retry wait {wait:?} would exhaust the delivery budget: {error}"
654                        ),
655                    };
656                }
657                tracing::debug!(
658                    code = "webhook_retry",
659                    url = %target.url,
660                    attempt,
661                    backoff_ms = wait.as_millis() as u64,
662                    "webhook not accepted ({error}); retrying in {wait:?}"
663                );
664                tokio::time::sleep(wait).await;
665                attempt += 1;
666            }
667        }
668    }
669}
670
671/// Deliver one alert-event envelope to one target, with the documented
672/// retry shape, counting exactly one outcome (`delivered` or `failed`)
673/// in `dwara_webhook_events_total{kind,outcome}`. Public for the
674/// delivery contract's unit tests; the deliverer calls it per delivery.
675pub async fn deliver(target: WebhookTarget, body: Bytes, kind: EventKind, obs: Arc<Observability>) {
676    let url = target.url().to_string();
677    match deliver_with_retry(&target, body, "application/json", USER_AGENT).await {
678        DeliveryEnd::Delivered { attempts } => {
679            tracing::debug!(
680                code = "webhook_delivered",
681                url = %url,
682                kind = kind.as_str(),
683                attempt = attempts,
684                "webhook delivered"
685            );
686            obs.record_webhook_event(kind.as_str(), "delivered");
687        }
688        DeliveryEnd::Failed { attempts, error } => {
689            tracing::warn!(
690                code = "webhook_failed",
691                url = %url,
692                kind = kind.as_str(),
693                attempt = attempts,
694                "webhook delivery failed: {error}"
695            );
696            obs.record_webhook_event(kind.as_str(), "failed");
697        }
698    }
699}
700
701/// The deliverer loop (DW-044): drain the bus, filter by kind, dispatch
702/// one bounded-concurrency delivery task per (event, target). Returns
703/// on shutdown (pending queue abandoned — documented: the gateway is
704/// not a durable queue) or when the bus closes.
705///
706/// REL-14 (#249): when a durability layer is provided, the deliverer
707/// marks each event as acked after dispatching to matching targets.
708/// This gives at-least-once semantics on restart: an event that was
709/// persisted to the WAL but not yet acked is replayed on the next
710/// startup.
711///
712/// Spawned by the `DataPlane`
713/// (`DataPlane::spawn_webhook_deliverer`) so the binary and tests share
714/// one wiring path.
715pub async fn run_deliverer(
716    mut rx: tokio::sync::mpsc::Receiver<Event>,
717    targets: watch::Receiver<Arc<Vec<WebhookTarget>>>,
718    obs: Arc<Observability>,
719    mut shutdown: watch::Receiver<()>,
720    durability: Option<Arc<dyn super::EventDurability>>,
721) {
722    let permits = Arc::new(Semaphore::new(MAX_CONCURRENT_DELIVERIES));
723    loop {
724        let event = tokio::select! {
725            changed = shutdown.changed() => {
726                if changed.is_err() {
727                    return;
728                }
729                tracing::debug!(
730                    code = "webhook_deliverer_stopped",
731                    "shutdown signaled; webhook deliverer stopping (pending queue abandoned)"
732                );
733                return;
734            }
735            event = rx.recv() => match event {
736                Some(event) => event,
737                None => return,
738            },
739        };
740        let body = envelope_json(&event);
741        if body.len() > MAX_ENVELOPE_BYTES {
742            tracing::warn!(
743                code = "webhook_event_dropped",
744                kind = event.kind.as_str(),
745                size = body.len(),
746                "event envelope exceeds the {} byte cap; dropped",
747                MAX_ENVELOPE_BYTES
748            );
749            obs.record_webhook_event(event.kind.as_str(), "dropped");
750            // REL-14 (#249): ack even dropped events so they are not
751            // replayed on restart (they were delivered to the
752            // deliverer; the drop is a size-limit policy, not a
753            // delivery failure).
754            if let Some(d) = &durability {
755                d.ack(&event.id);
756            }
757            continue;
758        }
759        let current = targets.borrow().clone();
760        for target in current.iter().filter(|t| t.wants(event.kind)) {
761            let Some(permit) = permits.clone().try_acquire_owned().ok() else {
762                tracing::warn!(
763                    code = "webhook_event_dropped",
764                    kind = event.kind.as_str(),
765                    url = %target.url(),
766                    "webhook delivery concurrency saturated ({} in flight); dropped",
767                    MAX_CONCURRENT_DELIVERIES
768                );
769                obs.record_webhook_event(event.kind.as_str(), "dropped");
770                continue;
771            };
772            tokio::spawn(delivery_task(
773                target.clone(),
774                Bytes::from(body.clone()),
775                event.kind,
776                Arc::clone(&obs),
777                permit,
778            ));
779        }
780        // REL-14 (#249): ack the event after dispatching to all
781        // matching targets. This gives at-least-once semantics: the
782        // event was consumed from the channel and handed off to the
783        // delivery tasks. On restart, only un-acked events are
784        // replayed.
785        if let Some(d) = &durability {
786            d.ack(&event.id);
787        }
788    }
789}
790
791/// `deliver` with the semaphore permit moved in (released when the
792/// delivery finishes, bounding concurrency).
793async fn delivery_task(
794    target: WebhookTarget,
795    body: Bytes,
796    kind: EventKind,
797    obs: Arc<Observability>,
798    _permit: OwnedSemaphorePermit,
799) {
800    deliver(target, body, kind, obs).await;
801}