diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index 56e22c6..9f070a1 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -71,7 +71,17 @@ def start_polling_for_definitions! # blocking HTTP call, and holding the lifecycle lock across it would # block a concurrent stop_polling_for_definitions! for the full request # timeout. - fetch_flag_definitions + # + # A transient failure here (network blip, HTTP 500) should NOT prevent + # the polling thread from spawning — the loop retries on the configured + # interval. Without this inner rescue, the outer rescue below would + # catch and return, leaving the SDK permanently without polling until + # the caller manually retried. + begin + fetch_flag_definitions + rescue StandardError => e + safe_handle_error(e) + end @lifecycle_mutex.synchronize do # If a stop arrived during/after our @stop_polling clear above, abort @@ -202,11 +212,19 @@ def get_all_variants(context) # Wrap @error_handler.handle so a misbehaving handler can't kill the # polling thread mid-loop — that would leave @polling_thread non-nil but # dead, and (without the .alive? check in start) silently prevent restart. + # + # Always warn to stderr as well. The default Mixpanel::ErrorHandler#handle + # is a no-op, so dispatching only via @error_handler swallows schema drift + # (NoMethodError, JSON::ParserError, etc.) — the loop runs forever + # undetected. Matches the convention in mixpanel-python / mixpanel-java / + # mixpanel-go / mixpanel-node, all of which log unconditionally and keep + # polling. def safe_handle_error(error) + warn "[Mixpanel] Failed to fetch flag definitions: #{error.class}: #{error.message}" @error_handler.handle(error) if @error_handler rescue StandardError - # Swallow: keeping the polling loop alive is more important than - # propagating a broken handler's failure. + # Swallow handler failures: keeping the polling loop alive is more + # important than propagating a broken handler's failure. end def fetch_flag_definitions diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 7382ac3..70a4987 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -1001,6 +1001,54 @@ def user_context_with_properties(properties) polling_provider.stop_polling_for_definitions! end end + + # SDK-78: safe_handle_error previously dispatched only to @error_handler, + # whose default Mixpanel::ErrorHandler#handle is a no-op — schema drift + # (NoMethodError, JSON::ParserError, etc.) looped forever undetected. + # Warn to stderr unconditionally so failures are visible without an + # error_handler being configured. + it 'warns to stderr when fetch raises and no error_handler is configured' do + stub_request(:get, endpoint_url_regex).to_return(status: 500, body: 'server down') + + polling_provider = Mixpanel::Flags::LocalFlagsProvider.new( + test_token, + { enable_polling: false }, + mock_tracker, + nil + ) + + expect { polling_provider.start_polling_for_definitions! } + .to output(/\[Mixpanel\] Failed to fetch flag definitions: Mixpanel::ServerError/).to_stderr + end + + # Regression: previously the outer rescue on start_polling_for_definitions! + # caught an initial-fetch failure and returned before the @lifecycle_mutex + # block ever spawned the poller thread. A single startup blip (transient + # 500 / network timeout) left the SDK permanently without a poller until + # the caller retried — the whole point of polling. + it 'still spawns the polling thread when the initial fetch fails' do + stub_request(:get, endpoint_url_regex).to_return(status: 500, body: 'transient') + + polling_provider = Mixpanel::Flags::LocalFlagsProvider.new( + test_token, + { enable_polling: true, polling_interval_in_seconds: 30 }, + mock_tracker, + nil + ) + + begin + # Warning is expected because the initial fetch fails; capture stderr + # so it doesn't pollute test output. + expect { polling_provider.start_polling_for_definitions! } + .to output(/\[Mixpanel\] Failed to fetch flag definitions/).to_stderr + + polling_thread = polling_provider.instance_variable_get(:@polling_thread) + expect(polling_thread).not_to be_nil + expect(polling_thread).to be_alive + ensure + polling_provider.stop_polling_for_definitions! + end + end end describe 'service account credentials' do