Skip to content
Merged
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
80 changes: 61 additions & 19 deletions lib/configcat/configfetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@
end

class FetchResponse
attr_reader :entry, :error, :is_transient_error
attr_reader :entry, :error, :is_transient_error, :cf_ray_id

def initialize(status, entry, error = nil, is_transient_error = false)
def initialize(status, entry, error = nil, is_transient_error = false, cf_ray_id = nil)
@status = status
@entry = entry
@error = error
@is_transient_error = is_transient_error
@cf_ray_id = cf_ray_id
end

# Gets whether a new configuration value was fetched or not.
Expand All @@ -53,19 +54,22 @@
@status == Status::FAILURE
end

def self.success(entry)
FetchResponse.new(Status::FETCHED, entry)
def self.success(entry, cf_ray_id = nil)
FetchResponse.new(Status::FETCHED, entry, nil, false, cf_ray_id)
end

def self.not_modified
FetchResponse.new(Status::NOT_MODIFIED, ConfigEntry::EMPTY)
def self.not_modified(cf_ray_id = nil)
FetchResponse.new(Status::NOT_MODIFIED, ConfigEntry::EMPTY, nil, false, cf_ray_id)
end

def self.failure(error, is_transient_error)
FetchResponse.new(Status::FAILURE, ConfigEntry::EMPTY, error, is_transient_error)
def self.failure(error, is_transient_error, cf_ray_id = nil)
FetchResponse.new(Status::FAILURE, ConfigEntry::EMPTY, error, is_transient_error, cf_ray_id)
end
end

RETRY_DELAY_SECONDS = 0.05
CONNECTION_RESET_THRESHOLD_SECONDS = 30

