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}