From e5f3578f3341a1f0edb08eb1c4b426a10406f87d Mon Sep 17 00:00:00 2001 From: Oskar Westberg Date: Tue, 5 May 2026 12:59:15 +0200 Subject: [PATCH 1/7] Log UDP send failures as warnings, not errors A host with both A and AAAA records on a network with no route for one family (e.g. v4-only network with AAAA record) will fail the send for the unreachable family while the other family succeeds. The previous "Sparoid error sending ..." wording made the partial failure look fatal; reword to "Sparoid warn: skip : " to make clear it is recoverable, mirroring sparoid.rb#20. --- spec/sparoid_spec.cr | 22 ++++++++++++++++++++++ src/client.cr | 4 +++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 9030ddf..603ff2c 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -142,3 +142,25 @@ describe Sparoid::Server do s.try &.close end end + +describe Sparoid::Client do + it "warns instead of erroring when a UDP send fails" do + # Sending to 0.0.0.0 reliably fails with EHOSTUNREACH/ENETUNREACH on most systems + # (matches the v6-no-route-on-v4-only-network case the warn wording is for). + tmp = File.tempfile("sparoid_stderr") + real_stderr = STDERR.dup + STDERR.reopen(tmp) + begin + Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "0.0.0.0", 8484) + rescue + # ignore — we only care about the log output + ensure + STDERR.flush + STDERR.reopen(real_stderr) + end + output = File.read(tmp.path) + tmp.delete + output.should_not contain("Sparoid error sending") + output.should match(/Sparoid warn: skip 0\.0\.0\.0:/) + end +end diff --git a/src/client.cr b/src/client.cr index 6147c9d..1ab3711 100644 --- a/src/client.cr +++ b/src/client.cr @@ -81,7 +81,9 @@ module Sparoid socket.send data, to: addrinfo.ip_address end rescue ex - STDERR << "Sparoid error sending " << ex.inspect << "\n" + # Warn rather than error: a host with both A and AAAA records on a network without + # routing for one family will fail per-addr but the other family's send still works. + STDERR << "Sparoid warn: skip " << addrinfo.ip_address << ": " << ex.message << "\n" ensure socket.close end From 1d456675cfc233965a240159badf8ffb2b37cd16 Mon Sep 17 00:00:00 2001 From: Oskar Westberg Date: Tue, 5 May 2026 13:01:31 +0200 Subject: [PATCH 2/7] Include hostname in UDP send warning --- spec/sparoid_spec.cr | 2 +- src/client.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 603ff2c..d6e8043 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -161,6 +161,6 @@ describe Sparoid::Client do output = File.read(tmp.path) tmp.delete output.should_not contain("Sparoid error sending") - output.should match(/Sparoid warn: skip 0\.0\.0\.0:/) + output.should match(/Sparoid warn: skip 0\.0\.0\.0 \(0\.0\.0\.0:8484\):/) end end diff --git a/src/client.cr b/src/client.cr index 1ab3711..4792046 100644 --- a/src/client.cr +++ b/src/client.cr @@ -83,7 +83,7 @@ module Sparoid rescue ex # Warn rather than error: a host with both A and AAAA records on a network without # routing for one family will fail per-addr but the other family's send still works. - STDERR << "Sparoid warn: skip " << addrinfo.ip_address << ": " << ex.message << "\n" + STDERR << "Sparoid warn: skip " << host << " (" << addrinfo.ip_address << "): " << ex.message << "\n" ensure socket.close end From 764400a7001afb5d25f30ca3b55337f00be0bf2a Mon Sep 17 00:00:00 2001 From: Oskar Westberg Date: Tue, 5 May 2026 13:06:45 +0200 Subject: [PATCH 3/7] Suppress per-address UDP send errors when one family succeeds When a host resolves to both A and AAAA addresses but the network only routes one family, the unreachable family's send raises EHOSTUNREACH. Previously every per-addr failure was logged, which is noisy when the other family's send succeeded and the call as a whole worked. Collect errors and only log them if every address failed; include the original hostname alongside the IP for easier triage. --- spec/sparoid_spec.cr | 52 +++++++++++++++++++++++++++++++------------- src/client.cr | 11 +++++++--- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index d6e8043..12234d5 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -143,24 +143,46 @@ describe Sparoid::Server do end end +# Capture STDERR for the duration of the block. Uses a tempfile (not IO.pipe) to avoid +# blocking when the pipe buffer fills with spec-runner output. +def capture_stderr(& : -> _) : String + tmp = File.tempfile("sparoid_stderr") + real_stderr = STDERR.dup + STDERR.reopen(tmp) + begin + yield + rescue + # ignore — we only care about the log output + ensure + STDERR.flush + STDERR.reopen(real_stderr) + end + output = File.read(tmp.path) + tmp.delete + output +end + describe Sparoid::Client do - it "warns instead of erroring when a UDP send fails" do - # Sending to 0.0.0.0 reliably fails with EHOSTUNREACH/ENETUNREACH on most systems - # (matches the v6-no-route-on-v4-only-network case the warn wording is for). - tmp = File.tempfile("sparoid_stderr") - real_stderr = STDERR.dup - STDERR.reopen(tmp) - begin + it "logs an error when every UDP send fails" do + # Sending to 0.0.0.0 reliably fails with EHOSTUNREACH/ENETUNREACH on most systems, + # so every (single) addrinfo here fails — exercise the all-failed path. + output = capture_stderr do Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "0.0.0.0", 8484) - rescue - # ignore — we only care about the log output - ensure - STDERR.flush - STDERR.reopen(real_stderr) end - output = File.read(tmp.path) - tmp.delete + output.should match(/Sparoid error sending to 0\.0\.0\.0 \(0\.0\.0\.0:8484\):/) + end + + it "stays silent when at least one UDP send succeeds" do + cb = ->(_ip : String, _family : Socket::Family) { } + s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS) + s.bind + spawn s.listen + output = capture_stderr do + Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, ADDRESS.address, ADDRESS.port) + Fiber.yield + end output.should_not contain("Sparoid error sending") - output.should match(/Sparoid warn: skip 0\.0\.0\.0 \(0\.0\.0\.0:8484\):/) + ensure + s.try &.close end end diff --git a/src/client.cr b/src/client.cr index 4792046..59da621 100644 --- a/src/client.cr +++ b/src/client.cr @@ -73,6 +73,7 @@ module Sparoid # Send to all resolved IPs for the hostname private def self.udp_send(host, port, key : String, hmac_key : String, public_ip : String? = nil) : Array(String) host_addresses = Socket::Addrinfo.udp(host, port) + errors = [] of {Socket::IPAddress, Exception} host_addresses.each do |addrinfo| packages = generate_messages(addrinfo.ip_address, public_ip).map { |message| generate_package(key, hmac_key, message) } socket = UDPSocket.new(addrinfo.family) @@ -81,13 +82,17 @@ module Sparoid socket.send data, to: addrinfo.ip_address end rescue ex - # Warn rather than error: a host with both A and AAAA records on a network without - # routing for one family will fail per-addr but the other family's send still works. - STDERR << "Sparoid warn: skip " << host << " (" << addrinfo.ip_address << "): " << ex.message << "\n" + errors << {addrinfo.ip_address, ex} ensure socket.close end end + # Only report errors if every address failed. In mixed-family setups one family commonly + # fails (e.g. AAAA addr on a v4-only network) while the other succeeds — staying silent + # in that case avoids spamming the log for a recoverable condition. + if !host_addresses.empty? && errors.size == host_addresses.size + errors.each { |ip, ex| STDERR << "Sparoid error sending to " << host << " (" << ip << "): " << ex.message << "\n" } + end host_addresses.map &.ip_address.address end From cc68575dc2f7e21201fa1c8e7e08802b5e87bc18 Mon Sep 17 00:00:00 2001 From: Oskar Westberg Date: Tue, 5 May 2026 13:14:01 +0200 Subject: [PATCH 4/7] Extract send_errors_to_report helper and unit-test it The previous integration tests relied on "send to 0.0.0.0 fails with EHOSTUNREACH" to exercise the all-failed branch, which is true on macOS but not on the Linux CI runners where the kernel accepts the send. Extract the error-reporting decision into a pure helper that can be unit-tested with synthetic inputs, independent of OS networking behavior. --- spec/sparoid_spec.cr | 53 +++++++++++++------------------------------- src/client.cr | 24 +++++++++++++------- 2 files changed, 32 insertions(+), 45 deletions(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 12234d5..7fe3560 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -143,46 +143,25 @@ describe Sparoid::Server do end end -# Capture STDERR for the duration of the block. Uses a tempfile (not IO.pipe) to avoid -# blocking when the pipe buffer fills with spec-runner output. -def capture_stderr(& : -> _) : String - tmp = File.tempfile("sparoid_stderr") - real_stderr = STDERR.dup - STDERR.reopen(tmp) - begin - yield - rescue - # ignore — we only care about the log output - ensure - STDERR.flush - STDERR.reopen(real_stderr) - end - output = File.read(tmp.path) - tmp.delete - output -end - describe Sparoid::Client do - it "logs an error when every UDP send fails" do - # Sending to 0.0.0.0 reliably fails with EHOSTUNREACH/ENETUNREACH on most systems, - # so every (single) addrinfo here fails — exercise the all-failed path. - output = capture_stderr do - Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "0.0.0.0", 8484) + describe ".send_errors_to_report" do + it "returns nothing when at least one send succeeded" do + ip = Socket::IPAddress.new("1.1.1.1", 1) + results = [{ip, nil.as(Exception?)}, {ip, Exception.new("boom").as(Exception?)}] + Sparoid::Client.send_errors_to_report(results).should be_empty end - output.should match(/Sparoid error sending to 0\.0\.0\.0 \(0\.0\.0\.0:8484\):/) - end - it "stays silent when at least one UDP send succeeds" do - cb = ->(_ip : String, _family : Socket::Family) { } - s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS) - s.bind - spawn s.listen - output = capture_stderr do - Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, ADDRESS.address, ADDRESS.port) - Fiber.yield + it "returns all errors when every send failed" do + ip1 = Socket::IPAddress.new("1.1.1.1", 1) + ip2 = Socket::IPAddress.new("2.2.2.2", 2) + err1 = Exception.new("a") + err2 = Exception.new("b") + results = [{ip1, err1.as(Exception?)}, {ip2, err2.as(Exception?)}] + Sparoid::Client.send_errors_to_report(results).should eq [{ip1, err1}, {ip2, err2}] + end + + it "returns nothing when there were no addresses" do + Sparoid::Client.send_errors_to_report([] of {Socket::IPAddress, Exception?}).should be_empty end - output.should_not contain("Sparoid error sending") - ensure - s.try &.close end end diff --git a/src/client.cr b/src/client.cr index 59da621..fa3628a 100644 --- a/src/client.cr +++ b/src/client.cr @@ -73,29 +73,37 @@ module Sparoid # Send to all resolved IPs for the hostname private def self.udp_send(host, port, key : String, hmac_key : String, public_ip : String? = nil) : Array(String) host_addresses = Socket::Addrinfo.udp(host, port) - errors = [] of {Socket::IPAddress, Exception} - host_addresses.each do |addrinfo| + results = host_addresses.map do |addrinfo| packages = generate_messages(addrinfo.ip_address, public_ip).map { |message| generate_package(key, hmac_key, message) } socket = UDPSocket.new(addrinfo.family) + error = nil.as(Exception?) begin packages.each do |data| socket.send data, to: addrinfo.ip_address end rescue ex - errors << {addrinfo.ip_address, ex} + error = ex ensure socket.close end + {addrinfo.ip_address, error} end - # Only report errors if every address failed. In mixed-family setups one family commonly - # fails (e.g. AAAA addr on a v4-only network) while the other succeeds — staying silent - # in that case avoids spamming the log for a recoverable condition. - if !host_addresses.empty? && errors.size == host_addresses.size - errors.each { |ip, ex| STDERR << "Sparoid error sending to " << host << " (" << ip << "): " << ex.message << "\n" } + send_errors_to_report(results).each do |ip, ex| + STDERR << "Sparoid error sending to " << host << " (" << ip << "): " << ex.message << "\n" end host_addresses.map &.ip_address.address end + # Decide which UDP send errors to surface. Empty if any send succeeded — a host with both + # A and AAAA records on a network that only routes one family otherwise spams errors for + # the unreachable family even though the other family worked. + def self.send_errors_to_report(results : Array({Socket::IPAddress, Exception?})) : Array({Socket::IPAddress, Exception}) + empty = [] of {Socket::IPAddress, Exception} + return empty if results.empty? + return empty if results.any? { |_, err| err.nil? } + results.map { |ip, err| {ip, err.not_nil!} } + end + private def self.encrypt(key, hmac_key, data) : Bytes cipher = OpenSSL::Cipher.new("aes-256-cbc") cipher.encrypt From f7a59c771dc322d796de5205e9d8e67ab5200445 Mon Sep 17 00:00:00 2001 From: Oskar Westberg Date: Tue, 5 May 2026 13:28:20 +0200 Subject: [PATCH 5/7] Raise SendError when no UDP send succeeds, warn on partial failure - Per-address failure with at least one success: STDERR warn line ("Sparoid warn: skip (): ") so v4-only networks don't make AAAA-resolved hosts log catastrophically. - Every address failed: raise Sparoid::Client::SendError with all per-address details. The CLI already catches and exits 1, so this surfaces a clear actionable failure instead of returning quietly and failing later in fdpass. --- spec/sparoid_spec.cr | 26 ++++++++++---------------- src/client.cr | 33 ++++++++++++++++++--------------- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 7fe3560..0ad9846 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -144,24 +144,18 @@ describe Sparoid::Server do end describe Sparoid::Client do - describe ".send_errors_to_report" do - it "returns nothing when at least one send succeeded" do - ip = Socket::IPAddress.new("1.1.1.1", 1) - results = [{ip, nil.as(Exception?)}, {ip, Exception.new("boom").as(Exception?)}] - Sparoid::Client.send_errors_to_report(results).should be_empty - end - - it "returns all errors when every send failed" do + describe ".format_send_errors" do + it "joins host, address and message for each error" do ip1 = Socket::IPAddress.new("1.1.1.1", 1) ip2 = Socket::IPAddress.new("2.2.2.2", 2) - err1 = Exception.new("a") - err2 = Exception.new("b") - results = [{ip1, err1.as(Exception?)}, {ip2, err2.as(Exception?)}] - Sparoid::Client.send_errors_to_report(results).should eq [{ip1, err1}, {ip2, err2}] - end - - it "returns nothing when there were no addresses" do - Sparoid::Client.send_errors_to_report([] of {Socket::IPAddress, Exception?}).should be_empty + errors = [ + {ip1, Exception.new("no route")}, + {ip2, Exception.new("network down")}, + ] + msg = Sparoid::Client.format_send_errors("example.com", errors) + msg.should contain("example.com") + msg.should contain("1.1.1.1:1: no route") + msg.should contain("2.2.2.2:2: network down") end end end diff --git a/src/client.cr b/src/client.cr index fa3628a..5ea9f83 100644 --- a/src/client.cr +++ b/src/client.cr @@ -11,6 +11,8 @@ require "wait_group" module Sparoid class Client + class SendError < Exception; end + def self.new(config_path = "~/.sparoid.ini") key = ENV.fetch("SPAROID_KEY", "") hmac_key = ENV.fetch("SPAROID_HMAC_KEY", "") @@ -70,38 +72,39 @@ module Sparoid exit 1 # only if all connects fails end - # Send to all resolved IPs for the hostname + # Send to all resolved IPs for the hostname. + # Per-address failures are logged as warnings if at least one address succeeded. + # If every address fails, raises SendError. private def self.udp_send(host, port, key : String, hmac_key : String, public_ip : String? = nil) : Array(String) host_addresses = Socket::Addrinfo.udp(host, port) - results = host_addresses.map do |addrinfo| + errors = [] of {Socket::IPAddress, Exception} + successes = 0 + host_addresses.each do |addrinfo| packages = generate_messages(addrinfo.ip_address, public_ip).map { |message| generate_package(key, hmac_key, message) } socket = UDPSocket.new(addrinfo.family) - error = nil.as(Exception?) begin packages.each do |data| socket.send data, to: addrinfo.ip_address end + successes += 1 rescue ex - error = ex + errors << {addrinfo.ip_address, ex} ensure socket.close end - {addrinfo.ip_address, error} end - send_errors_to_report(results).each do |ip, ex| - STDERR << "Sparoid error sending to " << host << " (" << ip << "): " << ex.message << "\n" + if successes.zero? && !errors.empty? + raise SendError.new(format_send_errors(host, errors)) + end + errors.each do |ip, ex| + STDERR << "Sparoid warn: skip " << host << " (" << ip << "): " << ex.message << "\n" end host_addresses.map &.ip_address.address end - # Decide which UDP send errors to surface. Empty if any send succeeded — a host with both - # A and AAAA records on a network that only routes one family otherwise spams errors for - # the unreachable family even though the other family worked. - def self.send_errors_to_report(results : Array({Socket::IPAddress, Exception?})) : Array({Socket::IPAddress, Exception}) - empty = [] of {Socket::IPAddress, Exception} - return empty if results.empty? - return empty if results.any? { |_, err| err.nil? } - results.map { |ip, err| {ip, err.not_nil!} } + def self.format_send_errors(host : String, errors : Array({Socket::IPAddress, Exception})) : String + details = errors.map { |ip, ex| "#{ip}: #{ex.message}" }.join("; ") + "Sparoid: failed to send to any address for #{host}: #{details}" end private def self.encrypt(key, hmac_key, data) : Bytes From 35fe110a5dd83f47fe2385abce94b6ca7358a104 Mon Sep 17 00:00:00 2001 From: Oskar Westberg Date: Tue, 5 May 2026 14:20:35 +0200 Subject: [PATCH 6/7] Emit partial-failure warnings via Log.warn instead of raw STDERR Apps embedding sparoid as a shard at scale (15k+ servers) need partial UDP send failures classified as WARN-level by their monitoring, not as ERROR-level (which most aggregators infer from STDERR by default). - Sparoid::Client::Log = ::Log.for(self) declares a log source so apps can filter sparoid output specifically. - udp_send emits per-address skips via Log.warn { ... }. - client-cli configures Log.setup_from_env with an STDERR backend (in :connect mode STDOUT is the unix-domain FD-passing channel and isn't safe for free-form output). --- src/client-cli.cr | 4 ++++ src/client.cr | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/client-cli.cr b/src/client-cli.cr index 338ebb6..17b45cf 100644 --- a/src/client-cli.cr +++ b/src/client-cli.cr @@ -1,7 +1,11 @@ +require "log" require "option_parser" require "./client" require "./version" +# Route logs to STDERR (in :connect mode STDOUT is the unix-domain socket used for FD passing). +Log.setup_from_env(default_level: :info, backend: Log::IOBackend.new(STDERR)) + subcommand = :none host = "0.0.0.0" port = 8484 diff --git a/src/client.cr b/src/client.cr index 5ea9f83..a69a86e 100644 --- a/src/client.cr +++ b/src/client.cr @@ -1,3 +1,4 @@ +require "log" require "socket" require "random/secure" require "openssl/cipher" @@ -11,6 +12,8 @@ require "wait_group" module Sparoid class Client + Log = ::Log.for(self) + class SendError < Exception; end def self.new(config_path = "~/.sparoid.ini") @@ -97,7 +100,7 @@ module Sparoid raise SendError.new(format_send_errors(host, errors)) end errors.each do |ip, ex| - STDERR << "Sparoid warn: skip " << host << " (" << ip << "): " << ex.message << "\n" + Log.warn { "skip #{host} (#{ip}): #{ex.message}" } end host_addresses.map &.ip_address.address end From 82cc21e6f5acaea3aaadb2ec376584032fc0c49a Mon Sep 17 00:00:00 2001 From: Oskar Westberg Date: Wed, 6 May 2026 08:06:01 +0200 Subject: [PATCH 7/7] Address PR review: drop redundant prefix, test send-result branching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SendError no longer prepends 'Sparoid:' to the message — the CLI's rescue clause already prefixes 'Sparoid error:', so the previous format produced 'Sparoid error: Sparoid: failed to send...'. - Replace the format_send_errors formatting helper with a process_send_results helper that owns the partial-vs-total decision. This raises SendError directly when every send failed and returns the per-address partial-failure errors otherwise. - Specs now cover both branches plus the all-succeeded and empty-input cases, exercising the actual control flow rather than just message formatting. --- spec/sparoid_spec.cr | 41 ++++++++++++++++++++++++++++++++--------- src/client.cr | 26 ++++++++++++++------------ 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 0ad9846..95f6614 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -144,18 +144,41 @@ describe Sparoid::Server do end describe Sparoid::Client do - describe ".format_send_errors" do - it "joins host, address and message for each error" do + describe ".process_send_results" do + it "raises SendError when every send failed" do ip1 = Socket::IPAddress.new("1.1.1.1", 1) ip2 = Socket::IPAddress.new("2.2.2.2", 2) - errors = [ - {ip1, Exception.new("no route")}, - {ip2, Exception.new("network down")}, + results = [ + {ip1, Exception.new("no route").as(Exception?)}, + {ip2, Exception.new("network down").as(Exception?)}, ] - msg = Sparoid::Client.format_send_errors("example.com", errors) - msg.should contain("example.com") - msg.should contain("1.1.1.1:1: no route") - msg.should contain("2.2.2.2:2: network down") + ex = expect_raises(Sparoid::Client::SendError) do + Sparoid::Client.process_send_results("example.com", results) + end + ex.message.to_s.should contain("example.com") + ex.message.to_s.should contain("1.1.1.1:1: no route") + ex.message.to_s.should contain("2.2.2.2:2: network down") + end + + it "returns partial-failure errors when at least one send succeeded" do + ip1 = Socket::IPAddress.new("1.1.1.1", 1) + ip2 = Socket::IPAddress.new("2.2.2.2", 2) + err = Exception.new("oops") + results = [ + {ip1, nil.as(Exception?)}, + {ip2, err.as(Exception?)}, + ] + Sparoid::Client.process_send_results("example.com", results).should eq [{ip2, err}] + end + + it "returns nothing when all sends succeeded" do + ip = Socket::IPAddress.new("1.1.1.1", 1) + results = [{ip, nil.as(Exception?)}] + Sparoid::Client.process_send_results("example.com", results).should be_empty + end + + it "returns nothing when there were no addresses" do + Sparoid::Client.process_send_results("example.com", [] of {Socket::IPAddress, Exception?}).should be_empty end end end diff --git a/src/client.cr b/src/client.cr index a69a86e..e525745 100644 --- a/src/client.cr +++ b/src/client.cr @@ -80,34 +80,36 @@ module Sparoid # If every address fails, raises SendError. private def self.udp_send(host, port, key : String, hmac_key : String, public_ip : String? = nil) : Array(String) host_addresses = Socket::Addrinfo.udp(host, port) - errors = [] of {Socket::IPAddress, Exception} - successes = 0 - host_addresses.each do |addrinfo| + results = host_addresses.map do |addrinfo| packages = generate_messages(addrinfo.ip_address, public_ip).map { |message| generate_package(key, hmac_key, message) } socket = UDPSocket.new(addrinfo.family) + error = nil.as(Exception?) begin packages.each do |data| socket.send data, to: addrinfo.ip_address end - successes += 1 rescue ex - errors << {addrinfo.ip_address, ex} + error = ex ensure socket.close end + {addrinfo.ip_address, error} end - if successes.zero? && !errors.empty? - raise SendError.new(format_send_errors(host, errors)) - end - errors.each do |ip, ex| + process_send_results(host, results).each do |ip, ex| Log.warn { "skip #{host} (#{ip}): #{ex.message}" } end host_addresses.map &.ip_address.address end - def self.format_send_errors(host : String, errors : Array({Socket::IPAddress, Exception})) : String - details = errors.map { |ip, ex| "#{ip}: #{ex.message}" }.join("; ") - "Sparoid: failed to send to any address for #{host}: #{details}" + # Decide whether per-address send failures are partial (warn) or total (raise). + # Returns the per-address errors to warn about. Raises SendError when every send failed. + def self.process_send_results(host : String, results : Array({Socket::IPAddress, Exception?})) : Array({Socket::IPAddress, Exception}) + errors = results.compact_map { |ip, err| err.try { |e| {ip, e} } } + if !results.empty? && errors.size == results.size + details = errors.map { |ip, ex| "#{ip}: #{ex.message}" }.join("; ") + raise SendError.new("failed to send to any address for #{host}: #{details}") + end + errors end private def self.encrypt(key, hmac_key, data) : Bytes