From 91cac6fe56ddbeff65379c7ebc66e18cfa794074 Mon Sep 17 00:00:00 2001 From: Aleksandras Maliuginas Date: Mon, 12 Jan 2026 13:58:54 +0200 Subject: [PATCH 1/6] fix: Make goff unix socket client thread safe Signed-off-by: Aleksandras Maliuginas --- .../Gemfile.lock | 2 +- .../go-feature-flag/client/unix_api.rb | 15 ++++++-- .../openfeature/go-feature-flag/version.rb | 2 +- .../gofeatureflag/client/unix_api_spec.rb | 38 ++++++++++--------- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/providers/openfeature-go-feature-flag-provider/Gemfile.lock b/providers/openfeature-go-feature-flag-provider/Gemfile.lock index c72016a..28c1a64 100644 --- a/providers/openfeature-go-feature-flag-provider/Gemfile.lock +++ b/providers/openfeature-go-feature-flag-provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-go-feature-flag-provider (0.1.7) + openfeature-go-feature-flag-provider (0.1.8) faraday-net_http_persistent (~> 2.3) openfeature-sdk (~> 0.3.1) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb index b2c9d31..b950152 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb @@ -7,11 +7,11 @@ module OpenFeature module GoFeatureFlag module Client class UnixApi < Common - attr_accessor :socket - def initialize(endpoint: nil, custom_headers: nil) + def initialize(endpoint: nil, custom_headers: nil, unix_socket_client_factory: nil) @custom_headers = custom_headers - @socket = HttpUnix.new(endpoint) + @endpoint = endpoint + @unix_socket_client_factory = unix_socket_client_factory || ->(ep) { HttpUnix.new(ep) } end def evaluate_ofrep_api(flag_key:, evaluation_context:) @@ -21,7 +21,7 @@ def evaluate_ofrep_api(flag_key:, evaluation_context:) evaluation_context.fields["targetingKey"] = evaluation_context.targeting_key evaluation_context.fields.delete("targeting_key") - response = @socket.post("/ofrep/v1/evaluate/flags/#{flag_key}", {context: evaluation_context.fields}, headers) + response = thread_local_socket.post("/ofrep/v1/evaluate/flags/#{flag_key}", {context: evaluation_context.fields}, headers) case response.code when "200" @@ -39,6 +39,13 @@ def evaluate_ofrep_api(flag_key:, evaluation_context:) raise OpenFeature::GoFeatureFlag::InternalServerError.new(response) end end + + private + + def thread_local_socket + key = :"openfeature_goff_unix_socket_#{object_id}" + Thread.current[key] ||= @unix_socket_client_factory.call(@endpoint) + end end end end diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb index 785cc6b..bab4f6f 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb @@ -1,5 +1,5 @@ module OpenFeature module GoFeatureFlag - GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.7" + GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.8" end end diff --git a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/unix_api_spec.rb b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/unix_api_spec.rb index 80fec13..9d1f3c4 100644 --- a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/unix_api_spec.rb +++ b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/unix_api_spec.rb @@ -2,8 +2,10 @@ RSpec.describe OpenFeature::GoFeatureFlag::Client::UnixApi do subject(:unix_api) do - described_class.new(endpoint: "/tmp/http.sock") + described_class.new(endpoint: "/tmp/http.sock", unix_socket_client_factory: unix_socket_client_factory) end + let(:unix_socket_client) { instance_double(HttpUnix) } + let(:unix_socket_client_factory) { ->(_endpoint) { unix_socket_client } } let(:default_evaluation_context) do OpenFeature::SDK::EvaluationContext.new( @@ -20,7 +22,7 @@ it "should raise an error if rate limited" do allow(response).to receive(:code).and_return("429") allow(response).to receive(:[]).with("Retry-After").and_return(nil) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -29,7 +31,7 @@ it "should raise an error if not authorized (401)" do allow(response).to receive(:code).and_return("401") - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -38,7 +40,7 @@ it "should raise an error if not authorized (403)" do allow(response).to receive(:code).and_return("403") - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -47,7 +49,7 @@ it "should raise an error if flag not found (404)" do allow(response).to receive(:code).and_return("404") - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "does-not-exists", evaluation_context: default_evaluation_context) @@ -56,7 +58,7 @@ it "should raise an error if unknown http code (500)" do allow(response).to receive(:code).and_return("500") - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -71,7 +73,7 @@ } allow(response).to receive(:code).and_return("400") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) got = unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) want = OpenFeature::GoFeatureFlag::OfrepApiResponse.new( @@ -96,7 +98,7 @@ } allow(response).to receive(:code).and_return("200") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) got = unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) want = OpenFeature::GoFeatureFlag::OfrepApiResponse.new( @@ -120,7 +122,7 @@ } allow(response).to receive(:code).and_return("200") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -136,7 +138,7 @@ } allow(response).to receive(:code).and_return("200") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -152,7 +154,7 @@ } allow(response).to receive(:code).and_return("200") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -168,7 +170,7 @@ } allow(response).to receive(:code).and_return("200") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -181,7 +183,7 @@ } allow(response).to receive(:code).and_return("400") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -192,7 +194,7 @@ body = {key: "double_key"} allow(response).to receive(:code).and_return("400") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -202,7 +204,7 @@ it "should not be able to call the API again if rate-limited (with retry-after int)" do allow(response).to receive(:code).and_return("429") allow(response).to receive(:[]).with("Retry-After").and_return("10") - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -224,7 +226,7 @@ allow(response).to receive(:code).and_return("429", "200") allow(response).to receive(:[]).with("Retry-After").and_return("1") allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -240,7 +242,7 @@ it "should not be able to call the API again if rate-limited (with retry-after date)" do allow(response).to receive(:code).and_return("429") allow(response).to receive(:[]).with("Retry-After").and_return((Time.now + 1).httpdate) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) @@ -262,7 +264,7 @@ allow(response).to receive(:code).and_return("429", "200") allow(response).to receive(:[]).with("Retry-After").and_return((Time.now + 1).httpdate) allow(response).to receive(:body).and_return(body.to_json) - allow(unix_api.socket).to receive(:post).and_return(response) + allow(unix_socket_client).to receive(:post).and_return(response) expect { unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) From e6b20361179638bde6c534aa4130d991c96c3044 Mon Sep 17 00:00:00 2001 From: Aleksandras Maliuginas Date: Mon, 12 Jan 2026 14:41:09 +0200 Subject: [PATCH 2/6] fix: Add mutex on retries to avoid race conditions when setting retry after time Signed-off-by: Aleksandras Maliuginas --- .../go-feature-flag/client/common.rb | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb index 62b2490..1ad89af 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb @@ -25,7 +25,9 @@ def headers end def check_retry_after - unless @retry_after.nil? + lock = (@retry_lock ||= Mutex.new) + lock.synchronize do + return if @retry_after.nil? if Time.now < @retry_after raise OpenFeature::GoFeatureFlag::RateLimited.new(nil) else @@ -109,12 +111,18 @@ def parse_retry_later_header(response) return nil if retry_after.nil? begin - @retry_after = if /^\d+$/.match?(retry_after) - # Retry-After is in seconds - Time.now + Integer(retry_after) - else - # Retry-After is an HTTP-date - Time.httpdate(retry_after) + next_retry_time = + if /^\d+$/.match?(retry_after) + # Retry-After is in seconds + Time.now + Integer(retry_after) + else + # Retry-After is an HTTP-date + Time.httpdate(retry_after) + end + # Protect updates and never shorten an existing backoff window + lock = (@retry_lock ||= Mutex.new) + lock.synchronize do + @retry_after = [@retry_after, next_retry_time].compact.max end rescue ArgumentError # ignore invalid Retry-After header From 053fe2e8c5afe58365cddca3093e0e54ae43a0e3 Mon Sep 17 00:00:00 2001 From: Aleksandras Maliuginas Date: Mon, 12 Jan 2026 14:41:37 +0200 Subject: [PATCH 3/6] Use string for thread value key instead of symbol Signed-off-by: Aleksandras Maliuginas --- .../lib/openfeature/go-feature-flag/client/unix_api.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb index b950152..f322f2f 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb @@ -43,7 +43,7 @@ def evaluate_ofrep_api(flag_key:, evaluation_context:) private def thread_local_socket - key = :"openfeature_goff_unix_socket_#{object_id}" + key = "openfeature_goff_unix_socket_#{object_id}" Thread.current[key] ||= @unix_socket_client_factory.call(@endpoint) end end From d59e68947316fc8f3c3cfd5fa10b0d28eef0c9b9 Mon Sep 17 00:00:00 2001 From: Aleksandras Maliuginas Date: Mon, 12 Jan 2026 14:43:43 +0200 Subject: [PATCH 4/6] Extract evaluation request builder to common.rb Signed-off-by: Aleksandras Maliuginas --- .../lib/openfeature/go-feature-flag/client/common.rb | 10 ++++++++++ .../lib/openfeature/go-feature-flag/client/http_api.rb | 7 ++----- .../lib/openfeature/go-feature-flag/client/unix_api.rb | 7 ++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb index 1ad89af..c7cfc40 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb @@ -24,6 +24,16 @@ def headers {"Content-Type" => "application/json"}.merge(@custom_headers || {}) end + def evaluation_request(evaluation_context) + ctx = evaluation_context || OpenFeature::SDK::EvaluationContext.new + fields = ctx.fields.dup + # replace targeting_key by targetingKey without mutating original fields + fields["targetingKey"] = ctx.targeting_key + fields.delete("targeting_key") + + {context: fields} + end + def check_retry_after lock = (@retry_lock ||= Mutex.new) lock.synchronize do diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/http_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/http_api.rb index ef85cbb..dceaed8 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/http_api.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/http_api.rb @@ -19,13 +19,10 @@ def initialize(endpoint: nil, custom_headers: nil, instrumentation: nil) def evaluate_ofrep_api(flag_key:, evaluation_context:) check_retry_after - evaluation_context = OpenFeature::SDK::EvaluationContext.new if evaluation_context.nil? - # replace targeting_key by targetingKey - evaluation_context.fields["targetingKey"] = evaluation_context.targeting_key - evaluation_context.fields.delete("targeting_key") + request = evaluation_request(evaluation_context) response = @faraday_connection.post("/ofrep/v1/evaluate/flags/#{flag_key}") do |req| - req.body = {context: evaluation_context.fields}.to_json + req.body = request.to_json end case response.status diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb index f322f2f..66fff04 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb @@ -16,12 +16,9 @@ def initialize(endpoint: nil, custom_headers: nil, unix_socket_client_factory: n def evaluate_ofrep_api(flag_key:, evaluation_context:) check_retry_after - evaluation_context = OpenFeature::SDK::EvaluationContext.new if evaluation_context.nil? - # replace targeting_key by targetingKey - evaluation_context.fields["targetingKey"] = evaluation_context.targeting_key - evaluation_context.fields.delete("targeting_key") - response = thread_local_socket.post("/ofrep/v1/evaluate/flags/#{flag_key}", {context: evaluation_context.fields}, headers) + request = evaluation_request(evaluation_context) + response = thread_local_socket.post("/ofrep/v1/evaluate/flags/#{flag_key}", request, headers) case response.code when "200" From 79dd9ae9516e8e688aa198dfd37491eda93812e7 Mon Sep 17 00:00:00 2001 From: Aleksandras Maliuginas Date: Mon, 12 Jan 2026 14:59:40 +0200 Subject: [PATCH 5/6] Undo version bump Signed-off-by: Aleksandras Maliuginas --- providers/openfeature-go-feature-flag-provider/Gemfile.lock | 2 +- .../lib/openfeature/go-feature-flag/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/openfeature-go-feature-flag-provider/Gemfile.lock b/providers/openfeature-go-feature-flag-provider/Gemfile.lock index 28c1a64..c72016a 100644 --- a/providers/openfeature-go-feature-flag-provider/Gemfile.lock +++ b/providers/openfeature-go-feature-flag-provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-go-feature-flag-provider (0.1.8) + openfeature-go-feature-flag-provider (0.1.7) faraday-net_http_persistent (~> 2.3) openfeature-sdk (~> 0.3.1) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb index bab4f6f..785cc6b 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb @@ -1,5 +1,5 @@ module OpenFeature module GoFeatureFlag - GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.8" + GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.7" end end From 91d52b8227c1a0fb9976e6057c0b5535e57b1791 Mon Sep 17 00:00:00 2001 From: Aleksandras Maliuginas Date: Tue, 13 Jan 2026 10:00:01 +0200 Subject: [PATCH 6/6] lint: remove empty line Signed-off-by: Aleksandras Maliuginas --- .../lib/openfeature/go-feature-flag/client/unix_api.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb index 66fff04..169f837 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb @@ -7,7 +7,6 @@ module OpenFeature module GoFeatureFlag module Client class UnixApi < Common - def initialize(endpoint: nil, custom_headers: nil, unix_socket_client_factory: nil) @custom_headers = custom_headers @endpoint = endpoint