From 60cc82c5b5a3c0d9be178b2ac8549d2debb365a1 Mon Sep 17 00:00:00 2001 From: Dogan AY Date: Mon, 12 Jan 2026 15:52:39 +0100 Subject: [PATCH] fix(rpc): cleanly log tcp error on rpc polling --- .../Utils/rpc_client.rb | 40 ++++++++-- .../Utils/schema_polling_client.rb | 12 ++- .../utils/rpc_client_spec.rb | 76 +++++++++++++++++++ .../utils/schema_polling_client_spec.rb | 60 +++++++++++++++ 4 files changed, 182 insertions(+), 6 deletions(-) diff --git a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb index fe554724a..3775eb7db 100644 --- a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb +++ b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb @@ -60,7 +60,21 @@ def fetch_schema(endpoint, if_none_match: nil) def make_request(endpoint, caller: nil, method: :get, payload: nil, symbolize_keys: false, if_none_match: nil) log_request_start(method, endpoint, if_none_match) - client = Faraday.new(url: @api_url) do |faraday| + client = build_faraday_client(symbolize_keys) + headers = build_request_headers(caller, if_none_match) + + response = client.send(method, endpoint, payload, headers) + log_request_complete(response, endpoint) + response + rescue Faraday::ConnectionFailed => e + handle_connection_failed(endpoint, e) + rescue Faraday::TimeoutError => e + handle_timeout_error(endpoint, e) + end + # rubocop:enable Metrics/ParameterLists + + def build_faraday_client(symbolize_keys) + Faraday.new(url: @api_url) do |faraday| faraday.request :json faraday.response :json, parser_options: { symbolize_names: symbolize_keys } faraday.adapter Faraday.default_adapter @@ -68,7 +82,9 @@ def make_request(endpoint, caller: nil, method: :get, payload: nil, symbolize_ke faraday.options.timeout = DEFAULT_TIMEOUT faraday.options.open_timeout = DEFAULT_OPEN_TIMEOUT end + end + def build_request_headers(caller, if_none_match) timestamp = Time.now.utc.iso8601(3) signature = generate_signature(timestamp) @@ -80,12 +96,26 @@ def make_request(endpoint, caller: nil, method: :get, payload: nil, symbolize_ke headers['forest_caller'] = caller.to_json if caller headers['If-None-Match'] = %("#{if_none_match}") if if_none_match + headers + end - response = client.send(method, endpoint, payload, headers) - log_request_complete(response, endpoint) - response + def handle_connection_failed(endpoint, error) + ForestAdminAgent::Facades::Container.logger&.log( + 'Error', + "[RPC Client] Connection failed to #{@api_url}#{endpoint}: #{error.message}" + ) + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "RPC connection failed: Unable to connect to #{@api_url}. Please check if the RPC server is running." + end + + def handle_timeout_error(endpoint, error) + ForestAdminAgent::Facades::Container.logger&.log( + 'Error', + "[RPC Client] Request timeout to #{@api_url}#{endpoint}: #{error.message}" + ) + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "RPC request timeout: The RPC server at #{@api_url} did not respond in time." end - # rubocop:enable Metrics/ParameterLists def generate_signature(timestamp) OpenSSL::HMAC.hexdigest('SHA256', @auth_secret, timestamp) diff --git a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/schema_polling_client.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/schema_polling_client.rb index 3d82d499e..748644905 100644 --- a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/schema_polling_client.rb +++ b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/schema_polling_client.rb @@ -69,6 +69,8 @@ def check_schema log_connection_error(e) rescue ForestAdminAgent::Http::Exceptions::AuthenticationOpenIdClient => e log_authentication_error(e) + rescue ForestAdminDatasourceToolkit::Exceptions::ForestException => e + log_rpc_error(e) rescue StandardError => e log_unexpected_error(e) end @@ -108,7 +110,8 @@ def fetch_initial_schema_sync @introspection_schema = nil rescue Faraday::ConnectionFailed, Faraday::TimeoutError, - ForestAdminAgent::Http::Exceptions::AuthenticationOpenIdClient, StandardError => e + ForestAdminAgent::Http::Exceptions::AuthenticationOpenIdClient, + ForestAdminDatasourceToolkit::Exceptions::ForestException, StandardError => e handle_initial_fetch_error(e) end @@ -213,6 +216,13 @@ def log_connection_error(error) ) end + def log_rpc_error(error) + ForestAdminAgent::Facades::Container.logger&.log( + 'Warn', + "[Schema Polling] RPC error: #{error.message}" + ) + end + def log_authentication_error(error) ForestAdminAgent::Facades::Container.logger&.log( 'Error', diff --git a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/rpc_client_spec.rb b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/rpc_client_spec.rb index f42f220ba..50b4c5ef9 100644 --- a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/rpc_client_spec.rb +++ b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/rpc_client_spec.rb @@ -382,6 +382,82 @@ module Utils end end end + + context 'when connection fails' do + let(:faraday_connection) { instance_double(Faraday::Connection) } + let(:timestamp) { '2025-04-01T14:07:02Z' } + + before do + allow(Faraday).to receive(:new).and_return(faraday_connection) + allow(Time).to receive(:now).and_return(instance_double(Time, utc: instance_double(Time, iso8601: timestamp))) + end + + context 'with Faraday::ConnectionFailed' do + before do + allow(faraday_connection).to receive(:send).and_raise( + Faraday::ConnectionFailed.new('Failed to open TCP connection to localhost:3039') + ) + end + + it 'logs the connection error' do + expect { rpc_client.call_rpc('/rpc/test', method: :get) }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException + ) + + expect(logger).to have_received(:log).with( + 'Error', + %r{\[RPC Client\] Connection failed to http://localhost/rpc/test: Failed to open TCP connection} + ) + end + + it 'raises ForestException with user-friendly message' do + expect { rpc_client.call_rpc('/rpc/test', method: :get) }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + %r{RPC connection failed: Unable to connect to http://localhost.*Please check if the RPC server is running} + ) + end + + it 'handles connection failure in fetch_schema' do + expect { rpc_client.fetch_schema('/rpc/schema') }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + /RPC connection failed/ + ) + end + end + + context 'with Faraday::TimeoutError' do + before do + allow(faraday_connection).to receive(:send).and_raise( + Faraday::TimeoutError.new('Net::ReadTimeout') + ) + end + + it 'logs the timeout error' do + expect { rpc_client.call_rpc('/rpc/test', method: :get) }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException + ) + + expect(logger).to have_received(:log).with( + 'Error', + %r{\[RPC Client\] Request timeout to http://localhost/rpc/test} + ) + end + + it 'raises ForestException with user-friendly message' do + expect { rpc_client.call_rpc('/rpc/test', method: :get) }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + %r{RPC request timeout: The RPC server at http://localhost did not respond in time} + ) + end + + it 'handles timeout in fetch_schema' do + expect { rpc_client.fetch_schema('/rpc/schema') }.to raise_error( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + /RPC request timeout/ + ) + end + end + end end end end diff --git a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/schema_polling_client_spec.rb b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/schema_polling_client_spec.rb index ead4037f5..1a0694970 100644 --- a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/schema_polling_client_spec.rb +++ b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/schema_polling_client_spec.rb @@ -307,6 +307,19 @@ module Utils expect(logger).to have_received(:log).with('Error', /Authentication error/) end + it 'handles RPC errors (ForestException) gracefully' do + client = described_class.new(uri, secret) { |schema| callback.call(schema) } + allow(rpc_client).to receive(:fetch_schema).and_raise( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + 'RPC connection failed: Unable to connect to http://localhost. Please check if the RPC server is running.' + ) + + expect { client.check_schema }.not_to raise_error + + expect(logger).to have_received(:log).with('Warn', /RPC error.*RPC connection failed/) + expect(callback).not_to have_received(:call) + end + it 'handles unexpected errors gracefully' do client = described_class.new(uri, secret) { |schema| callback.call(schema) } allow(rpc_client).to receive(:fetch_schema).and_raise(StandardError, 'Unexpected error') @@ -424,6 +437,29 @@ module Utils expected_etag = Digest::SHA1.hexdigest(JSON.generate(introspection)) expect(client.instance_variable_get(:@cached_etag)).to eq(expected_etag) end + + it 'falls back to introspection_schema on ForestException (RPC wrapped error)' do + client = described_class.new( + uri, secret, + introspection_schema: introspection, + introspection_etag: 'fallback-etag-456' + ) { |s| callback.call(s) } + + allow(rpc_client).to receive(:fetch_schema).and_raise( + ForestAdminDatasourceToolkit::Exceptions::ForestException, + 'RPC connection failed: Unable to connect to http://localhost.' + ) + + client.start? + client.stop + + # Should use the introspection schema as current schema + expect(client.instance_variable_get(:@current_schema)).to eq(introspection) + # Should use the provided etag + expect(client.instance_variable_get(:@cached_etag)).to eq('fallback-etag-456') + # Should log the warning + expect(logger).to have_received(:log).with('Warn', /RPC agent.*unreachable.*ForestException/) + end end describe '#trigger_schema_change_callback' do @@ -518,6 +554,30 @@ module Utils # Should have continued polling after the connection error expect(logger).to have_received(:log).with('Warn', /Connection error/) end + + it 'continues polling after RPC errors (ForestException)' do + # Initial fetch succeeds, first poll fails with ForestException, second poll succeeds + call_count = 0 + allow(rpc_client).to receive(:fetch_schema) do + call_count += 1 + # First call is initial fetch (succeeds), second is first poll (fails), third succeeds + if call_count == 2 + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + 'RPC connection failed: Unable to connect to http://localhost.' + end + + schema_response + end + + client = described_class.new(uri, secret, polling_interval: 1) { |schema| callback.call(schema) } + + client.start? + sleep(3) # Wait for initial fetch + polling + client.stop + + # Should have continued polling after the RPC error + expect(logger).to have_received(:log).with('Warn', /RPC error.*RPC connection failed/) + end end end end