Class: Angarium::Delivery
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Angarium::Delivery
- Defined in:
- app/models/angarium/delivery.rb
Class Method Summary collapse
-
.reap_stalled(older_than: Angarium.config.delivering_timeout) ⇒ Object
Recover deliveries stranded in "delivering": a worker set the state to "delivering" (in #deliver!) but died (crash, deploy, OOM) before recording the attempt or rescheduling, so the job's
pending?guard never re-runs it.
Instance Method Summary collapse
-
#deliver!(client: Client.new, force: forced) ⇒ Object
Performs one attempt.
-
#redeliver!(force: false) ⇒ Object
Reset the retry cycle and re-enqueue immediately.
Class Method Details
.reap_stalled(older_than: Angarium.config.delivering_timeout) ⇒ Object
Recover deliveries stranded in "delivering": a worker set the state to
"delivering" (in #deliver!) but died (crash, deploy, OOM) before
recording the attempt or rescheduling, so the job's pending? guard never
re-runs it. Anything still "delivering" whose last attempt started before
older_than.ago is presumed abandoned and reset to "pending" + re-enqueued.
Returns the number requeued. Keep older_than well above a single attempt's
worst-case duration (open_timeout + http_timeout) so a live-but-slow worker
isn't reaped; a redelivery is at-least-once-safe regardless.
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'app/models/angarium/delivery.rb', line 26 def self.reap_stalled(older_than: Angarium.config.delivering_timeout) return 0 unless older_than ids = where(state: "delivering").where(last_attempt_at: ..older_than.ago).pluck(:id) return 0 if ids.empty? # Reset each id with a state-scoped compare-and-swap, not a blanket # `where(id: ids)`: a delivery can leave `delivering` (succeed, be marked # gone, etc.) between the pluck above and this reset. Re-asserting # `state: "delivering"` means we never drag a completed delivery back to # pending, and — since only the reaper that actually flipped the row # enqueues — two reapers racing the same snapshot can't double-enqueue. requeued = 0 ids.each do |id| changed = where(id: id, state: "delivering") .update_all(state: "pending", next_attempt_at: Time.current, updated_at: Time.current) next if changed.zero? DeliverJob.perform_later(id) requeued += 1 end requeued end |
Instance Method Details
#deliver!(client: Client.new, force: forced) ⇒ Object
Performs one attempt. Records a DeliveryAttempt, then transitions to
succeeded, blocked (SSRF), schedules a retry, or exhausts. Returns the
attempt. force defaults to the persisted forced flag so a reaped or
redelivered forced attempt keeps bypassing the guard; pass it explicitly
only to override in tests.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'app/models/angarium/delivery.rb', line 55 def deliver!(client: Client.new, force: forced) payload = {delivery_id: id, endpoint_id: endpoint_id, event: event.name, force: force} ActiveSupport::Notifications.instrument("deliver.angarium", payload) do # An endpoint's status can change after a delivery is queued: auto-disable # partway through a retry cycle, an operator pause!, or a 410 from a sibling # delivery. The dispatch-time `enabled` filter only gates delivery creation, # not queued retries, so re-check here before attempting. `force: true` # (a manual ping!/redeliver!) overrides the guard for this one attempt, so # you can test an endpoint before re-enabling it; any retry it schedules # follows the normal status rules again. unless force if endpoint.paused? payload[:outcome] = :held return hold_for_pause! end unless endpoint.enabled? payload[:outcome] = :canceled return cancel!(reason: endpoint.status) end end # Atomically claim this delivery (pending -> delivering) so only one # worker ever attempts it. Without the compare-and-swap, two jobs for the # same id (a reaper requeue racing a stale enqueue, an adapter retry, etc.) # would both pass the guard above, both POST, and both read the same stale # attempt_count. If the CAS changes no row, another worker owns it: bail. return nil unless claim_for_attempt! payload[:attempt] = attempt_count # Re-resolve at delivery time (rather than trusting the save-time check) # to catch DNS rebinding: a host that now resolves to a private/disallowed # IP is blocked even if it was fine when the endpoint was saved. Any # disallowed resolved address is a terminal block. addresses = AddressPolicy.resolve(destination_host) if addresses.any? { |ip| !AddressPolicy.ip_allowed?(ip, endpoint) } payload[:outcome] = :blocked payload[:error] = "blocked: destination address not permitted" attempt = delivery_attempts.create!(error: payload[:error]) update!(state: "blocked", next_attempt_at: nil) endpoint.record_delivery_failure! return attempt end # Fail closed: our resolver is the single source of truth. If we can't # resolve the host, do NOT let HTTPX resolve it unvalidated; record a # retryable failure. A transient DNS blip is retried; a persistently # unresolvable host eventually exhausts. if addresses.empty? payload[:outcome] = :unresolvable payload[:error] = "unresolvable host: #{destination_host}" attempt = delivery_attempts.create!(error: payload[:error]) handle_failure! return attempt end body = request_body ts = Time.now.to_i webhook_id = id.to_s signature = Signature.sign(payload: body, id: webhook_id, timestamp: ts, secret: endpoint.active_signing_secrets) headers = (endpoint.custom_headers || {}).merge( "webhook-id" => webhook_id, "webhook-timestamp" => ts.to_s, "webhook-signature" => signature ) result = client.post( endpoint.url, body: body, headers: headers, # Pin the connection to exactly the IP(s) we just validated, so HTTPX # can't re-resolve and connect somewhere else after our check (the # rebinding window). TLS SNI/cert verification still uses the URL's # host. `addresses` is guaranteed non-empty here (the fail-closed # branch above returned early otherwise), so the connection always pins. addresses: addresses.map(&:to_s) ) attempt = delivery_attempts.create!( response_code: result.code, response_body: result.body, error: result.error, duration: result.duration ) payload[:code] = result.code payload[:http_duration] = result.duration payload[:error] = result.error # Status handling follows the Standard Webhooks receiver-etiquette guidance: # 2xx -> success # 410 Gone -> the receiver wants no more webhooks: disable + stop (terminal) # everything else (3xx, 429, 5xx, ...) -> retryable failure. 429/502/504 # ("throttle" codes) are retried with backoff and honor Retry-After. if result.success? payload[:outcome] = :delivered succeed! elsif result.code == 410 payload[:outcome] = :gone handle_gone! else payload[:outcome] = :failed handle_failure!(retry_after: retry_after_seconds(result.headers)) end attempt end end |
#redeliver!(force: false) ⇒ Object
Reset the retry cycle and re-enqueue immediately. Keeps prior
DeliveryAttempt history. force: true sends even if the endpoint is no
longer enabled (for the re-enqueued attempt); it is persisted so the reaper
honors it too. Returns self.
165 166 167 168 169 |
# File 'app/models/angarium/delivery.rb', line 165 def redeliver!(force: false) update!(state: "pending", next_attempt_at: nil, attempt_count: 0, forced: force) DeliverJob.perform_later(id) self end |