axn-webhooks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +176 -0
  3. data/DESIGN-NOTES.md +241 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1042 -0
  6. data/lib/axn/webhooks/dispatch.rb +129 -0
  7. data/lib/axn/webhooks/errors.rb +48 -0
  8. data/lib/axn/webhooks/handler.rb +15 -0
  9. data/lib/axn/webhooks/header_value.rb +29 -0
  10. data/lib/axn/webhooks/inbound/build_request.rb +23 -0
  11. data/lib/axn/webhooks/inbound/challenge.rb +37 -0
  12. data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
  13. data/lib/axn/webhooks/inbound/dsl.rb +240 -0
  14. data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
  15. data/lib/axn/webhooks/inbound/parsers.rb +20 -0
  16. data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
  17. data/lib/axn/webhooks/inbound/router.rb +104 -0
  18. data/lib/axn/webhooks/inbound.rb +124 -0
  19. data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
  20. data/lib/axn/webhooks/outbound/config.rb +442 -0
  21. data/lib/axn/webhooks/outbound/deliver.rb +425 -0
  22. data/lib/axn/webhooks/outbound/dsl.rb +121 -0
  23. data/lib/axn/webhooks/outbound/emit.rb +181 -0
  24. data/lib/axn/webhooks/outbound/envelope.rb +23 -0
  25. data/lib/axn/webhooks/outbound/signer.rb +376 -0
  26. data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
  27. data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
  28. data/lib/axn/webhooks/outbound/transport.rb +59 -0
  29. data/lib/axn/webhooks/outbound.rb +73 -0
  30. data/lib/axn/webhooks/request.rb +230 -0
  31. data/lib/axn/webhooks/resolvers.rb +43 -0
  32. data/lib/axn/webhooks/respond.rb +26 -0
  33. data/lib/axn/webhooks/response.rb +116 -0
  34. data/lib/axn/webhooks/signature.rb +268 -0
  35. data/lib/axn/webhooks/static_respond.rb +22 -0
  36. data/lib/axn/webhooks/vendor_facet.rb +25 -0
  37. data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
  38. data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
  39. data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
  40. data/lib/axn/webhooks/verifiers.rb +50 -0
  41. data/lib/axn/webhooks/verify.rb +106 -0
  42. data/lib/axn/webhooks/version.rb +7 -0
  43. data/lib/axn/webhooks.rb +61 -0
  44. data/lib/axn-webhooks.rb +3 -0
  45. metadata +128 -0
