From ffd1980f5a23fd45260f0ceb326cc8fa71629364 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 20 Jul 2026 23:04:26 -0400 Subject: [PATCH] fix(flags): surface polling errors + still spawn poller on initial fetch failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two SDK-78 concerns addressed against master's current polling architecture (PR #149's ConditionVariable + lifecycle-mutex design already covers the original SDK-81 concurrent-start concerns, so those commits from an earlier iteration of this PR are dropped as superseded). - safe_handle_error now warns to stderr unconditionally in addition to dispatching to @error_handler. Prior to this, callers without an explicit error_handler saw no signal for schema drift (NoMethodError, JSON::ParserError, etc.) — the default Mixpanel::ErrorHandler#handle is a no-op, so the polling loop would swallow every failure forever. Matches the convention across mixpanel-python / mixpanel-java / mixpanel-go / mixpanel-node. - start_polling_for_definitions! wraps just the initial fetch in an inner begin/rescue. A transient initial-fetch failure (network blip, HTTP 500) previously fell through to the method-level rescue and returned before the @lifecycle_mutex block ever spawned the poller — a single startup blip left the SDK permanently without polling until the caller manually retried. Now the loop still spawns and retries on the configured interval. --- .../flags/local_flags_provider.rb | 24 ++++++++-- spec/mixpanel-ruby/flags/local_flags_spec.rb | 48 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index 5ea98faa..14a03455 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 @@ -201,11 +211,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 fad90d02..9928fd96 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -931,6 +931,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