From 0edeaad3033ca82bd74c7db186760553763c625b Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Thu, 5 Mar 2026 08:38:55 -0800 Subject: [PATCH 1/2] feat: add shutdown API, provider status on client, and status short-circuit (spec 1.6, 1.7) - Add API#shutdown that propagates shutdown to all registered providers and clears state (spec 1.6.1, 1.6.2) - Add Client#provider_status accessor returning provider's current state from ProviderStateRegistry (spec 1.7.1) - Add short-circuit in Client#fetch_details for NOT_READY and FATAL provider states, returning default values with appropriate error codes (spec 1.7.6, 1.7.7) - Make Configuration#provider_state and #provider_tracked? public - Add ProviderStateRegistry#tracked? to check if a provider is registered (prevents false NOT_READY for directly-constructed clients) Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jose Colella --- lib/open_feature/sdk/api.rb | 4 + lib/open_feature/sdk/client.rb | 21 +++++ lib/open_feature/sdk/configuration.rb | 24 +++++- .../sdk/provider_state_registry.rb | 8 ++ .../specification/flag_evaluation_api_spec.rb | 77 +++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/lib/open_feature/sdk/api.rb b/lib/open_feature/sdk/api.rb index f714ee09..f4a67134 100644 --- a/lib/open_feature/sdk/api.rb +++ b/lib/open_feature/sdk/api.rb @@ -68,6 +68,10 @@ def logger def logger=(new_logger) configuration.logger = new_logger end + + def shutdown + configuration.shutdown + end end end end diff --git a/lib/open_feature/sdk/client.rb b/lib/open_feature/sdk/client.rb index 4e76b244..2921f56e 100644 --- a/lib/open_feature/sdk/client.rb +++ b/lib/open_feature/sdk/client.rb @@ -28,6 +28,10 @@ def initialize(provider:, domain: nil, evaluation_context: nil) @hooks = [] end + def provider_status + OpenFeature::SDK.configuration.provider_state(@provider) + end + def add_handler(event_type, handler = nil, &block) actual_handler = handler || block OpenFeature::SDK.configuration.add_client_handler(self, event_type, actual_handler) @@ -54,6 +58,23 @@ def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil) validate_default_value_type(type, default_value) + state = provider_status + if OpenFeature::SDK.configuration.provider_tracked?(@provider) && state == ProviderState::NOT_READY + resolution = Provider::ResolutionDetails.new( + value: default_value, + error_code: Provider::ErrorCode::PROVIDER_NOT_READY, + reason: Provider::Reason::ERROR + ) + return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution) + elsif OpenFeature::SDK.configuration.provider_tracked?(@provider) && state == ProviderState::FATAL + resolution = Provider::ResolutionDetails.new( + value: default_value, + error_code: Provider::ErrorCode::PROVIDER_FATAL, + reason: Provider::Reason::ERROR + ) + return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution) + end + built_context = EvaluationContextBuilder.new.call( api_context: OpenFeature::SDK.evaluation_context, client_context: self.evaluation_context, diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 22610c78..10591459 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -76,6 +76,26 @@ def set_provider_and_wait(provider, domain: nil) set_provider_internal(provider, domain: domain, wait_for_init: true) end + def provider_state(provider) + @provider_state_registry.get_state(provider) + end + + def provider_tracked?(provider) + @provider_state_registry.tracked?(provider) + end + + def shutdown + providers_to_shutdown = @provider_mutex.synchronize { @providers.values.uniq } + + providers_to_shutdown.each do |prov| + prov.shutdown if prov.respond_to?(:shutdown) + rescue => e + @logger&.warn("Error shutting down provider #{prov&.class&.name || "unknown"}: #{e.message}") + end + + reset + end + private def reset @@ -171,10 +191,6 @@ def dispatch_provider_event(provider, event_type, details = {}) run_handlers_for_provider(provider, event_type, event_details) end - def provider_state(provider) - @provider_state_registry.get_state(provider) - end - private def extract_provider_name(provider) diff --git a/lib/open_feature/sdk/provider_state_registry.rb b/lib/open_feature/sdk/provider_state_registry.rb index 5ee20da5..c4b5940c 100644 --- a/lib/open_feature/sdk/provider_state_registry.rb +++ b/lib/open_feature/sdk/provider_state_registry.rb @@ -54,6 +54,14 @@ def remove_provider(provider) end end + def tracked?(provider) + return false unless provider + + @mutex.synchronize do + @states.key?(provider.object_id) + end + end + def ready?(provider) get_state(provider) == ProviderState::READY end diff --git a/spec/specification/flag_evaluation_api_spec.rb b/spec/specification/flag_evaluation_api_spec.rb index 7b86ca48..37b91180 100644 --- a/spec/specification/flag_evaluation_api_spec.rb +++ b/spec/specification/flag_evaluation_api_spec.rb @@ -222,6 +222,83 @@ end end + context "1.6 - Shutdown" do + context "Requirement 1.6.1" do + specify "The API MUST define a mechanism to propagate a shutdown request to registered providers." do + expect(OpenFeature::SDK).to respond_to(:shutdown) + end + end + + context "Requirement 1.6.2" do + specify "When a shutdown function is called, the API invokes the shutdown function on the registered provider." do + provider1 = OpenFeature::SDK::Provider::InMemoryProvider.new + provider2 = OpenFeature::SDK::Provider::InMemoryProvider.new + + OpenFeature::SDK.set_provider_and_wait(provider1) + OpenFeature::SDK.set_provider_and_wait(provider2, domain: "test-domain") + + expect(provider1).to receive(:shutdown) + expect(provider2).to receive(:shutdown) + + OpenFeature::SDK.shutdown + end + + specify "After shutdown, providers are cleared." do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + OpenFeature::SDK.set_provider_and_wait(provider) + + OpenFeature::SDK.shutdown + + expect(OpenFeature::SDK.provider).to be_nil + end + end + end + + context "1.7 - Provider Status" do + context "Requirement 1.7.1" do + specify "The client MUST define a provider status accessor which indicates the readiness of the associated provider." do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + OpenFeature::SDK.set_provider_and_wait(provider) + client = OpenFeature::SDK.build_client + + expect(client).to respond_to(:provider_status) + expect(client.provider_status).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + context "Requirement 1.7.6" do + specify "If the provider status is NOT_READY, the client should return the default value with PROVIDER_NOT_READY error." do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + OpenFeature::SDK.set_provider_and_wait(provider) + client = OpenFeature::SDK.build_client + + allow(OpenFeature::SDK.configuration).to receive(:provider_state).with(provider).and_return(OpenFeature::SDK::ProviderState::NOT_READY) + + result = client.fetch_boolean_details(flag_key: "flag", default_value: false) + + expect(result.value).to eq(false) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + end + end + + context "Requirement 1.7.7" do + specify "If the provider status is FATAL, the client should return the default value with PROVIDER_FATAL error." do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + OpenFeature::SDK.set_provider_and_wait(provider) + client = OpenFeature::SDK.build_client + + allow(OpenFeature::SDK.configuration).to receive(:provider_state).with(provider).and_return(OpenFeature::SDK::ProviderState::FATAL) + + result = client.fetch_string_details(flag_key: "flag", default_value: "default") + + expect(result.value).to eq("default") + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + end + end + end + context "Logger Methods" do specify "delegates logger getter to configuration" do logger = double("Logger") From 3d18c3f7c12f2501235365df582f4bc9bc0870c7 Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Thu, 5 Mar 2026 08:46:18 -0800 Subject: [PATCH 2/2] refactor: extract short_circuit_error_code to reduce duplication Replace copy-pasted NOT_READY/FATAL blocks with a single guard that uses a case statement helper method. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jose Colella --- lib/open_feature/sdk/client.rb | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/lib/open_feature/sdk/client.rb b/lib/open_feature/sdk/client.rb index 2921f56e..75c51777 100644 --- a/lib/open_feature/sdk/client.rb +++ b/lib/open_feature/sdk/client.rb @@ -58,21 +58,16 @@ def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil) validate_default_value_type(type, default_value) - state = provider_status - if OpenFeature::SDK.configuration.provider_tracked?(@provider) && state == ProviderState::NOT_READY - resolution = Provider::ResolutionDetails.new( - value: default_value, - error_code: Provider::ErrorCode::PROVIDER_NOT_READY, - reason: Provider::Reason::ERROR - ) - return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution) - elsif OpenFeature::SDK.configuration.provider_tracked?(@provider) && state == ProviderState::FATAL - resolution = Provider::ResolutionDetails.new( - value: default_value, - error_code: Provider::ErrorCode::PROVIDER_FATAL, - reason: Provider::Reason::ERROR - ) - return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution) + if OpenFeature::SDK.configuration.provider_tracked?(@provider) + error_code = short_circuit_error_code(provider_status) + if error_code + resolution = Provider::ResolutionDetails.new( + value: default_value, + error_code: error_code, + reason: Provider::Reason::ERROR + ) + return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution) + end end built_context = EvaluationContextBuilder.new.call( @@ -130,6 +125,13 @@ def evaluate_flag(type:, flag_key:, default_value:, evaluation_context:) EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details) end + def short_circuit_error_code(state) + case state + when ProviderState::NOT_READY then Provider::ErrorCode::PROVIDER_NOT_READY + when ProviderState::FATAL then Provider::ErrorCode::PROVIDER_FATAL + end + end + def validate_default_value_type(type, default_value) expected_classes = TYPE_CLASS_MAP[type] unless expected_classes.any? { |klass| default_value.is_a?(klass) }