@@ -0,0 +1,425 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Axn
6
+ module Webhooks
7
+ module Outbound
8
+ # A single delivery attempt + the self-managed retry engine. Built as an Axn: metrics/OTel/
9
+ # structured logs per attempt come free. Retryable responses reschedule via axn's
10
+ # adapter-agnostic call_async(_async: { wait: }) seam (never branching on adapter type);
11
+ # unexpected exceptions propagate so the async adapter retries the un-acked job (at-least-once).
12
+ class Deliver
13
+ include Axn
14
+ include Axn::Webhooks::VendorFacet
15
+
16
+ # The headers Deliver adds AFTER the signer's, and therefore the ones a signer must not
17
+ # emit: Ruby Hash keys are case-sensitive so a differently-cased duplicate survives the
18
+ # merge below, but Net::HTTP is case-INSENSITIVE and the later assignment wins — silently
19
+ # replacing the signature. Signer::HmacSigner rejects these at declaration time.
20
+ MANAGED_HEADERS = %w[content-type user-agent].freeze
21
+
22
+ # A plausible field-name (`Authorization`, `content_type` -- the documented common case of
23
+ # writing a `headers` resolver with a Symbol/String literal key) is a short, simple
24
+ # identifier -- used by `key_desc` to decide whether a malformed header's key is safe to
25
+ # log as-is (see `add_custom_header`).
26
+ PLAUSIBLE_FIELD_NAME = /\A[A-Za-z_][A-Za-z0-9_]{0,49}\z/
27
+
28
+ # RFC 7230's `field-value` grammar forbids every control byte except HTAB (0x09) -- CR/LF
29
+ # (0x0D/0x0A) are the ones Net::HTTP itself raises on, but any OTHER control byte (NUL,
30
+ # BEL, ...) is equally invalid on the wire and unvalidated here would reach the receiver
31
+ # (see `add_custom_header`).
32
+ FORBIDDEN_HEADER_VALUE_BYTES = /[\x00-\x08\x0A-\x1F\x7F]/
33
+
34
+ # sensitive: BOTH of these (security audit). The most common real webhook URL shape —
35
+ # Slack/Discord/Teams incoming hooks — carries a secret token AS THE PATH, which is exactly
36
+ # why TargetPolicy.redact_url exists; and `body` is the caller's own event payload, routinely
37
+ # PII. Without these, axn's per-call auto-logging rendered a live third-party token and the
38
+ # payload into the application log on EVERY delivery. The inbound half already marks
39
+ # request/verifier sensitive; this is the same boundary on the sending side.
40
+ expects :url, type: String, sensitive: true
41
+ expects :webhook_id, type: String
42
+ expects :body, type: String, sensitive: true
43
+ expects :event, type: String
44
+ expects :attempt, type: Integer, default: 1
45
+ # A DB-backed subscriber's own identity (its String id, not a secret/token) -- nil for
46
+ # today's declared-Array `to:` (no row to identify). Threaded through so a per-attempt
47
+ # secret/header resolver and the exhaustion report can name which subscription this was.
48
+ expects :subscriber_id, type: String, allow_blank: true, default: nil
49
+
50
+ # Bounded to the events a sending app declares — same shape as inbound's unconditional
51
+ # `reason` dimension, not a per-request identity.
52
+ dimension :event, -> { event }
53
+ # UNBOUNDED (a subscriber id off a DB table, unlike `event`) -- axn's `dimension` is the
54
+ # metrics facet and must stay bounded; `tag` is the high-cardinality log/trace facet with no
55
+ # metrics-billing cost (see Axn.config.logger.debug config comment near vendor_facet, and
56
+ # `Axn::Webhooks::VendorFacet`'s own dimension/tag split). Getting this backwards would
57
+ # quietly blow up a metrics backend's cardinality limits the first time a real subscriber
58
+ # table is wired up.
59
+ tag :subscriber_id, -> { subscriber_id }
60
+
61
+ # Only reports when `@exhaustion_error` was set by `retry_or_exhaust!`'s exhaustion branch
62
+ # (see `report_exhaustion_if_needed`) -- a permanent-4xx `fail!` (in `#call`) also fires
63
+ # `on_failure` (axn dispatches it for ANY `fail!`), but must NOT page: it never sets that
64
+ # ivar, so the guard holds. Registering here (rather than reporting inline before `fail!`)
65
+ # means the report runs once axn has already finalized `action.result` as a failure (see
66
+ # `with_exception_handling` in axn's executor.rb: `@context.__record_exception` runs before
67
+ # the `:failure` callback dispatch) -- so a reporter reading `action.result` observes the
68
+ # settled failure it exists to describe (Codex P2 finding).
69
+ on_failure :report_exhaustion_if_needed
70
+
71
+ def call
72
+ # Scoped deliberately to ONLY `post` (not the whole method): a network error talking to
73
+ # the receiver is a retryable delivery failure, but if `retry_or_exhaust!`'s own
74
+ # `call_async` raises while ENQUEUING the follow-up job (e.g. a Redis/Sidekiq outage),
75
+ # that must propagate as a loud exception — not get caught here and misinterpreted as
76
+ # another delivery network error, which would re-run retry_or_exhaust! a second time in
77
+ # the same attempt (a duplicate enqueue). Letting it propagate means the current job goes
78
+ # un-acked and the async adapter's own retry path handles the outage (at-least-once).
79
+ response = nil
80
+ begin
81
+ response = post
82
+ rescue *Transport::RETRYABLE_NETWORK_ERRORS => e
83
+ return retry_or_exhaust!(network_error: e)
84
+ end
85
+
86
+ return if success?(response.status) # 2xx -> done
87
+ return retry_or_exhaust!(retry_after: header_value(response.headers, "retry-after")) if retryable?(response.status)
88
+
89
+ fail!(permanent_failure_message(response))
90
+ end
91
+
92
+ private
93
+
94
+ def config = Axn::Webhooks::Outbound.config
95
+
96
+ def post
97
+ config.transport.post(**post_args)
98
+ end
99
+
100
+ # Timeouts are only forwarded to the built-in Transport — a custom injected transport
101
+ # (e.g. Faraday-backed) owns its own timeout configuration, and the documented seam is
102
+ # `.post(url:, body:, headers:)`; passing it kwargs it never declared would raise.
103
+ def post_args
104
+ args = { url:, body:, headers: signed_headers }
105
+ args.merge!(open_timeout: config.open_timeout, read_timeout: config.read_timeout) if config.transport == Transport
106
+ args
107
+ end
108
+
109
+ # Sign per attempt with a FRESH timestamp (so the receiver's replay window accepts a retry),
110
+ # reusing the stable webhook_id for idempotent dedup. Merge order is deliberate: custom
111
+ # (PRO-3214's per-destination `headers`) -> signer -> Deliver-managed, so the signer and
112
+ # Deliver always win a same-position `.merge`. That alone isn't the whole defense (Net::HTTP
113
+ # is case-insensitive, Hash keys are not, so a DIFFERENTLY-cased duplicate survives the merge
114
+ # and Net::HTTP still picks the later one) -- `custom_headers` below additionally drops any
115
+ # subscriber-supplied name that collides, case-insensitively, with either bucket.
116
+ def signed_headers
117
+ # `TargetPolicy.snapshot` (not a bare `Subscriber.new`) -- `url`/`subscriber_id` here are
118
+ # reconstructed fresh from THIS attempt's job payload, ordinary mutable Strings, never the
119
+ # frozen copy `TargetPolicy.check!` validated at resolution time (that copy lives only in
120
+ # `Emit`'s Resolution; `Deliver` never sees it again). A same-position `url:` hash-literal
121
+ # key below and the `url:` used here reference the SAME object -- so a subscriber-aware
122
+ # `sign`/`headers` resolver that mutated `subscriber.url` in place would silently swap the
123
+ # destination `post_args` already captured, sending the request to a host that was never
124
+ # checked against `allowed_hosts`/`allow_url` at all (Codex P2 finding, round 15). Freezing
125
+ # a fresh copy here means that mutation attempt raises loudly instead.
126
+ subscriber = TargetPolicy.snapshot(Subscriber.new(url:, id: subscriber_id))
127
+ signer_headers = config.signer.call(id: webhook_id, timestamp: Time.now.to_i, body:, subscriber:)
128
+
129
+ custom_headers(subscriber, signer_headers)
130
+ .merge(signer_headers)
131
+ .merge("content-type" => "application/json", "user-agent" => user_agent) # MANAGED_HEADERS
132
+ end
133
+
134
+ # Per-destination extra headers (PRO-3214) -- a malformed or colliding entry is DROPPED with
135
+ # a warning, not raised: a bad row from a `headers` resolver shouldn't crash an otherwise-
136
+ # deliverable attempt (a `headers` callable that itself raises is a different matter and
137
+ # propagates unchanged -- see `resolve_custom_headers`).
138
+ def custom_headers(subscriber, signer_headers)
139
+ raw = resolve_custom_headers(subscriber)
140
+ return {} if raw.nil?
141
+
142
+ # A permanent misconfiguration (the resolver forgot to return a Hash, or a conditional
143
+ # fell through to `false`) would otherwise raise NoMethodError from unconditional
144
+ # iteration below -- an UNEXPECTED exception the async adapter reads as a transient
145
+ # crash and retries forever, even though the malformed result will never become valid
146
+ # (Codex P2 finding).
147
+ unless raw.is_a?(Hash)
148
+ Axn.config.logger.warn("[axn-webhooks] dropping the headers resolver result -- expected a Hash, got #{raw.class}")
149
+ return {}
150
+ end
151
+
152
+ # Transport::RESERVED_HEADERS (content-length/transfer-encoding) are reserved
153
+ # unconditionally, not only for the built-in transport -- matching `sign :hmac`'s own
154
+ # header-name validation, which treats them the same way regardless of what `transport`
155
+ # ends up configured. Net::HTTP silently REWRITES both, AFTER headers are applied, so a
156
+ # subscriber-controlled value under either name would otherwise pass every check here
157
+ # and just never reach the receiver -- the delivery reports success regardless (Codex
158
+ # P2 finding).
159
+ #
160
+ # `signer_headers.keys` is normalized to Strings here: a custom `sign` block returning a
161
+ # Symbol-keyed Hash (`{ "X-Signature": ... }`, the natural way to write that literal)
162
+ # would otherwise put a Symbol into `reserved`, and below, `r.casecmp?(key)` returns nil
163
+ # -- not a match, but not an error either -- whenever `r` and `key` are different types,
164
+ # even when case-identical. That silently let a subscriber-controlled `headers` entry
165
+ # ship ALONGSIDE the signer's real header under the same wire name (Codex P2 finding,
166
+ # round 11).
167
+ reserved = MANAGED_HEADERS + Transport::RESERVED_HEADERS + signer_headers.keys.map(&:to_s)
168
+ raw.each_with_object({}) { |(key, value), out| add_custom_header(out, key, value, reserved) }
169
+ end
170
+
171
+ def resolve_custom_headers(subscriber)
172
+ callable = config.headers
173
+ return nil if callable.nil?
174
+
175
+ # Same precedence (and the Proc/#parameters quirk it works around) as the signing secret
176
+ # -- see Signer::StandardWebhooksSigner#resolve_secret (Codex P1 finding).
177
+ CallableArity.prefers_zero_args?(callable) ? callable.call : callable.call(subscriber)
178
+ end
179
+
180
+ # Net::HTTP requires String keys/values; a non-String pair would otherwise raise mid-flight,
181
+ # a boot-clean declaration turned into a per-attempt crash. A key colliding, CASE-
182
+ # INSENSITIVELY, with a Deliver-managed header or one the signer just emitted this attempt is
183
+ # dropped for the reason `signed_headers`' comment gives: Hash keys don't collide there, but
184
+ # Net::HTTP's header line does, silently, and it is always the LATER assignment that survives
185
+ # -- which a subscriber-controlled row must never be allowed to be for webhook-signature.
186
+ def add_custom_header(out, key, value, reserved)
187
+ # NEVER logs `value` -- `{ Authorization: "Bearer live-token" }` (a plain Symbol-keyed
188
+ # Hash literal, the single most natural way to write this in Ruby) fails the String-key
189
+ # check, and logging the value unconditionally here would copy a live credential
190
+ # straight into application logs the moment anyone wrote a `headers` resolver this way
191
+ # (Codex P1 finding). The key name alone is enough to debug "which header was malformed" --
192
+ # true for the DOCUMENTED common case (a plain Symbol/String literal like `Authorization:`)
193
+ # -- but `headers` exists specifically to carry credentials, and a resolver could just as
194
+ # easily build a Symbol/String key DYNAMICALLY from one (`token.to_sym`, or a String key
195
+ # paired with a non-String value, which is what actually routes a row into this branch) --
196
+ # Symbol/String content is exactly as unconstrained as a compound object's in that case
197
+ # (Codex P1 finding, round 25; round 16 fixed the compound-object case but still trusted
198
+ # ANY Symbol/String verbatim). `key_desc` below only shows a key matching a plausible
199
+ # field-name shape as-is; anything else -- compound, or Symbol/String that doesn't look
200
+ # like one -- is named by class only.
201
+ unless key.is_a?(String) && value.is_a?(String)
202
+ Axn.config.logger.warn("[axn-webhooks] dropping a custom header with a non-String key or value (key: #{key_desc(key)})")
203
+ return
204
+ end
205
+
206
+ # The built-in Transport rejects CR/LF in a header VALUE, but a String KEY containing
207
+ # CR/LF (or a space/colon) would otherwise reach `request[key] = value` unchanged --
208
+ # Net::HTTP serializes whatever key it's handed straight into the wire header line, so a
209
+ # subscriber-controlled `headers` resolver could inject an entirely separate header
210
+ # (Codex P1 finding). Same grammar `sign :hmac`'s own header options are validated
211
+ # against at boot.
212
+ #
213
+ # `#match?` itself isn't safe to call unconditionally: a String in a DIFFERENT encoding
214
+ # than the Regexp (e.g. UTF-16LE) raises `Encoding::CompatibilityError`, and a malformed
215
+ # byte sequence in its OWN declared encoding raises `ArgumentError` -- either way an
216
+ # UNEXPECTED exception escaping delivery, which the async adapter reads as a transient
217
+ # crash and retries forever on a resolver result that will never become valid (Codex P2
218
+ # finding, round 19). `safely_matches?`'s `on_error:` picks what a raised encoding error
219
+ # should be treated as -- `false` here (didn't match a valid field-name -- malformed,
220
+ # drop it).
221
+ unless safely_matches?(key, Signer::HEADER_NAME, on_error: false)
222
+ # NEVER logs `key` here -- unlike the non-String/Symbol-key and unknown-Hash-key cases
223
+ # elsewhere in this file, THIS key already passed "is a String" and just failed the
224
+ # valid-header-name check, so its content is unconstrained. `headers` exists
225
+ # specifically to carry credentials, and a resolver mistake could hand back the
226
+ # credential ITSELF as the key instead of a proper header name (e.g.
227
+ # `{ "Bearer live-token" => "x" }`, or a URL-keyed map) -- logging it in full would copy
228
+ # that credential straight into application logs (Codex P1 finding, round 24). A byte
229
+ # count is enough to debug "the key was malformed" without risking its content.
230
+ Axn.config.logger.warn("[axn-webhooks] dropping custom header with an invalid HTTP field-name or encoding (key: #{key.bytesize}-byte String)")
231
+ return
232
+ end
233
+
234
+ # Net::HTTP itself raises `ArgumentError: header field value cannot include CR/LF` for a
235
+ # value containing either -- unlike a malformed KEY (above), which it happily serializes
236
+ # verbatim. Left unvalidated, a permanently-malformed value would raise an UNEXPECTED
237
+ # exception on every attempt, which the async adapter reads as a transient crash and
238
+ # retries forever, rather than being dropped like every other malformed entry here
239
+ # (Codex P2 finding). `on_error: true` here (treat a raised encoding error as "found
240
+ # CR/LF" -- malformed, drop it) for the same reason `safely_matches?` exists above.
241
+ #
242
+ # Broadened beyond CR/LF to every control byte the RFC 7230 `field-value` grammar forbids
243
+ # (HTAB, 0x09, is the one control byte it explicitly permits) -- a value containing NUL or
244
+ # another stray control character (e.g. BEL) passed this check unvalidated and the
245
+ # built-in Transport serialized it straight onto the wire, where it's equally invalid and
246
+ # can get an otherwise-valid webhook rejected by the receiver or a proxy in between (Codex
247
+ # P2 finding, round 25).
248
+ if safely_matches?(value, FORBIDDEN_HEADER_VALUE_BYTES, on_error: true)
249
+ Axn.config.logger.warn(
250
+ "[axn-webhooks] dropping custom header #{key.inspect} -- value contains a forbidden control character (or has an invalid/incompatible encoding)",
251
+ )
252
+ return
253
+ end
254
+
255
+ if reserved.any? { |r| r.casecmp?(key) }
256
+ Axn.config.logger.warn(
257
+ "[axn-webhooks] dropping custom header #{key.inspect} -- collides with a header Deliver or the active signer already sets",
258
+ )
259
+ return
260
+ end
261
+
262
+ out[key] = value
263
+ end
264
+
265
+ # Only a key matching a plausible field-name shape is safe to show as-is (a Symbol/String
266
+ # built DYNAMICALLY from a credential is exactly as unconstrained as a compound object's
267
+ # #inspect -- see `add_custom_header`'s comment above); anything else, including a
268
+ # Symbol/String that doesn't look like a field name, is named by class only.
269
+ def key_desc(key)
270
+ return "instance of #{key.class}" unless key.is_a?(String) || key.is_a?(Symbol)
271
+
272
+ safely_matches?(key.to_s, PLAUSIBLE_FIELD_NAME, on_error: false) ? key.inspect : "instance of #{key.class}"
273
+ end
274
+
275
+ # `String#match?` raises rather than returning a boolean for two encoding failure modes:
276
+ # `Encoding::CompatibilityError` when `string`'s encoding differs from the Regexp's (e.g. a
277
+ # UTF-16LE header key against a US-ASCII/UTF-8 Regexp), and `ArgumentError` for a malformed
278
+ # byte sequence in `string`'s own declared encoding. Both are treated as "this string is
279
+ # unusable" -- `on_error:` supplies what that should count as for the specific check calling
280
+ # this (see call sites above).
281
+ def safely_matches?(string, regex, on_error:)
282
+ string.match?(regex)
283
+ rescue Encoding::CompatibilityError, ArgumentError
284
+ on_error
285
+ end
286
+
287
+ def user_agent
288
+ suffix = resolve_user_agent_suffix
289
+ return "axn-webhooks/#{Axn::Webhooks::VERSION}" if suffix.nil?
290
+
291
+ "axn-webhooks/#{Axn::Webhooks::VERSION} (#{suffix})"
292
+ end
293
+
294
+ def resolve_user_agent_suffix
295
+ configured = config.user_agent
296
+ return nil if configured.nil?
297
+
298
+ configured.respond_to?(:call) ? configured.call : configured
299
+ end
300
+
301
+ def success?(status) = (200..299).cover?(status)
302
+
303
+ # 5xx, plus the "come back later" 4xx codes.
304
+ def retryable?(status) = status >= 500 || [408, 425, 429].include?(status)
305
+
306
+ # The receiver-supplied body is the one piece of detail a permanent 4xx can offer beyond
307
+ # its status code — truncated so a verbose error page never blows up a log line or an
308
+ # exception report.
309
+ def permanent_failure_message(response)
310
+ "permanent delivery failure (HTTP #{response.status}) for #{event} to #{safe_url}#{truncated_body(response.body)}"
311
+ end
312
+
313
+ # net/http labels every response body ASCII-8BIT regardless of actual content, so `body` may
314
+ # hold arbitrary bytes (invalid UTF-8, or valid multibyte UTF-8 mislabeled as binary). Slice
315
+ # BYTES first (encoding-agnostic, so the cut itself never raises), then force UTF-8 and
316
+ # `scrub` — which also repairs a multibyte character split at the 500-byte boundary — before
317
+ # appending the UTF-8 ellipsis, so the two `+` operands are always compatible.
318
+ # Origin only — scheme://host[:port]. A Slack/Discord/Teams hook puts its secret in the
319
+ # path (and some receivers use a signed query token), so a bare `url` in any message that
320
+ # reaches a log or an error tracker publishes a live credential. Correlate on webhook_id /
321
+ # subscriber_id instead: both are credential-free by design.
322
+ def safe_url = TargetPolicy.redact_url(url)
323
+
324
+ def truncated_body(body)
325
+ return "" if body.nil? || body.empty?
326
+
327
+ bytes = body.b
328
+ truncated = bytes.bytesize > 500
329
+ snippet = bytes.byteslice(0, 500).force_encoding(Encoding::UTF_8).scrub("�")
330
+ snippet += "…" if truncated
331
+ ": #{snippet}"
332
+ end
333
+
334
+ # Only reschedule when BOTH attempts remain AND an async adapter is actually configured for
335
+ # Deliver to reschedule itself onto — otherwise `call_async` would raise a ScriptError
336
+ # (NotImplementedError) that escapes axn's StandardError-only exception boundary entirely,
337
+ # crashing the caller (e.g. Emit's synchronous best-effort fallback fan-out loop). No
338
+ # adapter configured is therefore treated the same as an exhausted retry budget: report
339
+ # once, fail! quietly (no crash, no cross-process retries — matches the documented
340
+ # best-effort promise of the sync fallback path).
341
+ def retry_or_exhaust!(retry_after: nil, network_error: nil)
342
+ if attempt >= config.max_attempts || !async_configured?
343
+ @exhaustion_error = network_error || Axn::Webhooks::Error.new("outbound delivery exhausted for #{event} to #{safe_url}")
344
+ return fail!(terminal_message)
345
+ end
346
+
347
+ delay = [config.backoff.call(attempt), parse_retry_after(retry_after)].compact.max
348
+ self.class.call_async(url:, webhook_id:, body:, event:, vendor:, subscriber_id:, attempt: attempt + 1,
349
+ _async: { wait: delay })
350
+ end
351
+
352
+ def terminal_message
353
+ return "delivery exhausted after #{attempt} attempts for #{event} to #{safe_url}" if attempt >= config.max_attempts
354
+
355
+ "delivery failed for #{event} to #{safe_url} (no async adapter configured to retry attempt #{attempt + 1})"
356
+ end
357
+
358
+ # Presence check ONLY (never branches on adapter type) — mirrors Dispatch's own
359
+ # `async_adapter_configured?` exactly, but against `self.class` since Deliver reschedules
360
+ # ITSELF. An explicit per-class setting (including `false`) always wins over the global
361
+ # default.
362
+ def async_configured?
363
+ return !!self.class._async_adapter unless self.class._async_adapter.nil?
364
+
365
+ Axn.config.default_async?
366
+ end
367
+
368
+ # HTTP header names are case-insensitive, but `Transport` is a public injectable seam — a
369
+ # custom transport (e.g. Faraday-backed) may return a plain Hash with "Retry-After" or
370
+ # "RETRY-AFTER" rather than the lowercased keys net/http's `to_hash` produces. Look up by
371
+ # name case-insensitively instead of assuming lowercase.
372
+ def header_value(headers, name)
373
+ headers.each { |k, v| return v if k.to_s.casecmp?(name) }
374
+ nil
375
+ end
376
+
377
+ # Retry-After per RFC 7231: either delay-seconds (integer) or an HTTP-date. For the
378
+ # HTTP-date form, compute the remaining seconds until that instant, clamped to >= 0 (a
379
+ # past/now date means "no extra delay beyond backoff", not "retry immediately forever").
380
+ def parse_retry_after(value)
381
+ return nil if value.nil? || value.to_s.empty?
382
+
383
+ return Integer(value, 10) if value.to_s.match?(/\A\d+\z/)
384
+
385
+ begin
386
+ [(Time.httpdate(value) - Time.now).to_i, 0].max
387
+ rescue ArgumentError
388
+ nil
389
+ end
390
+ end
391
+
392
+ # Gated `on_failure` handler (registered above, at class-body level): fires on EVERY
393
+ # `fail!` (including the permanent-4xx branch in `#call`), but only reports when
394
+ # `retry_or_exhaust!`'s exhaustion branch actually set `@exhaustion_error` — a permanent-4xx
395
+ # `fail!` never sets it, so this is a no-op there.
396
+ def report_exhaustion_if_needed
397
+ return unless @exhaustion_error
398
+
399
+ report_exhaustion(@exhaustion_error)
400
+ end
401
+
402
+ # Report ONCE at exhaustion via axn's configured reporter (Honeybadger at Teamshares),
403
+ # WITHOUT raising — raising would trigger the adapter to retry the already-exhausted job.
404
+ # `action:` must be the running INSTANCE (`self`), not the class — axn's own internal
405
+ # callers always pass the instance (see executor.rb), and `on_exception` relies on
406
+ # instance-only state (e.g. `action.result`) to enrich the report; the configured reporter
407
+ # itself may also expect a real action instance. `report_exhaustion` is itself an instance
408
+ # method, so `self` here already IS that instance. Called from `report_exhaustion_if_needed`
409
+ # (an `on_failure` callback), which runs AFTER axn has finalized `action.result` as a
410
+ # failure — see the `on_failure` doc comment above `retry_or_exhaust!` for why that ordering
411
+ # matters.
412
+ def report_exhaustion(error)
413
+ # The reporter itself may throw; guard it best-effort (logs+swallows in prod/test, re-raises
414
+ # in dev only when Axn.config.best_effort_raises_in_dev) so a broken reporter never turns
415
+ # exhaustion into a raise the async adapter would retry. `action: self` routes the warn to
416
+ # the running instance, matching axn's own internal best_effort callers.
417
+ Axn::Extensions.best_effort("reporting outbound delivery exhaustion", action: self) do
418
+ # url: redacted to its origin — this context is shipped to an external error tracker.
419
+ Axn.config.on_exception(error, action: self, context: { event:, url: safe_url, webhook_id:, attempt:, subscriber_id: })
420
+ end
421
+ end
422
+ end
423
+ end
424
+ end
425
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Outbound
6
+ # Receiver for the `Axn::Webhooks.outbound do … end` block.
7
+ class DSL
8
+ # Distinguishes "argument omitted" from "an explicit falsy value was passed" for
9
+ # `allow_url`/`headers` below -- `callable = nil` as the default can't tell `allow_url`
10
+ # (nothing given) apart from `allow_url false` (a caller-supplied, non-callable value that
11
+ # must be rejected at boot). `callable || block` treated both identically, silently
12
+ # DISABLING the host policy / header resolver for an explicit `false` rather than
13
+ # surfacing the setting's own "must be a callable" validation error (Codex P1 finding).
14
+ UNSET = Object.new.freeze
15
+ private_constant :UNSET
16
+
17
+ def initialize
18
+ @events = {}
19
+ @sign_spec = nil
20
+ @default_subscribers = nil
21
+ @max_attempts = nil
22
+ @backoff = nil
23
+ @transport = nil
24
+ @vendor = nil
25
+ @user_agent = nil
26
+ @open_timeout = nil
27
+ @read_timeout = nil
28
+ @allowed_hosts = nil
29
+ @allow_url = nil
30
+ @headers = nil
31
+ end
32
+
33
+ def sign(strategy = nil, **opts, &block)
34
+ @sign_spec = { strategy:, opts:, block: }
35
+ end
36
+
37
+ def subscribers(resolver = nil, &block)
38
+ @default_subscribers = resolver || block
39
+ end
40
+
41
+ def max_attempts(value) = @max_attempts = value
42
+ def backoff(callable = nil, &block) = @backoff = callable || block
43
+ def transport(obj) = @transport = obj
44
+
45
+ # The observability facet (Axn::Webhooks.config.vendor_facet) stamped on every Emit/Deliver
46
+ # for events with no per-event override — see `event`'s `vendor:`.
47
+ def vendor(value) = @vendor = value
48
+
49
+ # A suffix identifying the sending app/deploy, appended to the fixed
50
+ # "axn-webhooks/<version>" User-Agent as "axn-webhooks/<version> (<value>)". Plain value or
51
+ # a zero-arity callable, resolved per delivery attempt.
52
+ def user_agent(value = nil, &block) = @user_agent = value || block
53
+
54
+ def timeouts(open: nil, read: nil)
55
+ @open_timeout = open
56
+ @read_timeout = read
57
+ end
58
+
59
+ # A host policy for resolved targets (both a static `to:` Array and a runtime
60
+ # `subscribers`/`to:` lambda's return value go through the same check) -- see TargetPolicy
61
+ # for exact matching semantics. Splat-friendly: `allowed_hosts "a.example", "b.example"` and
62
+ # `allowed_hosts %w[a.example b.example]` both work.
63
+ def allowed_hosts(*values) = @allowed_hosts = values.flatten
64
+
65
+ # A general escape hatch alongside `allowed_hosts` -- called with the parsed URI, must
66
+ # return truthy to allow the target through. Both nil by default (no host policy at all).
67
+ def allow_url(callable = UNSET, &block) = @allow_url = resolve_settable(callable, block)
68
+
69
+ # Per-destination extra headers (e.g. a subscriber's bearer token) -- resolved fresh per
70
+ # DELIVERY ATTEMPT from the Subscriber, never stored, same convention `sign`'s `secret:`
71
+ # follows. 0-arity (ignores the subscriber) or 1-arity (receives it). nil by default.
72
+ def headers(callable = UNSET, &block) = @headers = resolve_settable(callable, block)
73
+
74
+ # rubocop:disable-next Naming/MethodParameterName
75
+ def event(name, to: nil, type: nil, vendor: nil)
76
+ @events[name.to_sym] = { to:, type:, vendor: }
77
+ end
78
+
79
+ # Internal: build the resolved Config, validating declarations.
80
+ def __config__
81
+ # A pure declaration mistake (decided once at boot, never at runtime) — ArgumentError, not
82
+ # Axn::Webhooks::Error, matching Config's own misconfiguration-vs-runtime split.
83
+ raise ArgumentError, "outbound block must declare `sign`" if @sign_spec.nil?
84
+
85
+ @events.each do |name, spec|
86
+ next unless spec[:to].is_a?(Array) && spec[:to].empty?
87
+
88
+ Axn.config.logger.warn("[axn-webhooks] outbound event #{name.inspect} declares an empty `to:` — it will deliver nowhere")
89
+ end
90
+
91
+ Config.new(
92
+ signer: Signer.build(**@sign_spec),
93
+ events: @events,
94
+ default_subscribers: @default_subscribers,
95
+ max_attempts: @max_attempts,
96
+ backoff: @backoff,
97
+ transport: @transport,
98
+ vendor: @vendor,
99
+ user_agent: @user_agent,
100
+ open_timeout: @open_timeout,
101
+ read_timeout: @read_timeout,
102
+ allowed_hosts: @allowed_hosts,
103
+ allow_url: @allow_url,
104
+ headers: @headers,
105
+ )
106
+ end
107
+
108
+ private
109
+
110
+ # `callable` is UNSET only when the method was called with no positional argument at
111
+ # all -- an explicit non-callable value (including `false`) must survive to `Config`'s
112
+ # own setting validator rather than being silently coerced into "not set".
113
+ def resolve_settable(callable, block)
114
+ return block if block
115
+
116
+ UNSET.equal?(callable) ? nil : callable
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end