From b2f4fcaaa5203bfec62b2a6e73eea8acff0b0ece Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Wed, 15 Oct 2025 23:11:59 +0200 Subject: [PATCH] Gracefully retry federation announcements over HTTP (#355) --- web/lib/potato_mesh/application/federation.rb | 41 +++++++++++- web/spec/federation_spec.rb | 62 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/web/lib/potato_mesh/application/federation.rb b/web/lib/potato_mesh/application/federation.rb index 3868716..a3582e4 100644 --- a/web/lib/potato_mesh/application/federation.rb +++ b/web/lib/potato_mesh/application/federation.rb @@ -156,6 +156,8 @@ module PotatoMesh def announce_instance_to_domain(domain, payload_json) return false unless domain && !domain.empty? + https_failures = [] + instance_uri_candidates(domain, "/api/instances").each do |uri| begin http = build_remote_http_client(uri) @@ -181,16 +183,51 @@ module PotatoMesh status: response.code, ) rescue StandardError => e - warn_log( - "Federation announcement raised exception", + metadata = { context: "federation.announce", target: uri.to_s, error_class: e.class.name, error_message: e.message, + } + + if uri.scheme == "https" && https_connection_refused?(e) + debug_log( + "HTTPS federation announcement failed, retrying with HTTP", + **metadata, + ) + https_failures << metadata + next + end + + warn_log( + "Federation announcement raised exception", + **metadata, ) end end + https_failures.each do |metadata| + warn_log( + "Federation announcement raised exception", + **metadata, + ) + end + + false + end + + # Determine whether an HTTPS announcement failure should fall back to HTTP. + # + # @param error [StandardError] failure raised while attempting HTTPS. + # @return [Boolean] true when the error corresponds to a refused TCP connection. + def https_connection_refused?(error) + current = error + while current + return true if current.is_a?(Errno::ECONNREFUSED) + + current = current.respond_to?(:cause) ? current.cause : nil + end + false end diff --git a/web/spec/federation_spec.rb b/web/spec/federation_spec.rb index 7061b6a..d749c62 100644 --- a/web/spec/federation_spec.rb +++ b/web/spec/federation_spec.rb @@ -34,6 +34,18 @@ RSpec.describe PotatoMesh::App::Federation do def reset_debug_messages @debug_messages = [] end + + def warn_messages + @warn_messages ||= [] + end + + def warn_log(message, **_metadata) + warn_messages << message + end + + def reset_warn_messages + @warn_messages = [] + end end end end @@ -42,6 +54,7 @@ RSpec.describe PotatoMesh::App::Federation do federation_helpers.instance_variable_set(:@remote_instance_cert_store, nil) federation_helpers.instance_variable_set(:@remote_instance_verify_callback, nil) federation_helpers.reset_debug_messages + federation_helpers.reset_warn_messages end describe ".remote_instance_cert_store" do @@ -208,4 +221,53 @@ RSpec.describe PotatoMesh::App::Federation do end.to raise_error(PotatoMesh::App::InstanceFetchError, "Net::ReadTimeout") end end + + describe ".announce_instance_to_domain" do + let(:payload) { "{}" } + let(:https_uri) { URI.parse("https://remote.mesh/api/instances") } + let(:http_uri) { URI.parse("http://remote.mesh/api/instances") } + let(:http_connection) { instance_double("Net::HTTPConnection") } + let(:success_response) { Net::HTTPOK.new("1.1", "200", "OK") } + + before do + allow(success_response).to receive(:code).and_return("200") + end + + it "retries over HTTP when HTTPS connections are refused" do + https_client = instance_double(Net::HTTP) + http_client = instance_double(Net::HTTP) + + allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client) + allow(federation_helpers).to receive(:build_remote_http_client).with(http_uri).and_return(http_client) + + allow(https_client).to receive(:start).and_raise(Errno::ECONNREFUSED.new("refused")) + allow(http_connection).to receive(:request).and_return(success_response) + allow(http_client).to receive(:start).and_yield(http_connection).and_return(success_response) + + result = federation_helpers.announce_instance_to_domain("remote.mesh", payload) + + expect(result).to be(true) + expect(federation_helpers.debug_messages).to include("HTTPS federation announcement failed, retrying with HTTP") + expect(federation_helpers.warn_messages).to be_empty + end + + it "logs a warning when HTTPS refusal persists after HTTP fallback" do + https_client = instance_double(Net::HTTP) + http_client = instance_double(Net::HTTP) + + allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client) + allow(federation_helpers).to receive(:build_remote_http_client).with(http_uri).and_return(http_client) + + allow(https_client).to receive(:start).and_raise(Errno::ECONNREFUSED.new("refused")) + allow(http_client).to receive(:start).and_raise(SocketError.new("dns failure")) + + result = federation_helpers.announce_instance_to_domain("remote.mesh", payload) + + expect(result).to be(false) + expect(federation_helpers.debug_messages).to include("HTTPS federation announcement failed, retrying with HTTP") + expect( + federation_helpers.warn_messages.count { |message| message.include?("Federation announcement raised exception") }, + ).to eq(2) + end + end end