require "json" require_relative "result" require_relative "http_client" require_relative "log" module Relay # Delivers one webhook payload to one endpoint, with retries. # # Failed attempts back off exponentially (1s, 2s, 4s, 8s, 16s) and give up # after MAX_ATTEMPTS; receivers throttle by IP, so attempts are never # retried back-to-back. A delivery that exhausts its attempts is appended # to the dead-letter file for manual replay. class Delivery MAX_ATTEMPTS = 5 BASE_BACKOFF = 1 # seconds; doubles each attempt def self.attempt_budget = MAX_ATTEMPTS def initialize(client: HttpClient.new, log:, dead_letter_path:) @client = client @log = log @dead_letter_path = dead_letter_path end def deliver(url, payload) body = JSON.generate(payload) last_error = nil MAX_ATTEMPTS.times do |attempt| sleep(BASE_BACKOFF * (2**(attempt - 1))) if attempt.positive? @log.record("attempt", "#{url} ##{attempt + 1}") result = @client.post(url, body) if result.ok? @log.record("delivered", url) return Result.ok(result.value) end last_error = result.error @log.record("failed", "#{url}: #{last_error}") end dead_letter(url, payload, last_error) Result.err("gave up after #{MAX_ATTEMPTS} attempts: #{last_error}") end private def dead_letter(url, payload, error) entry = { "url" => url, "payload" => payload, "error" => error } File.open(@dead_letter_path, "a") { |f| f.puts(JSON.generate(entry)) } @log.record("dead-lettered", url) end end end