Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions lib/network.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
112 changes: 72 additions & 40 deletions lib/spec_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion lib/statsig_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def assert_nothing_raised(*)
StatsigErrorBoundaryUsageTest
TestStore
TestSymbolHashes
TestSyncConfigFallback
TestURIHelper
UserFieldsTest
UserTest
Expand Down
126 changes: 126 additions & 0 deletions test/test_sync_config_fallback.rb
Original file line number Diff line number Diff line change
@@ -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