class ConfigFetcher
def initialize(sdk_key, log, mode, base_url: nil, proxy_address: nil, proxy_port: nil, proxy_user: nil, proxy_pass: nil,
open_timeout: 10, read_timeout: 30,
Expand All @@ -79,6 +83,7 @@
@_open_timeout = open_timeout
@_read_timeout = read_timeout
@_headers = { "User-Agent" => ((("ConfigCat-Ruby/") + mode) + ("-")) + VERSION, "X-ConfigCat-UserAgent" => ((("ConfigCat-Ruby/") + mode) + ("-")) + VERSION, "Content-Type" => "application/json" }
@_last_connection_reset = nil
if !base_url.equal?(nil)
@_base_url_overridden = true
@_base_url = base_url.chomp("/")
Expand Down Expand Up @@ -146,7 +151,9 @@

# To prevent loops we check if we retried at least 3 times with the new base_url
if retries >= 2
@log.error(1104, "Redirection loop encountered while trying to fetch config JSON. Please contact us at https://configcat.com/support/")
error = "Redirection loop encountered while trying to fetch config JSON. Please contact us at https://configcat.com/support/"
error = "%s (Ray ID: %s)" % [error, fetch_response.cf_ray_id] if fetch_response.cf_ray_id

Check failure on line 155 in lib/configcat/configfetcher.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "%s (Ray ID: %s)" 5 times.

See more on https://sonarcloud.io/project/issues?id=configcat_ruby-sdk&issues=AaEf7ZNh6Tc9IA-t9ZYs&open=AaEf7ZNh6Tc9IA-t9ZYs&pullRequest=46
@log.error(1104, error)
return fetch_response
end

Expand All @@ -163,48 +170,83 @@
private

def _fetch(etag)
response = _fetch_http(etag)
if response.is_failed && response.is_transient_error
_reset_http_if_needed
sleep(RETRY_DELAY_SECONDS)
response = _fetch_http(etag)
end
response
end

def _reset_http_if_needed
now = Utils.get_utc_now_seconds_since_epoch
if @_last_connection_reset.nil? || now - @_last_connection_reset >= CONNECTION_RESET_THRESHOLD_SECONDS
close
@_last_connection_reset = now
end
end

def _fetch_http(etag)

Check failure on line 190 in lib/configcat/configfetcher.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=configcat_ruby-sdk&issues=AaD3s_00jnvlAIh4nBkv&open=AaD3s_00jnvlAIh4nBkv&pullRequest=46
response_cf_ray_id = nil
begin
@log.debug("Fetching configuration from ConfigCat")
uri = URI.parse((((@_base_url + ("/")) + BASE_PATH) + @_sdk_key) + BASE_EXTENSION)
headers = @_headers
headers["If-None-Match"] = etag.empty? ? nil : etag
_create_http()
request = Net::HTTP::Get.new(uri.request_uri, headers)
response = @_http.request(request)
response = @_http.request(request) do |res|
# Capture the response before reading the body, so headers remain accessible even if the body download fails
response_cf_ray_id = res["CF-RAY"]
res.read_body
end

case response
when Net::HTTPSuccess
when Net::HTTPOK
@log.debug("ConfigCat configuration json fetch response code:#{response.code} Cached:#{response['ETag']}")
content = response.body
if content.nil? || content.empty?
raise ArgumentError.new("Config JSON content cannot be null or empty.")
end
config = JSON.parse(content)
if config.nil?
raise ArgumentError.new("Invalid config JSON content: #{content}")
end
Config.fixup_config_salt_and_segments(config)
response_etag = response["ETag"]
if response_etag.nil?
response_etag = ""
end
config = JSON.parse(response.body)
Config.fixup_config_salt_and_segments(config)
return FetchResponse.success(ConfigEntry.new(config, response_etag, response.body, Utils.get_utc_now_seconds_since_epoch))
return FetchResponse.success(ConfigEntry.new(config, response_etag, response.body, Utils.get_utc_now_seconds_since_epoch), response_cf_ray_id)
when Net::HTTPNotModified
return FetchResponse.not_modified
return FetchResponse.not_modified(response_cf_ray_id)
when Net::HTTPNotFound, Net::HTTPForbidden
masked_sdk_key = ConfigCatLogger.mask_sdk_key(@_sdk_key)
error = "Your SDK Key seems to be wrong: '#{masked_sdk_key}'. You can find the valid SDK Key at https://app.configcat.com/sdkkey. Received unexpected response: #{response}"
error = "%s (Ray ID: %s)" % [error, response_cf_ray_id] if response_cf_ray_id
@log.error(1100, error)
return FetchResponse.failure(error, false)
return FetchResponse.failure(error, false, response_cf_ray_id)
else
raise Net::HTTPError.new("", response)
end
rescue Net::HTTPError => e
error = "Unexpected HTTP response was received while trying to fetch config JSON: #{e}"
error = "%s (Ray ID: %s)" % [error, response_cf_ray_id] if response_cf_ray_id
@log.error(1101, error)
return FetchResponse.failure(error, true)
return FetchResponse.failure(error, true, response_cf_ray_id)
rescue Timeout::Error => e
error = "Request timed out while trying to fetch config JSON. Timeout values: [connect: #{get_open_timeout()}s, read: #{get_read_timeout()}s]"
error = "%s (Ray ID: %s)" % [error, response_cf_ray_id] if response_cf_ray_id
@log.error(1102, error)
return FetchResponse.failure(error, true)
return FetchResponse.failure(error, true, response_cf_ray_id)
rescue Exception => e
error = "Unexpected error occurred while trying to fetch config JSON. It is most likely due to a local network " \
"issue. Please make sure your application can reach the ConfigCat CDN servers (or your proxy server) " \
"over HTTP. #{e}"
error = "%s (Ray ID: %s)" % [error, response_cf_ray_id] if response_cf_ray_id
@log.error(1103, error)
return FetchResponse.failure(error, true)
return FetchResponse.failure(error, true, response_cf_ray_id)
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/configcat/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module ConfigCat
VERSION = "9.0.2"
VERSION = "10.0.0"
end
132 changes: 132 additions & 0 deletions spec/configcat/configfetcher_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
require_relative 'mocks'

RSpec.describe ConfigCat::ConfigFetcher do
[
"",
"null"
].each do |body|
it "fetch_empty_#{body.empty? ? 'empty' : 'null'}" do
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
WebMock.stub_request(:get, uri_template)
.to_return(status: 200, body: body, headers: {})

log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("", log, "m")
fetch_response = fetcher.get_configuration()

expect(fetch_response.is_fetched()).to be false
expect(fetch_response.is_failed()).to be true
expect(fetch_response.error).to include("Unexpected error occurred while trying to fetch config JSON")
end
end

it "test_simple_fetch_success" do
test_json = '{"test": "json"}'
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
Expand Down Expand Up @@ -166,6 +185,119 @@
end
end

it "retry_on_transient_http_error" do
test_json = '{"f": {}}'
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
stub = WebMock.stub_request(:get, uri_template)
.to_return(status: 500, body: "", headers: {})
.then
.to_return(status: 200, body: test_json, headers: {})

log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("", log, "m")
fetch_response = fetcher.get_configuration()

expect(fetch_response.is_fetched()).to be true
expect(WebMock).to have_requested(:get, uri_template).twice
end

it "retry_on_timeout" do
test_json = '{"f": {}}'
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
WebMock.stub_request(:get, uri_template)
.to_raise(Timeout::Error.new("timed out"))
.then
.to_return(status: 200, body: test_json, headers: {})

log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("", log, "m")
fetch_response = fetcher.get_configuration()

expect(fetch_response.is_fetched()).to be true
expect(WebMock).to have_requested(:get, uri_template).twice
end

it "retry_on_unexpected_error" do
test_json = '{"f": {}}'
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
WebMock.stub_request(:get, uri_template)
.to_raise(SocketError.new("connection reset"))
.then
.to_return(status: 200, body: test_json, headers: {})

log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("", log, "m")
fetch_response = fetcher.get_configuration()

expect(fetch_response.is_fetched()).to be true
expect(WebMock).to have_requested(:get, uri_template).twice
end

it "retry_on_transient_http_error_both_fail" do
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
WebMock.stub_request(:get, uri_template)
.to_return(status: 500, body: "", headers: {})

log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("", log, "m")
fetch_response = fetcher.get_configuration()

expect(fetch_response.is_failed()).to be true
expect(fetch_response.is_transient_error).to be true
expect(WebMock).to have_requested(:get, uri_template).twice
end

it "evict_all_throttled_within_30_seconds" do
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
WebMock.stub_request(:get, uri_template)
.to_return(status: 500, body: "", headers: {})

log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("", log, "m")

# First failure: connection reset should happen (last_reset is nil)
fetcher.get_configuration()
first_reset_time = fetcher.instance_variable_get(:@_last_connection_reset)
expect(first_reset_time).not_to be_nil

WebMock.reset!
WebMock.stub_request(:get, uri_template)
.to_return(status: 500, body: "", headers: {})

# Second failure within 30s: reset should NOT update the timestamp
fetcher.get_configuration()
second_reset_time = fetcher.instance_variable_get(:@_last_connection_reset)
expect(second_reset_time).to eq first_reset_time
end

it "no_retry_on_403" do
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
WebMock.stub_request(:get, uri_template)
.to_return(status: 403, body: "", headers: {})

log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("", log, "m")
fetch_response = fetcher.get_configuration()

expect(fetch_response.is_failed()).to be true
expect(fetch_response.is_transient_error).to be false
expect(WebMock).to have_requested(:get, uri_template).once
end

it "no_retry_on_404" do
uri_template = Addressable::Template.new "https://{base_url}/{base_path}/{api_key}/{base_ext}"
WebMock.stub_request(:get, uri_template)
.to_return(status: 404, body: "", headers: {})

log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("", log, "m")
fetch_response = fetcher.get_configuration()

expect(fetch_response.is_failed()).to be true
expect(fetch_response.is_transient_error).to be false
expect(WebMock).to have_requested(:get, uri_template).once
end

it "test_server_side_etag" do
log = ConfigCatLogger.new(Hooks.new)
fetcher = ConfigCat::ConfigFetcher.new("PKDVCLf-Hq-h-kCzMp-L7Q/HhOWfwVtZ0mb30i9wi17GQ",
Expand Down
Loading