diff --git a/lib/network.rb b/lib/network.rb index 44401cd..fa30370 100644 --- a/lib/network.rb +++ b/lib/network.rb @@ -7,6 +7,7 @@ RETRY_CODES = [408, 500, 502, 503, 504, 522, 524, 599].freeze module Statsig + STATSIG_CDN_DCS_BASE = 'https://api.statsigcdn.com/v2'.freeze class NetworkError < StandardError attr_reader :http_code @@ -44,6 +45,14 @@ def download_config_specs(since_time, context) get(dcs_url) end + def download_config_specs_fallback(since_time, context) + dcs_url = "#{STATSIG_CDN_DCS_BASE}/download_config_specs/#{@server_secret}.json" + if since_time.positive? + dcs_url += "?sinceTime=#{since_time}" + end + get(dcs_url) + end + def post_logs(events, error_boundary) url = @options.log_event_url event_count = events.length diff --git a/lib/spec_store.rb b/lib/spec_store.rb index 7721a52..439ded3 100644 --- a/lib/spec_store.rb +++ b/lib/spec_store.rb @@ -6,6 +6,8 @@ module Statsig class SpecStore + STATSIG_NETWORK_FALLBACK_THRESHOLD = 5 + attr_accessor :last_config_sync_time attr_accessor :initial_config_sync_time attr_accessor :init_reason @@ -48,6 +50,7 @@ def initialize(network, options, error_callback, diagnostics, error_boundary, lo @secret_key = secret_key @unsupported_configs = Set.new @sdk_configs = sdk_config + @sync_failure_count = 0 startTime = (Time.now.to_f * 1000).to_i @@ -84,6 +87,9 @@ def initialize(network, options, error_callback, diagnostics, error_boundary, lo if @init_reason == EvaluationReason::UNINITIALIZED failure_details = download_config_specs('initialize') + if !failure_details.nil? && @options.fallback_to_statsig_api && using_proxy_for_dcs? + failure_details = download_config_specs_fallback('initialize') + end end @initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time @@ -205,10 +211,20 @@ def maybe_restart_background_threads def sync_config_specs if @options.data_store&.should_be_used_for_querying_updates(Interfaces::IDataStore::CONFIG_SPECS_V2_KEY) - load_config_specs_from_storage_adapter('config_sync') + failure_details = load_config_specs_from_storage_adapter('config_sync') + else + failure_details = download_config_specs('config_sync') + end + + if failure_details.nil? + @sync_failure_count = 0 else - download_config_specs('config_sync') + @sync_failure_count += 1 + if @options.fallback_to_statsig_api && using_proxy_for_dcs? && (@sync_failure_count % STATSIG_NETWORK_FALLBACK_THRESHOLD == 0) + download_config_specs_fallback('config_sync') + end end + @logger.log_diagnostics_event(@diagnostics, 'config_sync') end @@ -223,6 +239,58 @@ def sync_id_lists private + def using_proxy_for_dcs? + !@options.download_config_specs_url.start_with?(STATSIG_CDN_DCS_BASE) + end + + def download_config_specs_fallback(context) + response, e = @network.download_config_specs_fallback(@last_config_sync_time, context) + handle_config_specs_response(context, 'download_config_specs_fallback', 'CONFIG_SPECS_FALLBACK', response, e) + end + + def handle_config_specs_response(context, diagnostic_key, error_prefix, response, e) + tracker = @diagnostics.track(context, diagnostic_key, 'network_request') + + error = nil + failure_details = nil + begin + code = response&.status.to_i + if e.is_a? NetworkError + code = e.http_code + failure_details = {statusCode: code, exception: e, reason: "#{error_prefix}_NETWORK_ERROR"} + elsif !e.nil? + # Transport-level errors (ECONNREFUSED, timeout, DNS, SSL) are caught and returned as values + # by network.rb. Without this, failure_details stays nil and the caller treats it as success. + failure_details = {exception: e, reason: "#{error_prefix}_CONNECTION_ERROR"} + end + tracker.end(statusCode: code, success: e.nil?, sdkRegion: response&.headers&.[]('X-Statsig-Region')) + + if e.nil? + unless response.nil? + tracker = @diagnostics.track(context, diagnostic_key, 'process') + body = response.body.to_s + failure_details = process_specs(body) + if failure_details.nil? + @init_reason = EvaluationReason::NETWORK + end + tracker.end(success: @init_reason == EvaluationReason::NETWORK) + + unless response.body.nil? or @rules_updated_callback.nil? + @rules_updated_callback.call(body, @last_config_sync_time) + end + end + else + error = e + end + rescue StandardError => e + failure_details = {exception: e, reason: "INTERNAL_ERROR"} + error = e + end + + @error_callback.call(error) unless error.nil? or @error_callback.nil? + return failure_details + end + def load_config_specs_from_storage_adapter(context) tracker = @diagnostics.track(context, 'data_store_config_specs', 'fetch') cached_values = @options.data_store.get(Interfaces::IDataStore::CONFIG_SPECS_V2_KEY) @@ -284,44 +352,8 @@ def spawn_sync_id_lists_thread end def download_config_specs(context) - tracker = @diagnostics.track(context, 'download_config_specs', 'network_request') - - error = nil - failure_details = nil - begin - response, e = @network.download_config_specs(@last_config_sync_time, context) - code = response&.status.to_i - if e.is_a? NetworkError - code = e.http_code - failure_details = {statusCode: code, exception: e, reason: "CONFIG_SPECS_NETWORK_ERROR"} - end - tracker.end(statusCode: code, success: e.nil?, sdkRegion: response&.headers&.[]('X-Statsig-Region')) - - if e.nil? - unless response.nil? - tracker = @diagnostics.track(context, 'download_config_specs', 'process') - body = response.body.to_s - failure_details = process_specs(body) - if failure_details.nil? - @init_reason = EvaluationReason::NETWORK - end - tracker.end(success: @init_reason == EvaluationReason::NETWORK) - - unless response.body.nil? or @rules_updated_callback.nil? - @rules_updated_callback.call(body, - @last_config_sync_time) - end - end - else - error = e - end - rescue StandardError => e - failure_details = {exception: e, reason: "INTERNAL_ERROR"} - error = e - end - - @error_callback.call(error) unless error.nil? or @error_callback.nil? - return failure_details + response, e = @network.download_config_specs(@last_config_sync_time, context) + handle_config_specs_response(context, 'download_config_specs', 'CONFIG_SPECS', response, e) end def process_specs(specs_string, from_adapter: false) diff --git a/lib/statsig_options.rb b/lib/statsig_options.rb index aa85686..431f49b 100644 --- a/lib/statsig_options.rb +++ b/lib/statsig_options.rb @@ -96,6 +96,12 @@ class StatsigOptions # default: 0 attr_accessor :initialize_retry_limit + # When true, if the configured download_config_specs_url fails, the SDK will + # automatically fall back to the Statsig CDN to fetch config specs. + # Only effective when a custom download_config_specs_url is configured. + # default: false + attr_accessor :fallback_to_statsig_api + def initialize( environment = nil, download_config_specs_url: nil, @@ -120,7 +126,8 @@ def initialize( post_logs_retry_backoff: nil, user_persistent_storage: nil, disable_evaluation_memoization: false, - initialize_retry_limit: 0 + initialize_retry_limit: 0, + fallback_to_statsig_api: false ) @environment = environment.is_a?(Hash) ? environment : nil @@ -152,5 +159,6 @@ def initialize( @user_persistent_storage = user_persistent_storage @disable_evaluation_memoization = disable_evaluation_memoization @initialize_retry_limit = initialize_retry_limit + @fallback_to_statsig_api = fallback_to_statsig_api end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0466e5b..9ce4949 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -87,6 +87,7 @@ def assert_nothing_raised(*) StatsigErrorBoundaryUsageTest TestStore TestSymbolHashes + TestSyncConfigFallback TestURIHelper UserFieldsTest UserTest diff --git a/test/test_sync_config_fallback.rb b/test/test_sync_config_fallback.rb new file mode 100644 index 0000000..c8e0c7c --- /dev/null +++ b/test/test_sync_config_fallback.rb @@ -0,0 +1,126 @@ +require_relative 'test_helper' +require 'minitest' +require 'minitest/autorun' +require 'webmock/minitest' +require 'statsig' + +CUSTOM_DCS_BASE = 'http://custom-proxy.example.com/v2'.freeze +STATSIG_CDN_BASE = 'https://api.statsigcdn.com/v2'.freeze + +class TestSyncConfigFallback < BaseTest + suite :TestSyncConfigFallback + + def setup + super + @cdn_call_count = 0 + @custom_call_count = 0 + @dcs_response = File.read("#{__dir__}/data/download_config_specs.json") + + WebMock.enable! + WebMock.disable_net_connect! + + stub_download_config_specs(STATSIG_CDN_BASE) + .to_return do |_req| + @cdn_call_count += 1 + { status: 200, body: @dcs_response, headers: { 'Content-Type' => 'application/json' } } + end + + stub_request(:post, 'https://statsigapi.net/v1/get_id_lists') + .to_return(status: 200, body: '{}') + stub_request(:post, 'https://statsigapi.net/v1/log_event') + .to_return(status: 200) + end + + def teardown + Statsig.shutdown rescue nil + WebMock.reset! + WebMock.disable! + super + end + + def stub_custom(status:, body: nil) + stub_download_config_specs(CUSTOM_DCS_BASE) + .to_return do |_req| + @custom_call_count += 1 + { status: status, body: body || @dcs_response, headers: { 'Content-Type' => 'application/json' } } + end + end + + def custom_options(extra = {}) + StatsigOptions.new( + download_config_specs_url: "#{CUSTOM_DCS_BASE}/download_config_specs/", + fallback_to_statsig_api: true, + disable_rulesets_sync: true, + disable_idlists_sync: true, + **extra + ) + end + + def test_no_fallback_when_primary_succeeds + stub_custom(status: 200) + Statsig.initialize(SDK_KEY, custom_options) + + assert_equal 1, @custom_call_count + assert_equal 0, @cdn_call_count + end + + def test_fallback_triggered_on_init_500 + stub_custom(status: 500) + Statsig.initialize(SDK_KEY, custom_options) + + assert @cdn_call_count > 0, "Expected fallback to CDN on init failure but CDN was not called" + end + + def test_no_fallback_when_option_false + stub_custom(status: 500) + options = StatsigOptions.new( + download_config_specs_url: "#{CUSTOM_DCS_BASE}/download_config_specs/", + fallback_to_statsig_api: false, + disable_rulesets_sync: true, + disable_idlists_sync: true + ) + Statsig.initialize(SDK_KEY, options) + + assert_equal 0, @cdn_call_count + end + + def test_fallback_triggered_during_background_sync + original_threshold = Statsig::SpecStore::STATSIG_NETWORK_FALLBACK_THRESHOLD + Statsig::SpecStore.send(:remove_const, :STATSIG_NETWORK_FALLBACK_THRESHOLD) + Statsig::SpecStore.const_set(:STATSIG_NETWORK_FALLBACK_THRESHOLD, 1) + + begin + stub_custom(status: 200) + options = StatsigOptions.new( + download_config_specs_url: "#{CUSTOM_DCS_BASE}/download_config_specs/", + fallback_to_statsig_api: true, + rulesets_sync_interval: 0.3, + disable_idlists_sync: true + ) + Statsig.initialize(SDK_KEY, options) + cdn_after_init = @cdn_call_count + + WebMock.reset! + WebMock.disable_net_connect! + stub_download_config_specs(CUSTOM_DCS_BASE) + .to_return { @custom_call_count += 1; { status: 500 } } + stub_download_config_specs(STATSIG_CDN_BASE) + .to_return do |_req| + @cdn_call_count += 1 + { status: 200, body: @dcs_response, headers: { 'Content-Type' => 'application/json' } } + end + stub_request(:post, 'https://statsigapi.net/v1/get_id_lists') + .to_return(status: 200, body: '{}') + stub_request(:post, 'https://statsigapi.net/v1/log_event') + .to_return(status: 200) + + sleep(0.7) + + assert @cdn_call_count > cdn_after_init, + "Expected CDN to be called after bg sync failure, but cdn_call_count=#{@cdn_call_count}" + ensure + Statsig::SpecStore.send(:remove_const, :STATSIG_NETWORK_FALLBACK_THRESHOLD) + Statsig::SpecStore.const_set(:STATSIG_NETWORK_FALLBACK_THRESHOLD, original_threshold) + end + end +end \ No newline at end of file