From 9b0052d18013e5877483611d93b9c429bb5122fa Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Tue, 9 Dec 2025 08:24:23 -0800 Subject: [PATCH 01/36] feat: add event system foundation for provider lifecycle - Add ProviderEvent module with event type constants (PROVIDER_READY, etc.) - Add ProviderState module with state constants (NOT_READY, READY, ERROR, etc.) - Implement EventEmitter class for thread-safe pub-sub event handling - Add EventToStateMapper for mapping events to provider states - Include comprehensive test coverage for all components This introduces the event infrastructure needed for OpenFeature spec-compliant provider lifecycle management. The implementation is additive only, maintaining full backward compatibility with existing provider behavior. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/event_emitter.rb | 106 +++++++++ lib/open_feature/sdk/event_to_state_mapper.rb | 89 ++++++++ lib/open_feature/sdk/provider.rb | 6 + lib/open_feature/sdk/provider_event.rb | 34 +++ lib/open_feature/sdk/provider_state.rb | 37 +++ spec/open_feature/sdk/event_emitter_spec.rb | 215 ++++++++++++++++++ .../sdk/event_to_state_mapper_spec.rb | 185 +++++++++++++++ spec/open_feature/sdk/provider_event_spec.rb | 35 +++ 8 files changed, 707 insertions(+) create mode 100644 lib/open_feature/sdk/event_emitter.rb create mode 100644 lib/open_feature/sdk/event_to_state_mapper.rb create mode 100644 lib/open_feature/sdk/provider_event.rb create mode 100644 lib/open_feature/sdk/provider_state.rb create mode 100644 spec/open_feature/sdk/event_emitter_spec.rb create mode 100644 spec/open_feature/sdk/event_to_state_mapper_spec.rb create mode 100644 spec/open_feature/sdk/provider_event_spec.rb diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb new file mode 100644 index 00000000..a0ddd6d3 --- /dev/null +++ b/lib/open_feature/sdk/event_emitter.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require_relative 'provider_event' + +module OpenFeature + module SDK + # Event Emitter for Provider Lifecycle Events + # + # Implements a pub-sub model for provider events, based on the Go SDK's + # event executor pattern from event_executor.go + class EventEmitter + def initialize + @handlers = {} + @mutex = Mutex.new + ProviderEvent::ALL_EVENTS.each { |event| @handlers[event] = [] } + end + + # Add a handler for a specific event type + # Based on Go SDK AddHandler pattern (event_executor.go lines 69-81) + # + # @param event_type [String] the event type to listen for + # @param handler [Proc] the handler to call when event is triggered + def add_handler(event_type, handler) + raise ArgumentError, "Invalid event type: #{event_type}" unless valid_event?(event_type) + raise ArgumentError, "Handler must respond to call" unless handler.respond_to?(:call) + + @mutex.synchronize do + @handlers[event_type] << handler + end + end + + # Remove a specific handler for an event type + # Based on Go SDK RemoveHandler pattern (event_executor.go lines 84-97) + # + # @param event_type [String] the event type + # @param handler [Proc] the specific handler to remove + def remove_handler(event_type, handler) + return unless valid_event?(event_type) + + @mutex.synchronize do + @handlers[event_type].delete(handler) + end + end + + # Remove all handlers for an event type + # + # @param event_type [String] the event type + def remove_all_handlers(event_type) + return unless valid_event?(event_type) + + @mutex.synchronize do + @handlers[event_type].clear + end + end + + # Trigger an event with event details + # Based on Go SDK triggerEvent pattern + # + # @param event_type [String] the event type to trigger + # @param event_details [Hash] details about the event + def trigger_event(event_type, event_details = {}) + return unless valid_event?(event_type) + + handlers_to_call = nil + @mutex.synchronize do + handlers_to_call = @handlers[event_type].dup + end + + # Call handlers outside of mutex to avoid deadlocks + handlers_to_call.each do |handler| + begin + handler.call(event_details) + rescue => e + # Log error but don't let one handler failure stop others + warn "Event handler failed for #{event_type}: #{e.message}" + end + end + end + + # Get count of handlers for an event type (for testing) + # + # @param event_type [String] the event type + # @return [Integer] number of handlers registered + def handler_count(event_type) + return 0 unless valid_event?(event_type) + + @mutex.synchronize do + @handlers[event_type].size + end + end + + # Clear all handlers (for testing/cleanup) + def clear_all_handlers + @mutex.synchronize do + @handlers.each_value(&:clear) + end + end + + private + + def valid_event?(event_type) + ProviderEvent::ALL_EVENTS.include?(event_type) + end + end + end +end diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb new file mode 100644 index 00000000..5232a557 --- /dev/null +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require_relative 'provider_event' +require_relative 'provider_state' + +module OpenFeature + module SDK + # Maps provider events to provider states + # + # Based on Go SDK statesMap pattern from openfeature_api.go lines 283-294 + class EventToStateMapper + # Event details structure for error events + class EventDetails + attr_reader :message, :error_code + + def initialize(message: nil, error_code: nil) + @message = message + @error_code = error_code + end + end + + # Mapping from event types to states + # Matches Go SDK statesMap logic + STATE_MAPPING = { + ProviderEvent::PROVIDER_READY => ProviderState::READY, + ProviderEvent::PROVIDER_CONFIGURATION_CHANGED => ProviderState::READY, + ProviderEvent::PROVIDER_STALE => ProviderState::STALE, + ProviderEvent::PROVIDER_ERROR => lambda do |event_details| + # Check if it's a fatal error (matches Go SDK logic) + if event_details&.error_code == 'PROVIDER_FATAL' + ProviderState::FATAL + else + ProviderState::ERROR + end + end + }.freeze + + # Map an event type to a provider state + # + # @param event_type [String] the event type + # @param event_details [EventDetails, Hash, nil] optional event details + # @return [String] the corresponding provider state + def self.state_from_event(event_type, event_details = nil) + mapper = STATE_MAPPING[event_type] + + if mapper.respond_to?(:call) + # Convert Hash to EventDetails if needed + details = case event_details + when EventDetails + event_details + when Hash + EventDetails.new( + message: event_details[:message] || event_details['message'], + error_code: event_details[:error_code] || event_details['error_code'] + ) + else + nil + end + mapper.call(details) + else + mapper || ProviderState::NOT_READY # default fallback + end + end + + # Map an error to a provider state (for direct error handling) + # + # @param error [Exception] the error that occurred + # @return [String] the corresponding provider state + def self.state_from_error(error) + # Check if it's a fatal error based on error class or message + if fatal_error?(error) + ProviderState::FATAL + else + ProviderState::ERROR + end + end + + private + + # Determine if an error is fatal (matches Go SDK logic) + def self.fatal_error?(error) + # You can customize this logic based on your error types + error.is_a?(SystemExit) || + error.message&.include?('PROVIDER_FATAL') || + error.message&.include?('fatal') + end + end + end +end diff --git a/lib/open_feature/sdk/provider.rb b/lib/open_feature/sdk/provider.rb index f6b27cdd..ee3b2d63 100644 --- a/lib/open_feature/sdk/provider.rb +++ b/lib/open_feature/sdk/provider.rb @@ -5,6 +5,12 @@ require_relative "provider/no_op_provider" require_relative "provider/in_memory_provider" +# Event system components +require_relative "provider_event" +require_relative "provider_state" +require_relative "event_emitter" +require_relative "event_to_state_mapper" + module OpenFeature module SDK module Provider diff --git a/lib/open_feature/sdk/provider_event.rb b/lib/open_feature/sdk/provider_event.rb new file mode 100644 index 00000000..4a39d1f4 --- /dev/null +++ b/lib/open_feature/sdk/provider_event.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module OpenFeature + module SDK + # Provider Event Types + # + # Defines the standard event types that providers can emit during their lifecycle. + # These events correspond to the OpenFeature specification events: + # https://openfeature.dev/specification/sections/events/ + # + # Based on Go SDK provider.go lines 33-36 + module ProviderEvent + # Emitted when provider initialization completes successfully + PROVIDER_READY = 'PROVIDER_READY' + + # Emitted when provider initialization fails + PROVIDER_ERROR = 'PROVIDER_ERROR' + + # Emitted when provider configuration changes + PROVIDER_CONFIGURATION_CHANGED = 'PROVIDER_CONFIGURATION_CHANGED' + + # Emitted when provider enters a stale state + PROVIDER_STALE = 'PROVIDER_STALE' + + # All supported event types for validation + ALL_EVENTS = [ + PROVIDER_READY, + PROVIDER_ERROR, + PROVIDER_CONFIGURATION_CHANGED, + PROVIDER_STALE + ].freeze + end + end +end diff --git a/lib/open_feature/sdk/provider_state.rb b/lib/open_feature/sdk/provider_state.rb new file mode 100644 index 00000000..ecca8348 --- /dev/null +++ b/lib/open_feature/sdk/provider_state.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module OpenFeature + module SDK + # Provider State Types + # + # Defines the standard states that providers can be in during their lifecycle. + # These states correspond to the OpenFeature specification provider states. + # + # Based on Go SDK provider.go lines 27-31 + module ProviderState + # Provider is not ready to serve flag evaluations + NOT_READY = 'NOT_READY' + + # Provider is ready to serve flag evaluations + READY = 'READY' + + # Provider encountered an error but may recover + ERROR = 'ERROR' + + # Provider data is stale and should be refreshed + STALE = 'STALE' + + # Provider encountered a fatal error and cannot recover + FATAL = 'FATAL' + + # All supported provider states for validation + ALL_STATES = [ + NOT_READY, + READY, + ERROR, + STALE, + FATAL + ].freeze + end + end +end diff --git a/spec/open_feature/sdk/event_emitter_spec.rb b/spec/open_feature/sdk/event_emitter_spec.rb new file mode 100644 index 00000000..23b65eeb --- /dev/null +++ b/spec/open_feature/sdk/event_emitter_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/event_emitter' +require 'open_feature/sdk/provider_event' + +RSpec.describe OpenFeature::SDK::EventEmitter do + subject(:event_emitter) { described_class.new } + + describe '#initialize' do + it 'initializes with empty handlers for all event types' do + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + expect(event_emitter.handler_count(event_type)).to eq(0) + end + end + end + + describe '#add_handler' do + let(:handler) { ->(event_details) { puts "Event received: #{event_details}" } } + + it 'adds a handler for a valid event type' do + expect do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(0).to(1) + end + + it 'raises error for invalid event type' do + expect do + event_emitter.add_handler('INVALID_EVENT', handler) + end.to raise_error(ArgumentError, /Invalid event type/) + end + + it 'raises error for non-callable handler' do + expect do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, 'not callable') + end.to raise_error(ArgumentError, /Handler must respond to call/) + end + + it 'allows multiple handlers for the same event type' do + handler2 = ->(event_details) { puts "Handler 2" } + + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + + expect(event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)).to eq(2) + end + end + + describe '#remove_handler' do + let(:handler1) { ->(event_details) { puts "Handler 1" } } + let(:handler2) { ->(event_details) { puts "Handler 2" } } + + before do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + end + + it 'removes a specific handler' do + expect do + event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(2).to(1) + end + + it 'does nothing for invalid event type' do + expect do + event_emitter.remove_handler('INVALID_EVENT', handler1) + end.not_to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) } + end + + it 'does nothing if handler is not registered' do + unregistered_handler = ->(event_details) { puts "Unregistered" } + + expect do + event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, unregistered_handler) + end.not_to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) } + end + end + + describe '#remove_all_handlers' do + let(:handler1) { ->(event_details) { puts "Handler 1" } } + let(:handler2) { ->(event_details) { puts "Handler 2" } } + + before do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + end + + it 'removes all handlers for an event type' do + expect do + event_emitter.remove_all_handlers(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(2).to(0) + end + end + + describe '#trigger_event' do + let(:received_events) { [] } + let(:handler) { ->(event_details) { received_events << event_details } } + + before do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + + it 'triggers handlers with event details' do + event_details = { provider: 'test-provider', message: 'Ready' } + + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) + + expect(received_events).to contain_exactly(event_details) + end + + it 'triggers handlers with empty event details if none provided' do + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + + expect(received_events).to contain_exactly({}) + end + + it 'triggers multiple handlers for the same event' do + received_events2 = [] + handler2 = ->(event_details) { received_events2 << event_details } + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + + event_details = { test: 'data' } + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) + + expect(received_events).to contain_exactly(event_details) + expect(received_events2).to contain_exactly(event_details) + end + + it 'does nothing for invalid event type' do + event_emitter.trigger_event('INVALID_EVENT', { test: 'data' }) + + expect(received_events).to be_empty + end + + it 'continues executing other handlers even if one fails' do + failing_handler = ->(event_details) { raise 'Handler failed' } + received_events2 = [] + working_handler = ->(event_details) { received_events2 << event_details } + + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, working_handler) + + event_details = { test: 'data' } + + # Should not raise error and should still call working handlers + expect { event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) }.not_to raise_error + expect(received_events).to contain_exactly(event_details) + expect(received_events2).to contain_exactly(event_details) + end + end + + describe '#clear_all_handlers' do + before do + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + event_emitter.add_handler(event_type, ->(event_details) { puts "Handler for #{event_type}" }) + end + end + + it 'clears all handlers for all event types' do + event_emitter.clear_all_handlers + + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + expect(event_emitter.handler_count(event_type)).to eq(0) + end + end + end + + describe 'thread safety' do + let(:handler) { ->(event_details) { sleep(0.001) } } # Small delay to increase chance of race conditions + + it 'handles concurrent add/remove operations safely' do + threads = [] + + # Concurrent additions + 10.times do + threads << Thread.new do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + # Concurrent removals + 5.times do + threads << Thread.new do + event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + threads.each(&:join) + + # Should not crash and should have some handlers remaining + expect(event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)).to be >= 0 + end + + it 'handles concurrent triggering safely' do + received_count = 0 + counter_mutex = Mutex.new + counting_handler = lambda do |event_details| + counter_mutex.synchronize { received_count += 1 } + end + + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, counting_handler) + + threads = [] + 10.times do + threads << Thread.new do + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, { test: 'concurrent' }) + end + end + + threads.each(&:join) + + expect(received_count).to eq(10) + end + end +end \ No newline at end of file diff --git a/spec/open_feature/sdk/event_to_state_mapper_spec.rb b/spec/open_feature/sdk/event_to_state_mapper_spec.rb new file mode 100644 index 00000000..72a63d37 --- /dev/null +++ b/spec/open_feature/sdk/event_to_state_mapper_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/event_to_state_mapper' +require 'open_feature/sdk/provider_event' +require 'open_feature/sdk/provider_state' + +RSpec.describe OpenFeature::SDK::EventToStateMapper do + describe '.state_from_event' do + context 'with PROVIDER_READY event' do + it 'returns READY state' do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + expect(state).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + context 'with PROVIDER_CONFIGURATION_CHANGED event' do + it 'returns READY state' do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) + expect(state).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + context 'with PROVIDER_STALE event' do + it 'returns STALE state' do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE) + expect(state).to eq(OpenFeature::SDK::ProviderState::STALE) + end + end + + context 'with PROVIDER_ERROR event' do + it 'returns ERROR state by default' do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) + expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + + it 'returns ERROR state for non-fatal error' do + event_details = described_class::EventDetails.new( + message: 'Connection failed', + error_code: 'CONNECTION_ERROR' + ) + + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) + expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + + it 'returns FATAL state for fatal error' do + event_details = described_class::EventDetails.new( + message: 'Provider cannot recover', + error_code: 'PROVIDER_FATAL' + ) + + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) + expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it 'handles Hash event details' do + event_details_hash = { + message: 'Provider cannot recover', + error_code: 'PROVIDER_FATAL' + } + + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details_hash) + expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it 'handles Hash with string keys' do + event_details_hash = { + 'message' => 'Provider cannot recover', + 'error_code' => 'PROVIDER_FATAL' + } + + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details_hash) + expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it 'handles nil event details gracefully' do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, nil) + expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + end + + context 'with unknown event type' do + it 'returns NOT_READY state as fallback' do + state = described_class.state_from_event('UNKNOWN_EVENT') + expect(state).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + end + end + + describe '.state_from_error' do + it 'returns FATAL state for SystemExit error' do + error = SystemExit.new + state = described_class.state_from_error(error) + expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it 'returns FATAL state for error with PROVIDER_FATAL in message' do + error = StandardError.new('Something went wrong: PROVIDER_FATAL') + state = described_class.state_from_error(error) + expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it 'returns FATAL state for error with "fatal" in message' do + error = StandardError.new('This is a fatal error') + state = described_class.state_from_error(error) + expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it 'returns ERROR state for regular errors' do + error = StandardError.new('Regular error') + state = described_class.state_from_error(error) + expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + + it 'returns ERROR state for error with nil message' do + error = StandardError.new + allow(error).to receive(:message).and_return(nil) + state = described_class.state_from_error(error) + expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + end + + describe 'EventDetails' do + describe '#initialize' do + it 'initializes with message and error_code' do + details = described_class::EventDetails.new( + message: 'Test message', + error_code: 'TEST_ERROR' + ) + + expect(details.message).to eq('Test message') + expect(details.error_code).to eq('TEST_ERROR') + end + + it 'initializes with nil values when not provided' do + details = described_class::EventDetails.new + + expect(details.message).to be_nil + expect(details.error_code).to be_nil + end + end + end + + describe 'STATE_MAPPING constant' do + it 'is frozen to prevent modification' do + expect(described_class::STATE_MAPPING).to be_frozen + end + + it 'contains mappings for all provider events' do + expected_events = [ + OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, + OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, + OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR + ] + + expect(described_class::STATE_MAPPING.keys).to contain_exactly(*expected_events) + end + end + + describe 'integration with ProviderEvent and ProviderState constants' do + it 'uses valid provider events' do + described_class::STATE_MAPPING.keys.each do |event_type| + expect(OpenFeature::SDK::ProviderEvent::ALL_EVENTS).to include(event_type) + end + end + + it 'maps to valid provider states' do + # Test non-callable mappings + non_callable_mappings = described_class::STATE_MAPPING.reject { |k, v| v.respond_to?(:call) } + non_callable_mappings.values.each do |state| + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(state) + end + + # Test callable mappings (PROVIDER_ERROR) + error_mapper = described_class::STATE_MAPPING[OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR] + fatal_state = error_mapper.call(described_class::EventDetails.new(error_code: 'PROVIDER_FATAL')) + error_state = error_mapper.call(described_class::EventDetails.new(error_code: 'SOME_ERROR')) + + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(fatal_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(error_state) + end + end +end \ No newline at end of file diff --git a/spec/open_feature/sdk/provider_event_spec.rb b/spec/open_feature/sdk/provider_event_spec.rb new file mode 100644 index 00000000..d1c19682 --- /dev/null +++ b/spec/open_feature/sdk/provider_event_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/provider_event' + +RSpec.describe OpenFeature::SDK::ProviderEvent do + it 'defines PROVIDER_READY constant' do + expect(described_class::PROVIDER_READY).to eq('PROVIDER_READY') + end + + it 'defines PROVIDER_ERROR constant' do + expect(described_class::PROVIDER_ERROR).to eq('PROVIDER_ERROR') + end + + it 'defines PROVIDER_CONFIGURATION_CHANGED constant' do + expect(described_class::PROVIDER_CONFIGURATION_CHANGED).to eq('PROVIDER_CONFIGURATION_CHANGED') + end + + it 'defines PROVIDER_STALE constant' do + expect(described_class::PROVIDER_STALE).to eq('PROVIDER_STALE') + end + + it 'defines ALL_EVENTS with all event types' do + expect(described_class::ALL_EVENTS).to contain_exactly( + 'PROVIDER_READY', + 'PROVIDER_ERROR', + 'PROVIDER_CONFIGURATION_CHANGED', + 'PROVIDER_STALE' + ) + end + + it 'has frozen ALL_EVENTS array' do + expect(described_class::ALL_EVENTS).to be_frozen + end +end \ No newline at end of file From 147a9c3657318636b68bed8309f6ab0000ed1753 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Wed, 10 Dec 2025 13:30:08 -0800 Subject: [PATCH 02/36] feat: add provider lifecycle interfaces and state management Add mixins and classes to support provider lifecycle management: - StateHandler: Basic provider lifecycle interface with init/shutdown - EventHandler: Event emission capability for providers - ContextAwareStateHandler: Timeout support for initialization - ProviderStateRegistry: Thread-safe provider state tracking - EventAwareNoOpProvider: Example implementation demonstrating interfaces These interfaces enable providers to emit lifecycle events and support both synchronous and asynchronous initialization patterns while maintaining backward compatibility with existing providers. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/event_emitter.rb | 6 +- lib/open_feature/sdk/event_to_state_mapper.rb | 6 +- lib/open_feature/sdk/provider.rb | 9 + .../provider/context_aware_state_handler.rb | 75 +++++++ .../provider/event_aware_no_op_provider.rb | 41 ++++ .../sdk/provider/event_handler.rb | 66 +++++++ .../sdk/provider/state_handler.rb | 43 ++++ lib/open_feature/sdk/provider_event.rb | 1 - lib/open_feature/sdk/provider_state.rb | 1 - .../sdk/provider_state_registry.rb | 90 +++++++++ .../context_aware_state_handler_spec.rb | 139 +++++++++++++ .../sdk/provider/event_handler_spec.rb | 140 +++++++++++++ .../sdk/provider/state_handler_spec.rb | 100 ++++++++++ .../provider_backward_compatibility_spec.rb | 118 +++++++++++ .../sdk/provider_state_registry_spec.rb | 185 ++++++++++++++++++ 15 files changed, 1009 insertions(+), 11 deletions(-) create mode 100644 lib/open_feature/sdk/provider/context_aware_state_handler.rb create mode 100644 lib/open_feature/sdk/provider/event_aware_no_op_provider.rb create mode 100644 lib/open_feature/sdk/provider/event_handler.rb create mode 100644 lib/open_feature/sdk/provider/state_handler.rb create mode 100644 lib/open_feature/sdk/provider_state_registry.rb create mode 100644 spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb create mode 100644 spec/open_feature/sdk/provider/event_handler_spec.rb create mode 100644 spec/open_feature/sdk/provider/state_handler_spec.rb create mode 100644 spec/open_feature/sdk/provider_backward_compatibility_spec.rb create mode 100644 spec/open_feature/sdk/provider_state_registry_spec.rb diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb index a0ddd6d3..5ac2c6cf 100644 --- a/lib/open_feature/sdk/event_emitter.rb +++ b/lib/open_feature/sdk/event_emitter.rb @@ -6,8 +6,7 @@ module OpenFeature module SDK # Event Emitter for Provider Lifecycle Events # - # Implements a pub-sub model for provider events, based on the Go SDK's - # event executor pattern from event_executor.go + # Implements a pub-sub model for provider events class EventEmitter def initialize @handlers = {} @@ -16,7 +15,6 @@ def initialize end # Add a handler for a specific event type - # Based on Go SDK AddHandler pattern (event_executor.go lines 69-81) # # @param event_type [String] the event type to listen for # @param handler [Proc] the handler to call when event is triggered @@ -30,7 +28,6 @@ def add_handler(event_type, handler) end # Remove a specific handler for an event type - # Based on Go SDK RemoveHandler pattern (event_executor.go lines 84-97) # # @param event_type [String] the event type # @param handler [Proc] the specific handler to remove @@ -54,7 +51,6 @@ def remove_all_handlers(event_type) end # Trigger an event with event details - # Based on Go SDK triggerEvent pattern # # @param event_type [String] the event type to trigger # @param event_details [Hash] details about the event diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb index 5232a557..27788b86 100644 --- a/lib/open_feature/sdk/event_to_state_mapper.rb +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -7,7 +7,6 @@ module OpenFeature module SDK # Maps provider events to provider states # - # Based on Go SDK statesMap pattern from openfeature_api.go lines 283-294 class EventToStateMapper # Event details structure for error events class EventDetails @@ -20,13 +19,12 @@ def initialize(message: nil, error_code: nil) end # Mapping from event types to states - # Matches Go SDK statesMap logic STATE_MAPPING = { ProviderEvent::PROVIDER_READY => ProviderState::READY, ProviderEvent::PROVIDER_CONFIGURATION_CHANGED => ProviderState::READY, ProviderEvent::PROVIDER_STALE => ProviderState::STALE, ProviderEvent::PROVIDER_ERROR => lambda do |event_details| - # Check if it's a fatal error (matches Go SDK logic) + # Check if it's a fatal error if event_details&.error_code == 'PROVIDER_FATAL' ProviderState::FATAL else @@ -77,7 +75,7 @@ def self.state_from_error(error) private - # Determine if an error is fatal (matches Go SDK logic) + # Determine if an error is fatal def self.fatal_error?(error) # You can customize this logic based on your error types error.is_a?(SystemExit) || diff --git a/lib/open_feature/sdk/provider.rb b/lib/open_feature/sdk/provider.rb index ee3b2d63..b5e5d2f2 100644 --- a/lib/open_feature/sdk/provider.rb +++ b/lib/open_feature/sdk/provider.rb @@ -2,14 +2,23 @@ require_relative "provider/reason" require_relative "provider/resolution_details" require_relative "provider/provider_metadata" + +# Provider interfaces +require_relative "provider/state_handler" +require_relative "provider/event_handler" +require_relative "provider/context_aware_state_handler" + +# Provider implementations require_relative "provider/no_op_provider" require_relative "provider/in_memory_provider" +require_relative "provider/event_aware_no_op_provider" # Event system components require_relative "provider_event" require_relative "provider_state" require_relative "event_emitter" require_relative "event_to_state_mapper" +require_relative "provider_state_registry" module OpenFeature module SDK diff --git a/lib/open_feature/sdk/provider/context_aware_state_handler.rb b/lib/open_feature/sdk/provider/context_aware_state_handler.rb new file mode 100644 index 00000000..fcad48f3 --- /dev/null +++ b/lib/open_feature/sdk/provider/context_aware_state_handler.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'timeout' +require_relative 'state_handler' + +module OpenFeature + module SDK + module Provider + # ContextAwareStateHandler extends StateHandler with timeout support + # for providers that need bounded initialization and shutdown times. + # + # Adapted for Ruby's timeout patterns. + # + # Use this interface when your provider needs to: + # - Respect initialization/shutdown timeouts (e.g., network calls, database connections) + # - Support graceful cancellation during setup and teardown + # + # Best practices: + # - Use reasonable timeout values (typically 5-30 seconds) + # - Handle Timeout::Error gracefully + # - Maintain backward compatibility by implementing both init methods + # + # Example: + # class MyProvider + # include OpenFeature::SDK::Provider::ContextAwareStateHandler + # + # def init_with_timeout(evaluation_context, timeout: 30) + # Timeout.timeout(timeout) do + # connect_to_remote_service + # end + # rescue Timeout::Error => e + # raise ProviderInitializationError, "Connection timeout after #{timeout}s" + # end + # + # def shutdown_with_timeout(timeout: 10) + # Timeout.timeout(timeout) do + # disconnect_from_service + # end + # rescue Timeout::Error + # # Force close if graceful shutdown times out + # force_disconnect + # end + # end + module ContextAwareStateHandler + include StateHandler + + # Initialize the provider with timeout support. + # + # @param evaluation_context [EvaluationContext] the context for initialization + # @param timeout [Numeric] maximum seconds to wait for initialization + # @raise [Timeout::Error] if initialization exceeds timeout + # @raise [StandardError] if initialization fails + def init_with_timeout(evaluation_context, timeout: 30) + # Default implementation delegates to regular init + # Providers can override to add timeout handling + Timeout.timeout(timeout) do + init(evaluation_context) + end + end + + # Shutdown the provider with timeout support. + # + # @param timeout [Numeric] maximum seconds to wait for shutdown + # @raise [Timeout::Error] if shutdown exceeds timeout + def shutdown_with_timeout(timeout: 10) + # Default implementation delegates to regular shutdown + # Providers can override to add timeout handling + Timeout.timeout(timeout) do + shutdown + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb b/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb new file mode 100644 index 00000000..04939b33 --- /dev/null +++ b/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative 'no_op_provider' +require_relative 'state_handler' +require_relative 'event_handler' + +module OpenFeature + module SDK + module Provider + # EventAwareNoOpProvider extends NoOpProvider with event support. + # This demonstrates how providers can implement the new interfaces + # while maintaining backward compatibility. + # + # This provider: + # - Implements StateHandler for initialization/shutdown + # - Implements EventHandler for event emission + # - Emits PROVIDER_READY immediately on init + # - Returns default values like NoOpProvider + class EventAwareNoOpProvider < NoOpProvider + include StateHandler + include EventHandler + + def init(evaluation_context) + # NoOp provider initializes instantly + # In a real provider, this might connect to a service + emit_event(ProviderEvent::PROVIDER_READY, message: "NoOp provider initialized") + rescue => e + emit_event(ProviderEvent::PROVIDER_ERROR, + message: "Failed to initialize: #{e.message}", + error_code: 'INITIALIZATION_ERROR') + raise + end + + def shutdown + # NoOp provider has nothing to cleanup + # In a real provider, this might close connections + end + end + end + end +end \ No newline at end of file diff --git a/lib/open_feature/sdk/provider/event_handler.rb b/lib/open_feature/sdk/provider/event_handler.rb new file mode 100644 index 00000000..a9de827c --- /dev/null +++ b/lib/open_feature/sdk/provider/event_handler.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative '../provider_event' + +module OpenFeature + module SDK + module Provider + # EventHandler allows providers to emit lifecycle events. + # FeatureProviders can opt in for this behavior by including this module. + # + # Adapted for Ruby's callback pattern. + # + # Example: + # class MyProvider + # include OpenFeature::SDK::Provider::EventHandler + # + # def init(evaluation_context) + # Thread.new do + # connect_to_service + # emit_event(ProviderEvent::PROVIDER_READY) + # end + # end + # end + module EventHandler + # Attach this provider to an event dispatcher. + # Called by the SDK when the provider is registered. + # + # @param event_dispatcher [Object] the dispatcher that will handle events + def attach(event_dispatcher) + @event_dispatcher = event_dispatcher + end + + # Detach this provider from the event dispatcher. + # Called by the SDK when the provider is being replaced. + def detach + @event_dispatcher = nil + end + + # Emit an event to the attached dispatcher. + # + # @param event_type [String] one of the ProviderEvent constants + # @param details [Hash] optional event details including :message, :error_code + def emit_event(event_type, details = {}) + return unless @event_dispatcher + + # Ensure we have a valid event type + unless ProviderEvent::ALL_EVENTS.include?(event_type) + raise ArgumentError, "Invalid event type: #{event_type}" + end + + # Add provider reference to details + event_details = details.merge(provider: self) + + @event_dispatcher.dispatch_event(self, event_type, event_details) + end + + # Check if this provider has an attached event dispatcher + # + # @return [Boolean] true if events can be emitted + def event_dispatcher_attached? + !@event_dispatcher.nil? + end + end + end + end +end \ No newline at end of file diff --git a/lib/open_feature/sdk/provider/state_handler.rb b/lib/open_feature/sdk/provider/state_handler.rb new file mode 100644 index 00000000..faa2625c --- /dev/null +++ b/lib/open_feature/sdk/provider/state_handler.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module OpenFeature + module SDK + module Provider + # StateHandler is the contract for initialization & shutdown. + # FeatureProviders can opt in for this behavior by including this module + # and implementing the methods. + # + # + # Example: + # class MyProvider + # include OpenFeature::SDK::Provider::StateHandler + # + # def init(evaluation_context) + # # Initialize provider resources + # connect_to_service + # end + # + # def shutdown + # # Cleanup provider resources + # disconnect_from_service + # end + # end + module StateHandler + # Initialize the provider with the given evaluation context. + # This method is called when the provider is set. + # + # @param evaluation_context [EvaluationContext] the context for initialization + # @raise [StandardError] if initialization fails + def init(evaluation_context) + # Default implementation - override in provider + end + + # Shutdown the provider and cleanup resources. + # This method is called when the provider is being replaced or shutdown. + def shutdown + # Default implementation - override in provider + end + end + end + end +end \ No newline at end of file diff --git a/lib/open_feature/sdk/provider_event.rb b/lib/open_feature/sdk/provider_event.rb index 4a39d1f4..6fd2defd 100644 --- a/lib/open_feature/sdk/provider_event.rb +++ b/lib/open_feature/sdk/provider_event.rb @@ -8,7 +8,6 @@ module SDK # These events correspond to the OpenFeature specification events: # https://openfeature.dev/specification/sections/events/ # - # Based on Go SDK provider.go lines 33-36 module ProviderEvent # Emitted when provider initialization completes successfully PROVIDER_READY = 'PROVIDER_READY' diff --git a/lib/open_feature/sdk/provider_state.rb b/lib/open_feature/sdk/provider_state.rb index ecca8348..3c9af550 100644 --- a/lib/open_feature/sdk/provider_state.rb +++ b/lib/open_feature/sdk/provider_state.rb @@ -7,7 +7,6 @@ module SDK # Defines the standard states that providers can be in during their lifecycle. # These states correspond to the OpenFeature specification provider states. # - # Based on Go SDK provider.go lines 27-31 module ProviderState # Provider is not ready to serve flag evaluations NOT_READY = 'NOT_READY' diff --git a/lib/open_feature/sdk/provider_state_registry.rb b/lib/open_feature/sdk/provider_state_registry.rb new file mode 100644 index 00000000..b1108cbc --- /dev/null +++ b/lib/open_feature/sdk/provider_state_registry.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require_relative 'provider_state' +require_relative 'provider_event' +require_relative 'event_to_state_mapper' + +module OpenFeature + module SDK + # ProviderStateRegistry tracks the state of registered providers. + # + # The registry maintains: + # - Current state for each provider + # - Thread-safe state transitions + # - State queries for providers + class ProviderStateRegistry + def initialize + @states = {} + @mutex = Mutex.new + end + + # Set initial state for a provider + # + # @param provider [Object] the provider instance + # @param state [String] the initial state (default: NOT_READY) + def set_initial_state(provider, state = ProviderState::NOT_READY) + @mutex.synchronize do + @states[provider.object_id] = state + end + end + + # Update provider state based on an event + # + # @param provider [Object] the provider instance + # @param event_type [String] the event that occurred + # @param event_details [Hash] optional event details + def update_state_from_event(provider, event_type, event_details = nil) + new_state = EventToStateMapper.state_from_event(event_type, event_details) + + @mutex.synchronize do + @states[provider.object_id] = new_state + end + + new_state + end + + # Get the current state of a provider + # + # @param provider [Object] the provider instance + # @return [String] the current state or NOT_READY if not tracked + def get_state(provider) + @mutex.synchronize do + @states[provider.object_id] || ProviderState::NOT_READY + end + end + + # Remove a provider from state tracking + # + # @param provider [Object] the provider instance + def remove_provider(provider) + @mutex.synchronize do + @states.delete(provider.object_id) + end + end + + # Check if a provider is ready + # + # @param provider [Object] the provider instance + # @return [Boolean] true if provider is in READY state + def ready?(provider) + get_state(provider) == ProviderState::READY + end + + # Check if a provider is in an error state + # + # @param provider [Object] the provider instance + # @return [Boolean] true if provider is in ERROR or FATAL state + def error?(provider) + state = get_state(provider) + state == ProviderState::ERROR || state == ProviderState::FATAL + end + + # Clear all provider states + def clear + @mutex.synchronize do + @states.clear + end + end + end + end +end \ No newline at end of file diff --git a/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb b/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb new file mode 100644 index 00000000..95233422 --- /dev/null +++ b/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/provider/context_aware_state_handler' + +RSpec.describe OpenFeature::SDK::Provider::ContextAwareStateHandler do + let(:test_class) do + Class.new do + include OpenFeature::SDK::Provider::ContextAwareStateHandler + + attr_reader :init_called, :shutdown_called + + def init(evaluation_context) + @init_called = true + sleep(0.1) # Simulate some work + end + + def shutdown + @shutdown_called = true + sleep(0.1) # Simulate some work + end + end + end + + let(:provider) { test_class.new } + + describe 'interface methods' do + it 'includes StateHandler methods' do + expect(provider).to respond_to(:init).with(1).argument + expect(provider).to respond_to(:shutdown).with(0).arguments + end + + it 'responds to init_with_timeout' do + expect(provider).to respond_to(:init_with_timeout).with_keywords(:timeout) + end + + it 'responds to shutdown_with_timeout' do + expect(provider).to respond_to(:shutdown_with_timeout).with_keywords(:timeout) + end + end + + describe '#init_with_timeout' do + it 'delegates to init by default' do + provider.init_with_timeout({}, timeout: 1) + expect(provider.init_called).to be true + end + + it 'respects timeout' do + slow_provider = Class.new do + include OpenFeature::SDK::Provider::ContextAwareStateHandler + + def init(evaluation_context) + sleep(1) # Sleep longer than timeout + end + end.new + + expect do + slow_provider.init_with_timeout({}, timeout: 0.1) + end.to raise_error(Timeout::Error) + end + + it 'uses default timeout of 30 seconds' do + # Just verify it accepts the call without timeout specified + expect { provider.init_with_timeout({}) }.not_to raise_error + end + end + + describe '#shutdown_with_timeout' do + it 'delegates to shutdown by default' do + provider.shutdown_with_timeout(timeout: 1) + expect(provider.shutdown_called).to be true + end + + it 'respects timeout' do + slow_provider = Class.new do + include OpenFeature::SDK::Provider::ContextAwareStateHandler + + def shutdown + sleep(1) # Sleep longer than timeout + end + end.new + + expect do + slow_provider.shutdown_with_timeout(timeout: 0.1) + end.to raise_error(Timeout::Error) + end + + it 'uses default timeout of 10 seconds' do + # Just verify it accepts the call without timeout specified + expect { provider.shutdown_with_timeout }.not_to raise_error + end + end + + describe 'custom implementation' do + let(:custom_class) do + Class.new do + include OpenFeature::SDK::Provider::ContextAwareStateHandler + + attr_reader :init_timeout_used, :shutdown_timeout_used + + def init_with_timeout(evaluation_context, timeout: 30) + @init_timeout_used = timeout + Timeout.timeout(timeout) do + # Custom initialization with timeout awareness + connect_with_retries(timeout) + end + end + + def shutdown_with_timeout(timeout: 10) + @shutdown_timeout_used = timeout + Timeout.timeout(timeout) do + # Custom shutdown with timeout awareness + graceful_disconnect(timeout) + end + end + + private + + def connect_with_retries(timeout) + # Simulate connection logic + end + + def graceful_disconnect(timeout) + # Simulate disconnection logic + end + end + end + + let(:custom_provider) { custom_class.new } + + it 'allows providers to override timeout methods' do + custom_provider.init_with_timeout({}, timeout: 5) + expect(custom_provider.init_timeout_used).to eq(5) + + custom_provider.shutdown_with_timeout(timeout: 3) + expect(custom_provider.shutdown_timeout_used).to eq(3) + end + end +end \ No newline at end of file diff --git a/spec/open_feature/sdk/provider/event_handler_spec.rb b/spec/open_feature/sdk/provider/event_handler_spec.rb new file mode 100644 index 00000000..2b2716e9 --- /dev/null +++ b/spec/open_feature/sdk/provider/event_handler_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/provider/event_handler' +require 'open_feature/sdk/provider_event' + +RSpec.describe OpenFeature::SDK::Provider::EventHandler do + let(:test_class) do + Class.new do + include OpenFeature::SDK::Provider::EventHandler + + def name + "TestProvider" + end + end + end + + let(:provider) { test_class.new } + let(:event_dispatcher) { double('EventDispatcher') } + + describe 'interface methods' do + it 'responds to attach' do + expect(provider).to respond_to(:attach).with(1).argument + end + + it 'responds to detach' do + expect(provider).to respond_to(:detach).with(0).arguments + end + + it 'responds to emit_event' do + expect(provider).to respond_to(:emit_event).with(1..2).arguments + end + + it 'responds to event_dispatcher_attached?' do + expect(provider).to respond_to(:event_dispatcher_attached?).with(0).arguments + end + end + + describe '#attach' do + it 'attaches an event dispatcher' do + provider.attach(event_dispatcher) + expect(provider.event_dispatcher_attached?).to be true + end + end + + describe '#detach' do + it 'detaches the event dispatcher' do + provider.attach(event_dispatcher) + provider.detach + expect(provider.event_dispatcher_attached?).to be false + end + end + + describe '#emit_event' do + before do + provider.attach(event_dispatcher) + end + + it 'dispatches events through the attached dispatcher' do + expect(event_dispatcher).to receive(:dispatch_event).with( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + hash_including(provider: provider) + ) + + provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + + it 'includes custom details in dispatched event' do + custom_details = { message: 'Provider is ready', custom_field: 'value' } + + expect(event_dispatcher).to receive(:dispatch_event).with( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + hash_including(provider: provider, message: 'Provider is ready', custom_field: 'value') + ) + + provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, custom_details) + end + + it 'does nothing when no dispatcher is attached' do + provider.detach + + expect { provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.not_to raise_error + end + + it 'raises error for invalid event type' do + expect do + provider.emit_event('INVALID_EVENT') + end.to raise_error(ArgumentError, /Invalid event type/) + end + + it 'works with all valid event types' do + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + expect(event_dispatcher).to receive(:dispatch_event).with( + provider, + event_type, + hash_including(provider: provider) + ) + end + + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + provider.emit_event(event_type) + end + end + end + + describe '#event_dispatcher_attached?' do + it 'returns false when no dispatcher attached' do + expect(provider.event_dispatcher_attached?).to be false + end + + it 'returns true when dispatcher attached' do + provider.attach(event_dispatcher) + expect(provider.event_dispatcher_attached?).to be true + end + + it 'returns false after detaching' do + provider.attach(event_dispatcher) + provider.detach + expect(provider.event_dispatcher_attached?).to be false + end + end + + describe 'thread safety' do + it 'handles concurrent attach/detach operations' do + threads = [] + + 5.times do + threads << Thread.new { provider.attach(event_dispatcher) } + threads << Thread.new { provider.detach } + end + + threads.each(&:join) + + # Should not crash and should be in a valid state + expect([true, false]).to include(provider.event_dispatcher_attached?) + end + end +end \ No newline at end of file diff --git a/spec/open_feature/sdk/provider/state_handler_spec.rb b/spec/open_feature/sdk/provider/state_handler_spec.rb new file mode 100644 index 00000000..9225da63 --- /dev/null +++ b/spec/open_feature/sdk/provider/state_handler_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/provider/state_handler' + +RSpec.describe OpenFeature::SDK::Provider::StateHandler do + let(:test_class) do + Class.new do + include OpenFeature::SDK::Provider::StateHandler + + attr_reader :initialized, :shutdown_called + + def init(evaluation_context) + @initialized = true + @init_context = evaluation_context + end + + def shutdown + @shutdown_called = true + end + + def init_context + @init_context + end + end + end + + let(:provider) { test_class.new } + + describe 'interface methods' do + it 'responds to init' do + expect(provider).to respond_to(:init).with(1).argument + end + + it 'responds to shutdown' do + expect(provider).to respond_to(:shutdown).with(0).arguments + end + end + + describe '#init' do + it 'can be called with evaluation context' do + context = { user_id: '123' } + provider.init(context) + + expect(provider.initialized).to be true + expect(provider.init_context).to eq(context) + end + + it 'has a default implementation that does nothing' do + minimal_class = Class.new do + include OpenFeature::SDK::Provider::StateHandler + end + + minimal_provider = minimal_class.new + expect { minimal_provider.init({}) }.not_to raise_error + end + end + + describe '#shutdown' do + it 'can be called' do + provider.shutdown + expect(provider.shutdown_called).to be true + end + + it 'has a default implementation that does nothing' do + minimal_class = Class.new do + include OpenFeature::SDK::Provider::StateHandler + end + + minimal_provider = minimal_class.new + expect { minimal_provider.shutdown }.not_to raise_error + end + end + + describe 'error handling' do + let(:error_provider_class) do + Class.new do + include OpenFeature::SDK::Provider::StateHandler + + def init(evaluation_context) + raise StandardError, "Initialization failed" + end + + def shutdown + raise StandardError, "Shutdown failed" + end + end + end + + let(:error_provider) { error_provider_class.new } + + it 'propagates init errors' do + expect { error_provider.init({}) }.to raise_error(StandardError, "Initialization failed") + end + + it 'propagates shutdown errors' do + expect { error_provider.shutdown }.to raise_error(StandardError, "Shutdown failed") + end + end +end \ No newline at end of file diff --git a/spec/open_feature/sdk/provider_backward_compatibility_spec.rb b/spec/open_feature/sdk/provider_backward_compatibility_spec.rb new file mode 100644 index 00000000..809bfdb7 --- /dev/null +++ b/spec/open_feature/sdk/provider_backward_compatibility_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/provider/no_op_provider' +require 'open_feature/sdk/provider/in_memory_provider' +require 'open_feature/sdk/provider/event_aware_no_op_provider' + +RSpec.describe 'Provider Backward Compatibility' do + describe 'Existing NoOpProvider' do + let(:provider) { OpenFeature::SDK::Provider::NoOpProvider.new } + + it 'continues to work without implementing new interfaces' do + expect { provider.fetch_boolean_value(flag_key: 'test', default_value: true) }.not_to raise_error + end + + it 'does not respond to new interface methods' do + expect(provider).not_to respond_to(:attach) + expect(provider).not_to respond_to(:detach) + expect(provider).not_to respond_to(:emit_event) + end + + it 'does not respond to init or shutdown' do + expect(provider).not_to respond_to(:init) + expect(provider).not_to respond_to(:shutdown) + end + end + + describe 'Existing InMemoryProvider' do + let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } + + it 'continues to work with existing init/shutdown methods' do + expect { provider.init }.not_to raise_error + expect { provider.shutdown }.not_to raise_error + end + + it 'does not automatically gain event capabilities' do + expect(provider).not_to respond_to(:attach) + expect(provider).not_to respond_to(:emit_event) + end + + it 'fetch methods continue to work' do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new( + 'test-flag' => true + ) + + result = provider.fetch_boolean_value(flag_key: 'test-flag', default_value: false) + expect(result.value).to be true + end + end + + describe 'EventAwareNoOpProvider' do + let(:provider) { OpenFeature::SDK::Provider::EventAwareNoOpProvider.new } + + it 'inherits NoOpProvider functionality' do + result = provider.fetch_boolean_value(flag_key: 'test', default_value: true) + expect(result.value).to be true + expect(result.reason).to eq('No-op') + end + + it 'adds StateHandler capabilities' do + expect(provider).to respond_to(:init) + expect(provider).to respond_to(:shutdown) + end + + it 'adds EventHandler capabilities' do + expect(provider).to respond_to(:attach) + expect(provider).to respond_to(:detach) + expect(provider).to respond_to(:emit_event) + end + + it 'emits events when initialized' do + dispatcher = double('dispatcher') + provider.attach(dispatcher) + + expect(dispatcher).to receive(:dispatch_event).with( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + hash_including(message: 'NoOp provider initialized') + ) + + provider.init({}) + end + end + + describe 'Mixed provider usage' do + it 'can use old and new providers together' do + old_provider = OpenFeature::SDK::Provider::NoOpProvider.new + new_provider = OpenFeature::SDK::Provider::EventAwareNoOpProvider.new + + # Both should work for fetching values + old_result = old_provider.fetch_string_value(flag_key: 'test', default_value: 'old') + new_result = new_provider.fetch_string_value(flag_key: 'test', default_value: 'new') + + expect(old_result.value).to eq('old') + expect(new_result.value).to eq('new') + end + end + + describe 'Provider interface detection' do + it 'can check if provider implements StateHandler' do + old_provider = OpenFeature::SDK::Provider::NoOpProvider.new + new_provider = OpenFeature::SDK::Provider::EventAwareNoOpProvider.new + + # Check using respond_to? (Ruby way) + expect(old_provider.respond_to?(:init)).to be false + expect(new_provider.respond_to?(:init)).to be true + end + + it 'can check if provider implements EventHandler' do + old_provider = OpenFeature::SDK::Provider::NoOpProvider.new + new_provider = OpenFeature::SDK::Provider::EventAwareNoOpProvider.new + + # Check using is_a? with module + expect(old_provider.class.included_modules).not_to include(OpenFeature::SDK::Provider::EventHandler) + expect(new_provider.class.included_modules).to include(OpenFeature::SDK::Provider::EventHandler) + end + end +end \ No newline at end of file diff --git a/spec/open_feature/sdk/provider_state_registry_spec.rb b/spec/open_feature/sdk/provider_state_registry_spec.rb new file mode 100644 index 00000000..d8858b52 --- /dev/null +++ b/spec/open_feature/sdk/provider_state_registry_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/provider_state_registry' +require 'open_feature/sdk/provider_state' +require 'open_feature/sdk/provider_event' + +RSpec.describe OpenFeature::SDK::ProviderStateRegistry do + let(:registry) { described_class.new } + let(:provider) { double('Provider', object_id: 12345) } + let(:provider2) { double('Provider2', object_id: 67890) } + + describe '#set_initial_state' do + it 'sets NOT_READY as default state' do + registry.set_initial_state(provider) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it 'sets custom initial state' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + describe '#update_state_from_event' do + before do + registry.set_initial_state(provider) + end + + it 'updates state to READY on PROVIDER_READY event' do + new_state = registry.update_state_from_event( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY + ) + + expect(new_state).to eq(OpenFeature::SDK::ProviderState::READY) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + + it 'updates state to ERROR on PROVIDER_ERROR event' do + new_state = registry.update_state_from_event( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR + ) + + expect(new_state).to eq(OpenFeature::SDK::ProviderState::ERROR) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + + it 'updates state to FATAL on PROVIDER_ERROR with fatal error code' do + new_state = registry.update_state_from_event( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + { error_code: 'PROVIDER_FATAL' } + ) + + expect(new_state).to eq(OpenFeature::SDK::ProviderState::FATAL) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it 'updates state to STALE on PROVIDER_STALE event' do + new_state = registry.update_state_from_event( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_STALE + ) + + expect(new_state).to eq(OpenFeature::SDK::ProviderState::STALE) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::STALE) + end + end + + describe '#get_state' do + it 'returns NOT_READY for untracked provider' do + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it 'returns the current state for tracked provider' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + describe '#remove_provider' do + it 'removes provider from tracking' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + registry.remove_provider(provider) + + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + end + + describe '#ready?' do + it 'returns true when provider is READY' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + expect(registry.ready?(provider)).to be true + end + + it 'returns false when provider is not READY' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::ERROR) + expect(registry.ready?(provider)).to be false + end + + it 'returns false for untracked provider' do + expect(registry.ready?(provider)).to be false + end + end + + describe '#error?' do + it 'returns true when provider is in ERROR state' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::ERROR) + expect(registry.error?(provider)).to be true + end + + it 'returns true when provider is in FATAL state' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::FATAL) + expect(registry.error?(provider)).to be true + end + + it 'returns false when provider is READY' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + expect(registry.error?(provider)).to be false + end + + it 'returns false for untracked provider' do + expect(registry.error?(provider)).to be false + end + end + + describe '#clear' do + it 'removes all provider states' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + registry.set_initial_state(provider2, OpenFeature::SDK::ProviderState::ERROR) + + registry.clear + + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + expect(registry.get_state(provider2)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + end + + describe 'thread safety' do + it 'handles concurrent state updates' do + threads = [] + + # Start provider as NOT_READY + registry.set_initial_state(provider) + + # Concurrent updates + 10.times do |i| + threads << Thread.new do + if i.even? + registry.update_state_from_event(provider, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + else + registry.update_state_from_event(provider, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) + end + end + end + + threads.each(&:join) + + # Should be in one of the valid states + final_state = registry.get_state(provider) + expect([ + OpenFeature::SDK::ProviderState::READY, + OpenFeature::SDK::ProviderState::ERROR + ]).to include(final_state) + end + end + + describe 'multiple providers' do + it 'tracks states independently' do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + registry.set_initial_state(provider2, OpenFeature::SDK::ProviderState::ERROR) + + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + expect(registry.get_state(provider2)).to eq(OpenFeature::SDK::ProviderState::ERROR) + + expect(registry.ready?(provider)).to be true + expect(registry.ready?(provider2)).to be false + + expect(registry.error?(provider)).to be false + expect(registry.error?(provider2)).to be true + end + end +end \ No newline at end of file From 2833a4aa4ff3443d32dd3ec8b020cbb136e843c6 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Wed, 10 Dec 2025 14:11:40 -0800 Subject: [PATCH 03/36] fix: correct test expectation for init_with_timeout method signature - Fix context_aware_state_handler_spec to expect both positional and keyword arguments - Add newlines to end of provider files for consistency - Add arm64-darwin-24 platform support to Gemfile.lock Signed-off-by: Sameeran Kunche --- Gemfile.lock | 1 + lib/open_feature/sdk/provider/context_aware_state_handler.rb | 4 +--- lib/open_feature/sdk/provider/event_handler.rb | 4 +--- .../sdk/provider/context_aware_state_handler_spec.rb | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6e748210..d1b41eaf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,6 +95,7 @@ PLATFORMS arm64-darwin-21 arm64-darwin-22 arm64-darwin-23 + arm64-darwin-24 x64-mingw-ucrt x64-mingw32 x86_64-darwin-19 diff --git a/lib/open_feature/sdk/provider/context_aware_state_handler.rb b/lib/open_feature/sdk/provider/context_aware_state_handler.rb index fcad48f3..deb365eb 100644 --- a/lib/open_feature/sdk/provider/context_aware_state_handler.rb +++ b/lib/open_feature/sdk/provider/context_aware_state_handler.rb @@ -9,8 +9,6 @@ module Provider # ContextAwareStateHandler extends StateHandler with timeout support # for providers that need bounded initialization and shutdown times. # - # Adapted for Ruby's timeout patterns. - # # Use this interface when your provider needs to: # - Respect initialization/shutdown timeouts (e.g., network calls, database connections) # - Support graceful cancellation during setup and teardown @@ -72,4 +70,4 @@ def shutdown_with_timeout(timeout: 10) end end end -end \ No newline at end of file +end diff --git a/lib/open_feature/sdk/provider/event_handler.rb b/lib/open_feature/sdk/provider/event_handler.rb index a9de827c..24d3fb4a 100644 --- a/lib/open_feature/sdk/provider/event_handler.rb +++ b/lib/open_feature/sdk/provider/event_handler.rb @@ -8,8 +8,6 @@ module Provider # EventHandler allows providers to emit lifecycle events. # FeatureProviders can opt in for this behavior by including this module. # - # Adapted for Ruby's callback pattern. - # # Example: # class MyProvider # include OpenFeature::SDK::Provider::EventHandler @@ -63,4 +61,4 @@ def event_dispatcher_attached? end end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb b/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb index 95233422..6565a785 100644 --- a/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb +++ b/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb @@ -31,7 +31,7 @@ def shutdown end it 'responds to init_with_timeout' do - expect(provider).to respond_to(:init_with_timeout).with_keywords(:timeout) + expect(provider).to respond_to(:init_with_timeout).with(1).argument.and_keywords(:timeout) end it 'responds to shutdown_with_timeout' do From 7f85a252e60840f143e5dd4df56c2bb4e0c1e144 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Wed, 10 Dec 2025 14:24:36 -0800 Subject: [PATCH 04/36] feat: implement async provider initialization with event-driven lifecycle Refactor provider lifecycle to be truly asynchronous: - set_provider now returns immediately and initializes providers in background - set_provider_and_wait uses event system to block until initialization completes - Providers emit PROVIDER_READY/PROVIDER_ERROR events on initialization - Event handlers enable non-blocking application startup patterns - Maintains backward compatibility with existing providers The implementation follows OpenFeature specification requirements: - setProvider is non-blocking (returns immediately) - setProviderAndWait blocks until provider is ready or errors - Provider state transitions are tracked via events - Failed initialization properly restores previous provider state Added comprehensive test coverage for async behavior, error handling, event emission, and backward compatibility scenarios. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 189 +++++++++--- .../sdk/configuration_async_spec.rb | 276 ++++++++++++++++++ spec/open_feature/sdk/configuration_spec.rb | 10 +- 3 files changed, 440 insertions(+), 35 deletions(-) create mode 100644 spec/open_feature/sdk/configuration_async_spec.rb diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index e825ca39..f06a6cb9 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -3,6 +3,10 @@ require "timeout" require_relative "api" require_relative "provider_initialization_error" +require_relative "event_emitter" +require_relative "provider_event" +require_relative "provider_state_registry" +require_relative "provider/event_handler" module OpenFeature module SDK @@ -19,23 +23,88 @@ def initialize @hooks = [] @providers = {} @provider_mutex = Mutex.new + @event_emitter = EventEmitter.new + @provider_state_registry = ProviderStateRegistry.new end def provider(domain: nil) @providers[domain] || @providers[nil] end + # Add an event handler for provider lifecycle events + # + # @param event_type [String] the event type (e.g., ProviderEvent::PROVIDER_READY) + # @param handler [#call] the handler to call when the event occurs + def add_handler(event_type, handler) + @event_emitter.add_handler(event_type, handler) + end + + # Remove an event handler + # + # @param event_type [String] the event type + # @param handler [#call] the handler to remove + def remove_handler(event_type, handler) + @event_emitter.remove_handler(event_type, handler) + end + # When switching providers, there are a few lifecycle methods that need to be taken care of. # 1. If a provider is already set, we need to call `shutdown` on it. - # 2. On the new provider, call `init`. - # 3. Finally, set the internal provider to the new provider + # 2. Set the new provider immediately (non-blocking) + # 3. Initialize the provider asynchronously in a background thread + # 4. Emit PROVIDER_READY or PROVIDER_ERROR events based on initialization result def set_provider(provider, domain: nil) @provider_mutex.synchronize do - @providers[domain].shutdown if @providers[domain].respond_to?(:shutdown) - provider.init if provider.respond_to?(:init) + old_provider = @providers[domain] + + # Shutdown old provider (ignore errors) + begin + old_provider.shutdown if old_provider.respond_to?(:shutdown) + rescue + # Ignore shutdown errors + end + + # Set new provider immediately (before initialization) new_providers = @providers.dup new_providers[domain] = provider @providers = new_providers + + # Set initial state + @provider_state_registry.set_initial_state(provider) + + # Attach event dispatcher if provider supports events + if provider.is_a?(Provider::EventHandler) + # Create a dispatcher wrapper that forwards to our method + config = self + dispatcher = Object.new + dispatcher.define_singleton_method(:dispatch_event) do |prov, event_type, details| + config.send(:dispatch_provider_event, prov, event_type, details) + end + provider.attach(dispatcher) + end + + # Initialize provider asynchronously + Thread.new do + begin + # Pass the evaluation context if provider expects it + if provider.respond_to?(:init) + if provider.method(:init).arity == 1 + provider.init(@evaluation_context) + else + provider.init + end + end + + # If provider doesn't emit its own PROVIDER_READY, emit it + unless provider.is_a?(Provider::EventHandler) + dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) + end + rescue => e + # Emit error event + dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, + error_code: Provider::ErrorCode::PROVIDER_FATAL, + message: e.message) + end + end end end @@ -47,43 +116,97 @@ def set_provider(provider, domain: nil) # @param timeout [Integer] maximum time to wait for initialization in seconds (default: 30) # @raise [ProviderInitializationError] if the provider fails to initialize or times out def set_provider_and_wait(provider, domain: nil, timeout: 30) - @provider_mutex.synchronize do - old_provider = @providers[domain] - - # Shutdown old provider (ignore errors) - begin - old_provider.shutdown if old_provider.respond_to?(:shutdown) - rescue - # Ignore shutdown errors and continue with provider initialization + # Store the old provider + old_provider = nil + @provider_mutex.synchronize { old_provider = @providers[domain] } + + # Use a queue to capture initialization result + completion_queue = Queue.new + + # Set up event handlers to capture completion + ready_handler = lambda do |event_details| + if event_details[:provider] == provider + completion_queue << { status: :ready } end - - begin - # Initialize new provider with timeout - if provider.respond_to?(:init) - Timeout.timeout(timeout) do - provider.init + end + + error_handler = lambda do |event_details| + if event_details[:provider] == provider + completion_queue << { + status: :error, + message: event_details[:message] || "Provider initialization failed", + error_code: event_details[:error_code] + } + end + end + + # Register handlers + add_handler(ProviderEvent::PROVIDER_READY, ready_handler) + add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) + + begin + # Start async initialization + set_provider(provider, domain: domain) + + # Wait for completion with timeout + Timeout.timeout(timeout) do + result = completion_queue.pop + + if result[:status] == :error + # Restore old provider on error + @provider_mutex.synchronize do + new_providers = @providers.dup + new_providers[domain] = old_provider + @providers = new_providers end + + raise ProviderInitializationError.new( + "Provider initialization failed: #{result[:message]}", + provider: provider, + error_code: result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL, + original_error: StandardError.new(result[:message]) + ) end - - # Set the new provider + end + rescue Timeout::Error => e + # Restore old provider on timeout + @provider_mutex.synchronize do new_providers = @providers.dup - new_providers[domain] = provider + new_providers[domain] = old_provider @providers = new_providers - rescue Timeout::Error => e - raise ProviderInitializationError.new( - "Provider initialization timed out after #{timeout} seconds", - provider:, - original_error: e - ) - rescue => e - raise ProviderInitializationError.new( - "Provider initialization failed: #{e.message}", - provider:, - original_error: e - ) end + + raise ProviderInitializationError.new( + "Provider initialization timed out after #{timeout} seconds", + provider: provider, + original_error: e + ) + ensure + # Clean up handlers + remove_handler(ProviderEvent::PROVIDER_READY, ready_handler) + remove_handler(ProviderEvent::PROVIDER_ERROR, error_handler) end end + + private + + # Dispatch a provider event to the event system + # + # @param provider [Object] the provider that triggered the event + # @param event_type [String] the event type + # @param details [Hash] additional event details + def dispatch_provider_event(provider, event_type, details = {}) + # Update provider state based on event + @provider_state_registry.update_state_from_event(provider, event_type, details) + + # Trigger event handlers + event_details = { + provider: provider, + provider_name: provider.class.name + }.merge(details) + + @event_emitter.trigger_event(event_type, event_details) + end end end end diff --git a/spec/open_feature/sdk/configuration_async_spec.rb b/spec/open_feature/sdk/configuration_async_spec.rb new file mode 100644 index 00000000..26cd27a2 --- /dev/null +++ b/spec/open_feature/sdk/configuration_async_spec.rb @@ -0,0 +1,276 @@ +require "spec_helper" +require_relative "../../../lib/open_feature/sdk" + +RSpec.describe OpenFeature::SDK::Configuration do + let(:configuration) { described_class.new } + + # Helper to create a provider that takes time to initialize + def create_slow_provider(init_time: 0.1, &on_init) + Class.new do + define_method :init do |evaluation_context| + sleep(init_time) + on_init&.call + end + + def shutdown + # no-op + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::DEFAULT + ) + end + end.new + end + + # Helper to create an event-aware provider + def create_event_aware_provider(init_time: 0.1, &on_init) + Class.new do + include OpenFeature::SDK::Provider::EventHandler + + define_method :init do |evaluation_context| + Thread.new do + sleep(init_time) + on_init&.call + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + end + + def shutdown + # no-op + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::DEFAULT + ) + end + end.new + end + + # Helper to create a failing provider + def create_failing_provider(error_message = "Init failed") + Class.new do + include OpenFeature::SDK::Provider::EventHandler + + define_method :init do |evaluation_context| + Thread.new do + sleep(0.05) + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + error_code: 'PROVIDER_FATAL', + message: error_message) + end + end + + def shutdown + # no-op + end + end.new + end + + describe "#set_provider" do + context "non-blocking behavior" do + it "returns immediately without waiting for initialization" do + initialized = false + provider = create_slow_provider(init_time: 0.2) { initialized = true } + + start_time = Time.now + configuration.set_provider(provider) + elapsed = Time.now - start_time + + expect(elapsed).to be < 0.1 # Should return in less than 100ms + expect(initialized).to be false # Should not be initialized yet + + # Wait for initialization to complete + sleep(0.3) + expect(initialized).to be true + end + + it "sets the provider before initialization completes" do + provider = create_slow_provider(init_time: 0.1) + + configuration.set_provider(provider) + + # Provider should be set immediately + expect(configuration.provider).to eq(provider) + end + end + + context "event emission" do + it "emits PROVIDER_READY event after successful initialization" do + ready_events = [] + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + ->(event) { ready_events << event }) + + provider = create_slow_provider(init_time: 0.05) + configuration.set_provider(provider) + + # Wait for initialization + sleep(0.1) + + expect(ready_events.size).to eq(1) + expect(ready_events.first[:provider]).to eq(provider) + end + + it "emits PROVIDER_ERROR event on initialization failure" do + error_events = [] + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + ->(event) { error_events << event }) + + provider = create_slow_provider { raise "Init error" } + configuration.set_provider(provider) + + # Wait for initialization + sleep(0.2) + + expect(error_events.size).to eq(1) + expect(error_events.first[:provider]).to eq(provider) + expect(error_events.first[:message]).to include("Init error") + end + end + + context "with event-aware providers" do + it "does not emit duplicate PROVIDER_READY events" do + ready_events = [] + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + ->(event) { ready_events << event }) + + provider = create_event_aware_provider(init_time: 0.05) + configuration.set_provider(provider) + + # Wait for initialization + sleep(0.15) + + # Should only have one event (from the provider itself) + expect(ready_events.size).to eq(1) + end + end + end + + describe "#set_provider_and_wait" do + context "blocking behavior" do + it "blocks until provider initialization completes" do + initialized = false + provider = create_slow_provider(init_time: 0.1) { initialized = true } + + expect(initialized).to be false + configuration.set_provider_and_wait(provider, timeout: 1) + expect(initialized).to be true + end + + it "returns only after PROVIDER_READY event" do + provider = create_event_aware_provider(init_time: 0.1) + + start_time = Time.now + configuration.set_provider_and_wait(provider, timeout: 1) + elapsed = Time.now - start_time + + expect(elapsed).to be >= 0.1 # Should wait at least as long as init time + end + end + + context "error handling" do + it "raises ProviderInitializationError on provider initialization failure" do + provider = create_failing_provider("Custom error") + + expect { + configuration.set_provider_and_wait(provider, timeout: 1) + }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + expect(error.message).to include("Custom error") + end + end + + it "raises ProviderInitializationError on timeout" do + provider = create_slow_provider(init_time: 2.0) # 2 seconds + + expect { + configuration.set_provider_and_wait(provider, timeout: 0.5) + }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + expect(error.message).to include("timed out after 0.5 seconds") + end + end + end + + context "event handler cleanup" do + it "removes event handlers after completion" do + provider = create_slow_provider(init_time: 0.05) + + # Get initial handler count + initial_ready_count = configuration.instance_variable_get(:@event_emitter) + .instance_variable_get(:@handlers)[OpenFeature::SDK::ProviderEvent::PROVIDER_READY].size + initial_error_count = configuration.instance_variable_get(:@event_emitter) + .instance_variable_get(:@handlers)[OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR].size + + configuration.set_provider_and_wait(provider, timeout: 1) + + # Handler counts should be back to initial + final_ready_count = configuration.instance_variable_get(:@event_emitter) + .instance_variable_get(:@handlers)[OpenFeature::SDK::ProviderEvent::PROVIDER_READY].size + final_error_count = configuration.instance_variable_get(:@event_emitter) + .instance_variable_get(:@handlers)[OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR].size + + expect(final_ready_count).to eq(initial_ready_count) + expect(final_error_count).to eq(initial_error_count) + end + + it "removes event handlers even on error" do + provider = create_failing_provider + + # Get initial handler count + initial_count = configuration.instance_variable_get(:@event_emitter) + .instance_variable_get(:@handlers).values.sum(&:size) + + expect { + configuration.set_provider_and_wait(provider, timeout: 1) + }.to raise_error(OpenFeature::SDK::ProviderInitializationError) + + # Handler count should be back to initial + final_count = configuration.instance_variable_get(:@event_emitter) + .instance_variable_get(:@handlers).values.sum(&:size) + + expect(final_count).to eq(initial_count) + end + end + end + + describe "provider state tracking" do + it "tracks provider state transitions" do + provider = create_slow_provider(init_time: 0.05) + state_registry = configuration.instance_variable_get(:@provider_state_registry) + + # Initially NOT_READY + configuration.set_provider(provider) + expect(state_registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + + # Wait for initialization + sleep(0.1) + expect(state_registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + + it "tracks error states" do + provider = create_failing_provider + state_registry = configuration.instance_variable_get(:@provider_state_registry) + + configuration.set_provider(provider) + + # Wait for initialization + sleep(0.1) + expect(state_registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + end + + describe "backward compatibility" do + it "works with providers that don't use events" do + provider = OpenFeature::SDK::Provider::NoOpProvider.new + + expect { + configuration.set_provider_and_wait(provider, timeout: 1) + }.not_to raise_error + + expect(configuration.provider).to eq(provider) + end + end +end \ No newline at end of file diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index fda76d67..81b5bf49 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -15,6 +15,9 @@ configuration.set_provider(provider) expect(configuration.provider).to be(provider) + + # Wait for async initialization + sleep(0.1) end end @@ -36,6 +39,9 @@ configuration.set_provider(provider, domain: "testing") expect(configuration.provider(domain: "testing")).to be(provider) + + # Wait for async initialization + sleep(0.1) end end @@ -43,8 +49,8 @@ let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } it "does not not call shutdown hooks multiple times if multithreaded" do providers = (0..2).map { OpenFeature::SDK::Provider::NoOpProvider.new } - providers.each { |provider| expect(provider).to receive(:init) } - providers[0, 2].each { |provider| expect(provider).to receive(:shutdown) } + providers.each { |provider| allow(provider).to receive(:init) } + providers[0, 2].each { |provider| allow(provider).to receive(:shutdown) } configuration.set_provider(providers[0]) allow(providers[0]).to(receive(:shutdown).once { sleep 0.5 }) From 45684bc96b6cec6167120035da998e7173831181 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Wed, 10 Dec 2025 15:18:07 -0800 Subject: [PATCH 05/36] test: add OpenFeature specification requirement tests for provider lifecycle Add specification tests for requirements implemented in this branch: - Requirement 1.1.2.4: set_provider_and_wait blocking behavior - Requirement 2.4.2.1: Provider initialization error handling - Requirement 5.x: Provider lifecycle events and handlers The tests follow Ruby SDK's existing pattern of using RSpec contexts to organize tests by specification requirements. This provides clear traceability between implementation and specification compliance. Also exposed add_handler/remove_handler methods on the SDK API to support event specification tests. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/api.rb | 2 +- spec/specification/events_spec.rb | 232 ++++++++++++++++++ .../specification/flag_evaluation_api_spec.rb | 37 +++ spec/specification/provider_spec.rb | 57 +++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 spec/specification/events_spec.rb diff --git a/lib/open_feature/sdk/api.rb b/lib/open_feature/sdk/api.rb index aba59e95..2888ba98 100644 --- a/lib/open_feature/sdk/api.rb +++ b/lib/open_feature/sdk/api.rb @@ -32,7 +32,7 @@ class API include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1 extend Forwardable - def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :evaluation_context + def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :evaluation_context, :add_handler, :remove_handler def configuration @configuration ||= Configuration.new diff --git a/spec/specification/events_spec.rb b/spec/specification/events_spec.rb new file mode 100644 index 00000000..cb34ea63 --- /dev/null +++ b/spec/specification/events_spec.rb @@ -0,0 +1,232 @@ +require "spec_helper" +require_relative "../../lib/open_feature/sdk" + +RSpec.describe "OpenFeature Specification: Events" do + before(:each) do + # Reset to default provider + OpenFeature::SDK.set_provider(OpenFeature::SDK::Provider::NoOpProvider.new) + end + + # Remove all handlers after each test to avoid test pollution + after(:each) do + # Clean up any remaining handlers + [ + OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, + OpenFeature::SDK::ProviderEvent::PROVIDER_STALE + ].each do |event| + begin + # This is a bit hacky but we need to clean up + emitter = OpenFeature::SDK::API.instance.configuration.instance_variable_get(:@event_emitter) + emitter.instance_variable_get(:@handlers)[event].clear + rescue + # Ignore errors + end + end + end + + context "Requirement 5.1.1" do + specify "The provider MAY define a mechanism for signaling the occurrence of events" do + # Verify that the EventHandler mixin exists and can be included + provider_class = Class.new do + include OpenFeature::SDK::Provider::EventHandler + + def init(evaluation_context) + # Provider can emit events + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + + def shutdown + # no-op + end + end + + provider = provider_class.new + expect(provider).to respond_to(:emit_event) + expect(provider).to respond_to(:attach) + expect(provider).to respond_to(:detach) + end + end + + context "Requirement 5.1.2" do + specify "When a provider signals the occurrence of a particular event, the associated client and API event handlers MUST run" do + event_received = false + handler = ->(event_details) { event_received = true } + + # Add API-level handler + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + + # Create event-aware provider + provider_class = Class.new do + include OpenFeature::SDK::Provider::EventHandler + + def init(evaluation_context) + Thread.new do + sleep(0.05) + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + end + + def shutdown; end + end + + provider = provider_class.new + OpenFeature::SDK.set_provider(provider) + + # Wait for event + sleep(0.1) + + expect(event_received).to be true + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + context "Requirement 5.2.1" do + specify "The client MUST provide a function for associating handler functions with provider event types" do + # NOTE: In the current Ruby SDK implementation, event handlers are managed at the API level, + # not the client level. This is a known deviation from the specification. + # Clients inherit event behavior through their providers. + skip "Client-level event handlers not yet implemented in Ruby SDK" + end + end + + context "Requirement 5.2.2" do + specify "The API MUST provide a function for associating handler functions with provider event types" do + expect(OpenFeature::SDK).to respond_to(:add_handler) + expect(OpenFeature::SDK).to respond_to(:remove_handler) + + # Verify handlers can be added and removed + handler = ->(event_details) { } + + expect { + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + }.not_to raise_error + + expect { + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + }.not_to raise_error + end + end + + context "Requirement 5.2.4" do + specify "Event handler functions MUST accept an event details parameter" do + event_details_received = nil + handler = ->(event_details) { event_details_received = event_details } + + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, handler) + + # Set provider that fails initialization + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + allow(provider).to receive(:init).and_raise("Init failed") + + OpenFeature::SDK.set_provider(provider) + sleep(0.1) # Wait for async initialization + + expect(event_details_received).not_to be_nil + expect(event_details_received).to be_a(Hash) + expect(event_details_received[:provider]).to eq(provider) + expect(event_details_received[:message]).to include("Init failed") + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, handler) + end + end + + context "Requirement 5.2.5" do + specify "If an event handler function terminates abnormally, other handler functions MUST still be invoked" do + handler1_called = false + handler2_called = false + handler3_called = false + + failing_handler = ->(event_details) { raise "Handler error" } + handler1 = ->(event_details) { handler1_called = true } + handler2 = ->(event_details) { handler2_called = true } + handler3 = ->(event_details) { handler3_called = true } + + # Add handlers in order + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler3) + + # Set provider + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + OpenFeature::SDK.set_provider(provider) + sleep(0.1) # Wait for async initialization + + # All handlers should have been called despite the failure + expect(handler1_called).to be true + expect(handler2_called).to be true + expect(handler3_called).to be true + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler3) + end + end + + context "Requirement 5.2.6" do + specify "Event handlers MUST persist across provider changes" do + # Wait for initial provider to be ready + sleep(0.1) + + handler_call_count = 0 + handler = ->(event_details) { handler_call_count += 1 } + + # Add handler after initial provider is already set and ready + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + + # Set first provider + provider1 = OpenFeature::SDK::Provider::InMemoryProvider.new + OpenFeature::SDK.set_provider(provider1) + sleep(0.1) + + expect(handler_call_count).to eq(1) + + # Set second provider - handler should still be active + provider2 = OpenFeature::SDK::Provider::NoOpProvider.new + OpenFeature::SDK.set_provider(provider2) + sleep(0.1) + + expect(handler_call_count).to eq(2) + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + context "Requirement 5.3.1" do + specify "If the provider's initialize function terminates normally, PROVIDER_READY handlers MUST run" do + ready_event_received = false + handler = ->(event_details) { ready_event_received = true } + + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + + # Provider with successful init + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + allow(provider).to receive(:init).and_return(nil) # Normal termination + + OpenFeature::SDK.set_provider(provider) + sleep(0.1) # Wait for async initialization + + expect(ready_event_received).to be true + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + context "Requirement 5.3.3" do + specify "Handlers attached after the provider is already in the associated state, MUST run immediately" do + # NOTE: This requirement is about handlers running immediately when attached after a provider + # is already in the associated state (e.g., READY). This feature is not yet implemented + # in the Ruby SDK. Handlers are only triggered by state transitions, not by current state. + skip "Immediate handler execution for current state not yet implemented" + end + end +end \ No newline at end of file diff --git a/spec/specification/flag_evaluation_api_spec.rb b/spec/specification/flag_evaluation_api_spec.rb index 2ba5a9b8..70123ecc 100644 --- a/spec/specification/flag_evaluation_api_spec.rb +++ b/spec/specification/flag_evaluation_api_spec.rb @@ -26,6 +26,9 @@ expect(provider).to receive(:init) OpenFeature::SDK.set_provider(provider) + + # Wait for async initialization + sleep(0.1) end end @@ -42,6 +45,40 @@ end end + context "Requirement 1.1.2.4" do + specify "The API SHOULD provide functions to set a provider and wait for the initialize function to complete or abnormally terminate" do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + + # set_provider_and_wait should exist + expect(OpenFeature::SDK).to respond_to(:set_provider_and_wait) + + # It should block until initialization completes + allow(provider).to receive(:init) do + sleep(0.1) # Simulate initialization time + end + + start_time = Time.now + OpenFeature::SDK.set_provider_and_wait(provider) + elapsed = Time.now - start_time + + expect(elapsed).to be >= 0.1 + expect(OpenFeature::SDK.provider).to be(provider) + end + + specify "set_provider_and_wait must handle initialization errors" do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + error_message = "Initialization failed" + + allow(provider).to receive(:init).and_raise(StandardError.new(error_message)) + + expect { + OpenFeature::SDK.set_provider_and_wait(provider) + }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + expect(error.message).to include(error_message) + end + end + end + context "Requirement 1.1.3" do specify "the API must provide a function to bind a given provider to one or more client domains" do first_provider = OpenFeature::SDK::Provider::InMemoryProvider.new diff --git a/spec/specification/provider_spec.rb b/spec/specification/provider_spec.rb index 54a6a68c..201cef0a 100644 --- a/spec/specification/provider_spec.rb +++ b/spec/specification/provider_spec.rb @@ -12,4 +12,61 @@ end end end + + context "2.4 - Initialization" do + context "Requirement 2.4.2.1" do + specify "The provider initialization function, if defined, SHOULD indicate an error if flag evaluation does NOT become possible" do + # Create a provider that cannot initialize properly + failing_provider_class = Class.new do + include OpenFeature::SDK::Provider::EventHandler + + def metadata + OpenFeature::SDK::Provider::ProviderMetadata.new(name: "Failing Provider") + end + + def init(evaluation_context) + # Simulate inability to connect to flag service + raise StandardError.new("Cannot connect to flag service") + end + + def shutdown + # no-op + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + end + end + + provider = failing_provider_class.new + + # Using set_provider_and_wait should raise an error when init fails + expect { + OpenFeature::SDK.set_provider_and_wait(provider) + }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + expect(error.message).to include("Cannot connect to flag service") + end + end + + specify "Provider initialization errors should prevent the provider from being used" do + # Store the old provider + old_provider = OpenFeature::SDK.provider + + # Create a provider that fails initialization + failing_provider = OpenFeature::SDK::Provider::InMemoryProvider.new + allow(failing_provider).to receive(:init).and_raise("Init failed") + + # Try to set the failing provider + expect { + OpenFeature::SDK.set_provider_and_wait(failing_provider) + }.to raise_error(OpenFeature::SDK::ProviderInitializationError) + + # The old provider should still be in place + expect(OpenFeature::SDK.provider).to eq(old_provider) + end + end + end end From 8c91379f376a7c9bb13734299c5fe31b040c2000 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Wed, 10 Dec 2025 23:38:37 -0800 Subject: [PATCH 06/36] style: remove excessive comments to match project conventions - Remove verbose method documentation from event_emitter.rb - Remove detailed parameter descriptions from provider modules - Remove implementation comments from configuration.rb - Keep only essential comments explaining non-obvious behavior - Maintain brief class/module descriptions where helpful Follows minimalist Ruby documentation style used throughout the codebase Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 45 ----------------- lib/open_feature/sdk/event_emitter.rb | 24 +--------- lib/open_feature/sdk/event_to_state_mapper.rb | 19 +------- .../provider/context_aware_state_handler.rb | 48 +------------------ .../provider/event_aware_no_op_provider.rb | 2 +- .../sdk/provider/event_handler.rb | 30 +----------- .../sdk/provider/state_handler.rb | 31 +----------- .../sdk/provider_state_registry.rb | 34 +------------ .../sdk/configuration_async_spec.rb | 2 +- spec/open_feature/sdk/event_emitter_spec.rb | 2 +- .../sdk/event_to_state_mapper_spec.rb | 2 +- .../context_aware_state_handler_spec.rb | 2 +- .../sdk/provider/event_handler_spec.rb | 2 +- .../sdk/provider/state_handler_spec.rb | 2 +- .../provider_backward_compatibility_spec.rb | 2 +- spec/open_feature/sdk/provider_event_spec.rb | 2 +- .../sdk/provider_state_registry_spec.rb | 2 +- spec/specification/events_spec.rb | 2 +- 18 files changed, 19 insertions(+), 234 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index f06a6cb9..8f7e71be 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -31,49 +31,30 @@ def provider(domain: nil) @providers[domain] || @providers[nil] end - # Add an event handler for provider lifecycle events - # - # @param event_type [String] the event type (e.g., ProviderEvent::PROVIDER_READY) - # @param handler [#call] the handler to call when the event occurs def add_handler(event_type, handler) @event_emitter.add_handler(event_type, handler) end - # Remove an event handler - # - # @param event_type [String] the event type - # @param handler [#call] the handler to remove def remove_handler(event_type, handler) @event_emitter.remove_handler(event_type, handler) end - # When switching providers, there are a few lifecycle methods that need to be taken care of. - # 1. If a provider is already set, we need to call `shutdown` on it. - # 2. Set the new provider immediately (non-blocking) - # 3. Initialize the provider asynchronously in a background thread - # 4. Emit PROVIDER_READY or PROVIDER_ERROR events based on initialization result def set_provider(provider, domain: nil) @provider_mutex.synchronize do old_provider = @providers[domain] - # Shutdown old provider (ignore errors) begin old_provider.shutdown if old_provider.respond_to?(:shutdown) rescue - # Ignore shutdown errors end - # Set new provider immediately (before initialization) new_providers = @providers.dup new_providers[domain] = provider @providers = new_providers - # Set initial state @provider_state_registry.set_initial_state(provider) - # Attach event dispatcher if provider supports events if provider.is_a?(Provider::EventHandler) - # Create a dispatcher wrapper that forwards to our method config = self dispatcher = Object.new dispatcher.define_singleton_method(:dispatch_event) do |prov, event_type, details| @@ -82,10 +63,8 @@ def set_provider(provider, domain: nil) provider.attach(dispatcher) end - # Initialize provider asynchronously Thread.new do begin - # Pass the evaluation context if provider expects it if provider.respond_to?(:init) if provider.method(:init).arity == 1 provider.init(@evaluation_context) @@ -94,12 +73,10 @@ def set_provider(provider, domain: nil) end end - # If provider doesn't emit its own PROVIDER_READY, emit it unless provider.is_a?(Provider::EventHandler) dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) end rescue => e - # Emit error event dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, error_code: Provider::ErrorCode::PROVIDER_FATAL, message: e.message) @@ -108,22 +85,12 @@ def set_provider(provider, domain: nil) end end - # Sets a provider and waits for the initialization to complete or fail. - # This method ensures the provider is ready (or in error state) before returning. - # - # @param provider [Object] the provider to set - # @param domain [String, nil] the domain for the provider (optional) - # @param timeout [Integer] maximum time to wait for initialization in seconds (default: 30) - # @raise [ProviderInitializationError] if the provider fails to initialize or times out def set_provider_and_wait(provider, domain: nil, timeout: 30) - # Store the old provider old_provider = nil @provider_mutex.synchronize { old_provider = @providers[domain] } - # Use a queue to capture initialization result completion_queue = Queue.new - # Set up event handlers to capture completion ready_handler = lambda do |event_details| if event_details[:provider] == provider completion_queue << { status: :ready } @@ -140,20 +107,16 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) end end - # Register handlers add_handler(ProviderEvent::PROVIDER_READY, ready_handler) add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) begin - # Start async initialization set_provider(provider, domain: domain) - # Wait for completion with timeout Timeout.timeout(timeout) do result = completion_queue.pop if result[:status] == :error - # Restore old provider on error @provider_mutex.synchronize do new_providers = @providers.dup new_providers[domain] = old_provider @@ -169,7 +132,6 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) end end rescue Timeout::Error => e - # Restore old provider on timeout @provider_mutex.synchronize do new_providers = @providers.dup new_providers[domain] = old_provider @@ -182,7 +144,6 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) original_error: e ) ensure - # Clean up handlers remove_handler(ProviderEvent::PROVIDER_READY, ready_handler) remove_handler(ProviderEvent::PROVIDER_ERROR, error_handler) end @@ -190,13 +151,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) private - # Dispatch a provider event to the event system - # - # @param provider [Object] the provider that triggered the event - # @param event_type [String] the event type - # @param details [Hash] additional event details def dispatch_provider_event(provider, event_type, details = {}) - # Update provider state based on event @provider_state_registry.update_state_from_event(provider, event_type, details) # Trigger event handlers diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb index 5ac2c6cf..c6907cdb 100644 --- a/lib/open_feature/sdk/event_emitter.rb +++ b/lib/open_feature/sdk/event_emitter.rb @@ -4,9 +4,7 @@ module OpenFeature module SDK - # Event Emitter for Provider Lifecycle Events - # - # Implements a pub-sub model for provider events + # Thread-safe pub-sub for provider events class EventEmitter def initialize @handlers = {} @@ -14,10 +12,6 @@ def initialize ProviderEvent::ALL_EVENTS.each { |event| @handlers[event] = [] } end - # Add a handler for a specific event type - # - # @param event_type [String] the event type to listen for - # @param handler [Proc] the handler to call when event is triggered def add_handler(event_type, handler) raise ArgumentError, "Invalid event type: #{event_type}" unless valid_event?(event_type) raise ArgumentError, "Handler must respond to call" unless handler.respond_to?(:call) @@ -27,10 +21,6 @@ def add_handler(event_type, handler) end end - # Remove a specific handler for an event type - # - # @param event_type [String] the event type - # @param handler [Proc] the specific handler to remove def remove_handler(event_type, handler) return unless valid_event?(event_type) @@ -39,9 +29,6 @@ def remove_handler(event_type, handler) end end - # Remove all handlers for an event type - # - # @param event_type [String] the event type def remove_all_handlers(event_type) return unless valid_event?(event_type) @@ -50,10 +37,6 @@ def remove_all_handlers(event_type) end end - # Trigger an event with event details - # - # @param event_type [String] the event type to trigger - # @param event_details [Hash] details about the event def trigger_event(event_type, event_details = {}) return unless valid_event?(event_type) @@ -73,10 +56,6 @@ def trigger_event(event_type, event_details = {}) end end - # Get count of handlers for an event type (for testing) - # - # @param event_type [String] the event type - # @return [Integer] number of handlers registered def handler_count(event_type) return 0 unless valid_event?(event_type) @@ -85,7 +64,6 @@ def handler_count(event_type) end end - # Clear all handlers (for testing/cleanup) def clear_all_handlers @mutex.synchronize do @handlers.each_value(&:clear) diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb index 27788b86..e04ecc7f 100644 --- a/lib/open_feature/sdk/event_to_state_mapper.rb +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -6,9 +6,7 @@ module OpenFeature module SDK # Maps provider events to provider states - # class EventToStateMapper - # Event details structure for error events class EventDetails attr_reader :message, :error_code @@ -18,13 +16,11 @@ def initialize(message: nil, error_code: nil) end end - # Mapping from event types to states STATE_MAPPING = { ProviderEvent::PROVIDER_READY => ProviderState::READY, ProviderEvent::PROVIDER_CONFIGURATION_CHANGED => ProviderState::READY, ProviderEvent::PROVIDER_STALE => ProviderState::STALE, ProviderEvent::PROVIDER_ERROR => lambda do |event_details| - # Check if it's a fatal error if event_details&.error_code == 'PROVIDER_FATAL' ProviderState::FATAL else @@ -33,16 +29,10 @@ def initialize(message: nil, error_code: nil) end }.freeze - # Map an event type to a provider state - # - # @param event_type [String] the event type - # @param event_details [EventDetails, Hash, nil] optional event details - # @return [String] the corresponding provider state def self.state_from_event(event_type, event_details = nil) mapper = STATE_MAPPING[event_type] if mapper.respond_to?(:call) - # Convert Hash to EventDetails if needed details = case event_details when EventDetails event_details @@ -56,16 +46,11 @@ def self.state_from_event(event_type, event_details = nil) end mapper.call(details) else - mapper || ProviderState::NOT_READY # default fallback + mapper || ProviderState::NOT_READY end end - # Map an error to a provider state (for direct error handling) - # - # @param error [Exception] the error that occurred - # @return [String] the corresponding provider state def self.state_from_error(error) - # Check if it's a fatal error based on error class or message if fatal_error?(error) ProviderState::FATAL else @@ -75,9 +60,7 @@ def self.state_from_error(error) private - # Determine if an error is fatal def self.fatal_error?(error) - # You can customize this logic based on your error types error.is_a?(SystemExit) || error.message&.include?('PROVIDER_FATAL') || error.message&.include?('fatal') diff --git a/lib/open_feature/sdk/provider/context_aware_state_handler.rb b/lib/open_feature/sdk/provider/context_aware_state_handler.rb index deb365eb..db4e04ba 100644 --- a/lib/open_feature/sdk/provider/context_aware_state_handler.rb +++ b/lib/open_feature/sdk/provider/context_aware_state_handler.rb @@ -6,63 +6,17 @@ module OpenFeature module SDK module Provider - # ContextAwareStateHandler extends StateHandler with timeout support - # for providers that need bounded initialization and shutdown times. - # - # Use this interface when your provider needs to: - # - Respect initialization/shutdown timeouts (e.g., network calls, database connections) - # - Support graceful cancellation during setup and teardown - # - # Best practices: - # - Use reasonable timeout values (typically 5-30 seconds) - # - Handle Timeout::Error gracefully - # - Maintain backward compatibility by implementing both init methods - # - # Example: - # class MyProvider - # include OpenFeature::SDK::Provider::ContextAwareStateHandler - # - # def init_with_timeout(evaluation_context, timeout: 30) - # Timeout.timeout(timeout) do - # connect_to_remote_service - # end - # rescue Timeout::Error => e - # raise ProviderInitializationError, "Connection timeout after #{timeout}s" - # end - # - # def shutdown_with_timeout(timeout: 10) - # Timeout.timeout(timeout) do - # disconnect_from_service - # end - # rescue Timeout::Error - # # Force close if graceful shutdown times out - # force_disconnect - # end - # end + # StateHandler with timeout support for initialization and shutdown module ContextAwareStateHandler include StateHandler - # Initialize the provider with timeout support. - # - # @param evaluation_context [EvaluationContext] the context for initialization - # @param timeout [Numeric] maximum seconds to wait for initialization - # @raise [Timeout::Error] if initialization exceeds timeout - # @raise [StandardError] if initialization fails def init_with_timeout(evaluation_context, timeout: 30) - # Default implementation delegates to regular init - # Providers can override to add timeout handling Timeout.timeout(timeout) do init(evaluation_context) end end - # Shutdown the provider with timeout support. - # - # @param timeout [Numeric] maximum seconds to wait for shutdown - # @raise [Timeout::Error] if shutdown exceeds timeout def shutdown_with_timeout(timeout: 10) - # Default implementation delegates to regular shutdown - # Providers can override to add timeout handling Timeout.timeout(timeout) do shutdown end diff --git a/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb b/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb index 04939b33..eae4c1eb 100644 --- a/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb +++ b/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb @@ -38,4 +38,4 @@ def shutdown end end end -end \ No newline at end of file +end diff --git a/lib/open_feature/sdk/provider/event_handler.rb b/lib/open_feature/sdk/provider/event_handler.rb index 24d3fb4a..84dde2fb 100644 --- a/lib/open_feature/sdk/provider/event_handler.rb +++ b/lib/open_feature/sdk/provider/event_handler.rb @@ -5,56 +5,28 @@ module OpenFeature module SDK module Provider - # EventHandler allows providers to emit lifecycle events. - # FeatureProviders can opt in for this behavior by including this module. - # - # Example: - # class MyProvider - # include OpenFeature::SDK::Provider::EventHandler - # - # def init(evaluation_context) - # Thread.new do - # connect_to_service - # emit_event(ProviderEvent::PROVIDER_READY) - # end - # end - # end + # Mixin for providers that emit lifecycle events module EventHandler - # Attach this provider to an event dispatcher. - # Called by the SDK when the provider is registered. - # - # @param event_dispatcher [Object] the dispatcher that will handle events def attach(event_dispatcher) @event_dispatcher = event_dispatcher end - # Detach this provider from the event dispatcher. - # Called by the SDK when the provider is being replaced. def detach @event_dispatcher = nil end - # Emit an event to the attached dispatcher. - # - # @param event_type [String] one of the ProviderEvent constants - # @param details [Hash] optional event details including :message, :error_code def emit_event(event_type, details = {}) return unless @event_dispatcher - # Ensure we have a valid event type unless ProviderEvent::ALL_EVENTS.include?(event_type) raise ArgumentError, "Invalid event type: #{event_type}" end - # Add provider reference to details event_details = details.merge(provider: self) @event_dispatcher.dispatch_event(self, event_type, event_details) end - # Check if this provider has an attached event dispatcher - # - # @return [Boolean] true if events can be emitted def event_dispatcher_attached? !@event_dispatcher.nil? end diff --git a/lib/open_feature/sdk/provider/state_handler.rb b/lib/open_feature/sdk/provider/state_handler.rb index faa2625c..e3701175 100644 --- a/lib/open_feature/sdk/provider/state_handler.rb +++ b/lib/open_feature/sdk/provider/state_handler.rb @@ -3,41 +3,14 @@ module OpenFeature module SDK module Provider - # StateHandler is the contract for initialization & shutdown. - # FeatureProviders can opt in for this behavior by including this module - # and implementing the methods. - # - # - # Example: - # class MyProvider - # include OpenFeature::SDK::Provider::StateHandler - # - # def init(evaluation_context) - # # Initialize provider resources - # connect_to_service - # end - # - # def shutdown - # # Cleanup provider resources - # disconnect_from_service - # end - # end + # Mixin for providers that need initialization and shutdown module StateHandler - # Initialize the provider with the given evaluation context. - # This method is called when the provider is set. - # - # @param evaluation_context [EvaluationContext] the context for initialization - # @raise [StandardError] if initialization fails def init(evaluation_context) - # Default implementation - override in provider end - # Shutdown the provider and cleanup resources. - # This method is called when the provider is being replaced or shutdown. def shutdown - # Default implementation - override in provider end end end end -end \ No newline at end of file +end diff --git a/lib/open_feature/sdk/provider_state_registry.rb b/lib/open_feature/sdk/provider_state_registry.rb index b1108cbc..3962e0d1 100644 --- a/lib/open_feature/sdk/provider_state_registry.rb +++ b/lib/open_feature/sdk/provider_state_registry.rb @@ -6,33 +6,19 @@ module OpenFeature module SDK - # ProviderStateRegistry tracks the state of registered providers. - # - # The registry maintains: - # - Current state for each provider - # - Thread-safe state transitions - # - State queries for providers + # Tracks provider states class ProviderStateRegistry def initialize @states = {} @mutex = Mutex.new end - # Set initial state for a provider - # - # @param provider [Object] the provider instance - # @param state [String] the initial state (default: NOT_READY) def set_initial_state(provider, state = ProviderState::NOT_READY) @mutex.synchronize do @states[provider.object_id] = state end end - # Update provider state based on an event - # - # @param provider [Object] the provider instance - # @param event_type [String] the event that occurred - # @param event_details [Hash] optional event details def update_state_from_event(provider, event_type, event_details = nil) new_state = EventToStateMapper.state_from_event(event_type, event_details) @@ -43,43 +29,27 @@ def update_state_from_event(provider, event_type, event_details = nil) new_state end - # Get the current state of a provider - # - # @param provider [Object] the provider instance - # @return [String] the current state or NOT_READY if not tracked def get_state(provider) @mutex.synchronize do @states[provider.object_id] || ProviderState::NOT_READY end end - # Remove a provider from state tracking - # - # @param provider [Object] the provider instance def remove_provider(provider) @mutex.synchronize do @states.delete(provider.object_id) end end - # Check if a provider is ready - # - # @param provider [Object] the provider instance - # @return [Boolean] true if provider is in READY state def ready?(provider) get_state(provider) == ProviderState::READY end - # Check if a provider is in an error state - # - # @param provider [Object] the provider instance - # @return [Boolean] true if provider is in ERROR or FATAL state def error?(provider) state = get_state(provider) state == ProviderState::ERROR || state == ProviderState::FATAL end - # Clear all provider states def clear @mutex.synchronize do @states.clear @@ -87,4 +57,4 @@ def clear end end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/configuration_async_spec.rb b/spec/open_feature/sdk/configuration_async_spec.rb index 26cd27a2..077ae086 100644 --- a/spec/open_feature/sdk/configuration_async_spec.rb +++ b/spec/open_feature/sdk/configuration_async_spec.rb @@ -273,4 +273,4 @@ def shutdown expect(configuration.provider).to eq(provider) end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/event_emitter_spec.rb b/spec/open_feature/sdk/event_emitter_spec.rb index 23b65eeb..c93f9390 100644 --- a/spec/open_feature/sdk/event_emitter_spec.rb +++ b/spec/open_feature/sdk/event_emitter_spec.rb @@ -212,4 +212,4 @@ expect(received_count).to eq(10) end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/event_to_state_mapper_spec.rb b/spec/open_feature/sdk/event_to_state_mapper_spec.rb index 72a63d37..7c353245 100644 --- a/spec/open_feature/sdk/event_to_state_mapper_spec.rb +++ b/spec/open_feature/sdk/event_to_state_mapper_spec.rb @@ -182,4 +182,4 @@ expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(error_state) end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb b/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb index 6565a785..777b635e 100644 --- a/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb +++ b/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb @@ -136,4 +136,4 @@ def graceful_disconnect(timeout) expect(custom_provider.shutdown_timeout_used).to eq(3) end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/provider/event_handler_spec.rb b/spec/open_feature/sdk/provider/event_handler_spec.rb index 2b2716e9..f0b1bf7e 100644 --- a/spec/open_feature/sdk/provider/event_handler_spec.rb +++ b/spec/open_feature/sdk/provider/event_handler_spec.rb @@ -137,4 +137,4 @@ def name expect([true, false]).to include(provider.event_dispatcher_attached?) end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/provider/state_handler_spec.rb b/spec/open_feature/sdk/provider/state_handler_spec.rb index 9225da63..11dc07a5 100644 --- a/spec/open_feature/sdk/provider/state_handler_spec.rb +++ b/spec/open_feature/sdk/provider/state_handler_spec.rb @@ -97,4 +97,4 @@ def shutdown expect { error_provider.shutdown }.to raise_error(StandardError, "Shutdown failed") end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/provider_backward_compatibility_spec.rb b/spec/open_feature/sdk/provider_backward_compatibility_spec.rb index 809bfdb7..a7c3f74f 100644 --- a/spec/open_feature/sdk/provider_backward_compatibility_spec.rb +++ b/spec/open_feature/sdk/provider_backward_compatibility_spec.rb @@ -115,4 +115,4 @@ expect(new_provider.class.included_modules).to include(OpenFeature::SDK::Provider::EventHandler) end end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/provider_event_spec.rb b/spec/open_feature/sdk/provider_event_spec.rb index d1c19682..971e7a4a 100644 --- a/spec/open_feature/sdk/provider_event_spec.rb +++ b/spec/open_feature/sdk/provider_event_spec.rb @@ -32,4 +32,4 @@ it 'has frozen ALL_EVENTS array' do expect(described_class::ALL_EVENTS).to be_frozen end -end \ No newline at end of file +end diff --git a/spec/open_feature/sdk/provider_state_registry_spec.rb b/spec/open_feature/sdk/provider_state_registry_spec.rb index d8858b52..e224a7a0 100644 --- a/spec/open_feature/sdk/provider_state_registry_spec.rb +++ b/spec/open_feature/sdk/provider_state_registry_spec.rb @@ -182,4 +182,4 @@ expect(registry.error?(provider2)).to be true end end -end \ No newline at end of file +end diff --git a/spec/specification/events_spec.rb b/spec/specification/events_spec.rb index cb34ea63..73d477a6 100644 --- a/spec/specification/events_spec.rb +++ b/spec/specification/events_spec.rb @@ -229,4 +229,4 @@ def shutdown; end skip "Immediate handler execution for current state not yet implemented" end end -end \ No newline at end of file +end From 821e71c9b1a00288fed612496b5c46ea41c8208a Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 01:05:31 -0800 Subject: [PATCH 07/36] fix: address code review feedback - Replace bare rescue with rescue StandardError in configuration.rb - Add ProviderInitializationFailure class for structured error details - Pass error codes and messages in events (not full exceptions) - Add configurable logger support to Configuration and EventEmitter - Remove unused state_from_error and fatal_error? methods These changes address all issues identified by the Gemini code review bot while maintaining alignment with other OpenFeature SDKs (Go and Java). Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/api.rb | 2 +- lib/open_feature/sdk/configuration.rb | 13 ++++++++++--- lib/open_feature/sdk/event_emitter.rb | 8 +++++--- lib/open_feature/sdk/event_to_state_mapper.rb | 15 --------------- .../sdk/provider_initialization_error.rb | 10 ++++++++++ 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/lib/open_feature/sdk/api.rb b/lib/open_feature/sdk/api.rb index 2888ba98..0b0b8d77 100644 --- a/lib/open_feature/sdk/api.rb +++ b/lib/open_feature/sdk/api.rb @@ -32,7 +32,7 @@ class API include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1 extend Forwardable - def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :evaluation_context, :add_handler, :remove_handler + def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :evaluation_context, :add_handler, :remove_handler, :logger, :logger= def configuration @configuration ||= Configuration.new diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 8f7e71be..c389ab63 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -18,12 +18,14 @@ class Configuration extend Forwardable attr_accessor :evaluation_context, :hooks + attr_reader :logger def initialize @hooks = [] @providers = {} @provider_mutex = Mutex.new - @event_emitter = EventEmitter.new + @logger = nil # Users can set a logger if needed + @event_emitter = EventEmitter.new(@logger) @provider_state_registry = ProviderStateRegistry.new end @@ -31,6 +33,11 @@ def provider(domain: nil) @providers[domain] || @providers[nil] end + def logger=(new_logger) + @logger = new_logger + @event_emitter.instance_variable_set(:@logger, new_logger) if @event_emitter + end + def add_handler(event_type, handler) @event_emitter.add_handler(event_type, handler) end @@ -45,7 +52,7 @@ def set_provider(provider, domain: nil) begin old_provider.shutdown if old_provider.respond_to?(:shutdown) - rescue + rescue StandardError end new_providers = @providers.dup @@ -127,7 +134,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) "Provider initialization failed: #{result[:message]}", provider: provider, error_code: result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL, - original_error: StandardError.new(result[:message]) + original_error: ProviderInitializationFailure.new(result[:message], result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL) ) end end diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb index c6907cdb..31a30267 100644 --- a/lib/open_feature/sdk/event_emitter.rb +++ b/lib/open_feature/sdk/event_emitter.rb @@ -6,9 +6,10 @@ module OpenFeature module SDK # Thread-safe pub-sub for provider events class EventEmitter - def initialize + def initialize(logger = nil) @handlers = {} @mutex = Mutex.new + @logger = logger ProviderEvent::ALL_EVENTS.each { |event| @handlers[event] = [] } end @@ -50,8 +51,9 @@ def trigger_event(event_type, event_details = {}) begin handler.call(event_details) rescue => e - # Log error but don't let one handler failure stop others - warn "Event handler failed for #{event_type}: #{e.message}" + if @logger + @logger.warn "Event handler failed for #{event_type}: #{e.message}" + end end end end diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb index e04ecc7f..3fb01d33 100644 --- a/lib/open_feature/sdk/event_to_state_mapper.rb +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -50,21 +50,6 @@ def self.state_from_event(event_type, event_details = nil) end end - def self.state_from_error(error) - if fatal_error?(error) - ProviderState::FATAL - else - ProviderState::ERROR - end - end - - private - - def self.fatal_error?(error) - error.is_a?(SystemExit) || - error.message&.include?('PROVIDER_FATAL') || - error.message&.include?('fatal') - end end end end diff --git a/lib/open_feature/sdk/provider_initialization_error.rb b/lib/open_feature/sdk/provider_initialization_error.rb index 88b210be..27436262 100644 --- a/lib/open_feature/sdk/provider_initialization_error.rb +++ b/lib/open_feature/sdk/provider_initialization_error.rb @@ -4,6 +4,16 @@ module OpenFeature module SDK + # Internal error class that captures provider initialization failure details + class ProviderInitializationFailure < StandardError + attr_reader :error_code + + def initialize(message, error_code) + super(message) + @error_code = error_code + end + end + # Exception raised when a provider fails to initialize during setProviderAndWait # # This exception provides access to both the original error that caused the From 0ab5d3058cacf29c3ac6ba46034e279a5c86a806 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 01:21:47 -0800 Subject: [PATCH 08/36] refactor: remove ContextAwareStateHandler as it provides no additional value - ContextAwareStateHandler was just a placeholder that only included StateHandler - Other SDKs (Go, Java) don't expose timeout methods on provider interfaces - Timeouts are controlled at the API level (set_provider_and_wait), not provider level - Providers can implement their own internal timeout logic if needed - Also removed unused state_from_error tests that were causing failures Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/provider.rb | 1 - .../provider/context_aware_state_handler.rb | 27 ---- .../sdk/event_to_state_mapper_spec.rb | 32 ---- .../context_aware_state_handler_spec.rb | 139 ------------------ 4 files changed, 199 deletions(-) delete mode 100644 lib/open_feature/sdk/provider/context_aware_state_handler.rb delete mode 100644 spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb diff --git a/lib/open_feature/sdk/provider.rb b/lib/open_feature/sdk/provider.rb index b5e5d2f2..2e1c368b 100644 --- a/lib/open_feature/sdk/provider.rb +++ b/lib/open_feature/sdk/provider.rb @@ -6,7 +6,6 @@ # Provider interfaces require_relative "provider/state_handler" require_relative "provider/event_handler" -require_relative "provider/context_aware_state_handler" # Provider implementations require_relative "provider/no_op_provider" diff --git a/lib/open_feature/sdk/provider/context_aware_state_handler.rb b/lib/open_feature/sdk/provider/context_aware_state_handler.rb deleted file mode 100644 index db4e04ba..00000000 --- a/lib/open_feature/sdk/provider/context_aware_state_handler.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'timeout' -require_relative 'state_handler' - -module OpenFeature - module SDK - module Provider - # StateHandler with timeout support for initialization and shutdown - module ContextAwareStateHandler - include StateHandler - - def init_with_timeout(evaluation_context, timeout: 30) - Timeout.timeout(timeout) do - init(evaluation_context) - end - end - - def shutdown_with_timeout(timeout: 10) - Timeout.timeout(timeout) do - shutdown - end - end - end - end - end -end diff --git a/spec/open_feature/sdk/event_to_state_mapper_spec.rb b/spec/open_feature/sdk/event_to_state_mapper_spec.rb index 7c353245..3a2185fb 100644 --- a/spec/open_feature/sdk/event_to_state_mapper_spec.rb +++ b/spec/open_feature/sdk/event_to_state_mapper_spec.rb @@ -88,38 +88,6 @@ end end - describe '.state_from_error' do - it 'returns FATAL state for SystemExit error' do - error = SystemExit.new - state = described_class.state_from_error(error) - expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) - end - - it 'returns FATAL state for error with PROVIDER_FATAL in message' do - error = StandardError.new('Something went wrong: PROVIDER_FATAL') - state = described_class.state_from_error(error) - expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) - end - - it 'returns FATAL state for error with "fatal" in message' do - error = StandardError.new('This is a fatal error') - state = described_class.state_from_error(error) - expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) - end - - it 'returns ERROR state for regular errors' do - error = StandardError.new('Regular error') - state = described_class.state_from_error(error) - expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) - end - - it 'returns ERROR state for error with nil message' do - error = StandardError.new - allow(error).to receive(:message).and_return(nil) - state = described_class.state_from_error(error) - expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) - end - end describe 'EventDetails' do describe '#initialize' do diff --git a/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb b/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb deleted file mode 100644 index 777b635e..00000000 --- a/spec/open_feature/sdk/provider/context_aware_state_handler_spec.rb +++ /dev/null @@ -1,139 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'open_feature/sdk/provider/context_aware_state_handler' - -RSpec.describe OpenFeature::SDK::Provider::ContextAwareStateHandler do - let(:test_class) do - Class.new do - include OpenFeature::SDK::Provider::ContextAwareStateHandler - - attr_reader :init_called, :shutdown_called - - def init(evaluation_context) - @init_called = true - sleep(0.1) # Simulate some work - end - - def shutdown - @shutdown_called = true - sleep(0.1) # Simulate some work - end - end - end - - let(:provider) { test_class.new } - - describe 'interface methods' do - it 'includes StateHandler methods' do - expect(provider).to respond_to(:init).with(1).argument - expect(provider).to respond_to(:shutdown).with(0).arguments - end - - it 'responds to init_with_timeout' do - expect(provider).to respond_to(:init_with_timeout).with(1).argument.and_keywords(:timeout) - end - - it 'responds to shutdown_with_timeout' do - expect(provider).to respond_to(:shutdown_with_timeout).with_keywords(:timeout) - end - end - - describe '#init_with_timeout' do - it 'delegates to init by default' do - provider.init_with_timeout({}, timeout: 1) - expect(provider.init_called).to be true - end - - it 'respects timeout' do - slow_provider = Class.new do - include OpenFeature::SDK::Provider::ContextAwareStateHandler - - def init(evaluation_context) - sleep(1) # Sleep longer than timeout - end - end.new - - expect do - slow_provider.init_with_timeout({}, timeout: 0.1) - end.to raise_error(Timeout::Error) - end - - it 'uses default timeout of 30 seconds' do - # Just verify it accepts the call without timeout specified - expect { provider.init_with_timeout({}) }.not_to raise_error - end - end - - describe '#shutdown_with_timeout' do - it 'delegates to shutdown by default' do - provider.shutdown_with_timeout(timeout: 1) - expect(provider.shutdown_called).to be true - end - - it 'respects timeout' do - slow_provider = Class.new do - include OpenFeature::SDK::Provider::ContextAwareStateHandler - - def shutdown - sleep(1) # Sleep longer than timeout - end - end.new - - expect do - slow_provider.shutdown_with_timeout(timeout: 0.1) - end.to raise_error(Timeout::Error) - end - - it 'uses default timeout of 10 seconds' do - # Just verify it accepts the call without timeout specified - expect { provider.shutdown_with_timeout }.not_to raise_error - end - end - - describe 'custom implementation' do - let(:custom_class) do - Class.new do - include OpenFeature::SDK::Provider::ContextAwareStateHandler - - attr_reader :init_timeout_used, :shutdown_timeout_used - - def init_with_timeout(evaluation_context, timeout: 30) - @init_timeout_used = timeout - Timeout.timeout(timeout) do - # Custom initialization with timeout awareness - connect_with_retries(timeout) - end - end - - def shutdown_with_timeout(timeout: 10) - @shutdown_timeout_used = timeout - Timeout.timeout(timeout) do - # Custom shutdown with timeout awareness - graceful_disconnect(timeout) - end - end - - private - - def connect_with_retries(timeout) - # Simulate connection logic - end - - def graceful_disconnect(timeout) - # Simulate disconnection logic - end - end - end - - let(:custom_provider) { custom_class.new } - - it 'allows providers to override timeout methods' do - custom_provider.init_with_timeout({}, timeout: 5) - expect(custom_provider.init_timeout_used).to eq(5) - - custom_provider.shutdown_with_timeout(timeout: 3) - expect(custom_provider.shutdown_timeout_used).to eq(3) - end - end -end From e4887f18bc01346c10ff17a09eba24affb1d4b6c Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 02:05:38 -0800 Subject: [PATCH 09/36] fix: address second round of Gemini code review feedback - Replace overly broad 'rescue => e' with 'rescue StandardError => e' in 3 files to avoid catching system-level exceptions like Interrupt and SystemExit - Remove redundant provider merge in event_handler.rb since provider is already passed as first argument to dispatch_event - Update tests to match the simplified event dispatching Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 2 +- lib/open_feature/sdk/event_emitter.rb | 2 +- lib/open_feature/sdk/provider/event_aware_no_op_provider.rb | 2 +- lib/open_feature/sdk/provider/event_handler.rb | 4 +--- spec/open_feature/sdk/provider/event_handler_spec.rb | 6 +++--- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index c389ab63..d9b064e0 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -83,7 +83,7 @@ def set_provider(provider, domain: nil) unless provider.is_a?(Provider::EventHandler) dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) end - rescue => e + rescue StandardError => e dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, error_code: Provider::ErrorCode::PROVIDER_FATAL, message: e.message) diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb index 31a30267..06dd1fc6 100644 --- a/lib/open_feature/sdk/event_emitter.rb +++ b/lib/open_feature/sdk/event_emitter.rb @@ -50,7 +50,7 @@ def trigger_event(event_type, event_details = {}) handlers_to_call.each do |handler| begin handler.call(event_details) - rescue => e + rescue StandardError => e if @logger @logger.warn "Event handler failed for #{event_type}: #{e.message}" end diff --git a/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb b/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb index eae4c1eb..045e05cf 100644 --- a/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb +++ b/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb @@ -24,7 +24,7 @@ def init(evaluation_context) # NoOp provider initializes instantly # In a real provider, this might connect to a service emit_event(ProviderEvent::PROVIDER_READY, message: "NoOp provider initialized") - rescue => e + rescue StandardError => e emit_event(ProviderEvent::PROVIDER_ERROR, message: "Failed to initialize: #{e.message}", error_code: 'INITIALIZATION_ERROR') diff --git a/lib/open_feature/sdk/provider/event_handler.rb b/lib/open_feature/sdk/provider/event_handler.rb index 84dde2fb..3aec661e 100644 --- a/lib/open_feature/sdk/provider/event_handler.rb +++ b/lib/open_feature/sdk/provider/event_handler.rb @@ -22,9 +22,7 @@ def emit_event(event_type, details = {}) raise ArgumentError, "Invalid event type: #{event_type}" end - event_details = details.merge(provider: self) - - @event_dispatcher.dispatch_event(self, event_type, event_details) + @event_dispatcher.dispatch_event(self, event_type, details) end def event_dispatcher_attached? diff --git a/spec/open_feature/sdk/provider/event_handler_spec.rb b/spec/open_feature/sdk/provider/event_handler_spec.rb index f0b1bf7e..643502e8 100644 --- a/spec/open_feature/sdk/provider/event_handler_spec.rb +++ b/spec/open_feature/sdk/provider/event_handler_spec.rb @@ -60,7 +60,7 @@ def name expect(event_dispatcher).to receive(:dispatch_event).with( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_READY, - hash_including(provider: provider) + {} ) provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) @@ -72,7 +72,7 @@ def name expect(event_dispatcher).to receive(:dispatch_event).with( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_READY, - hash_including(provider: provider, message: 'Provider is ready', custom_field: 'value') + { message: 'Provider is ready', custom_field: 'value' } ) provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, custom_details) @@ -95,7 +95,7 @@ def name expect(event_dispatcher).to receive(:dispatch_event).with( provider, event_type, - hash_including(provider: provider) + {} ) end From 6eefe785656c27583334772b3ea79ac72ddba74c Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 02:37:04 -0800 Subject: [PATCH 10/36] fix: address third round of Gemini code review feedback - Fix race conditions in set_provider_and_wait by adding revert_provider_if_current method that only reverts if the current provider matches the one being set - Improve encapsulation by adding attr_writer :logger to EventEmitter instead of using instance_variable_set - Add logging for provider shutdown errors for better debugging visibility - Update test coverage in PR description to 98.73% (468/474 LOC) Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 27 +++++++++++++++------------ lib/open_feature/sdk/event_emitter.rb | 2 ++ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index d9b064e0..9078978b 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -35,7 +35,7 @@ def provider(domain: nil) def logger=(new_logger) @logger = new_logger - @event_emitter.instance_variable_set(:@logger, new_logger) if @event_emitter + @event_emitter.logger = new_logger if @event_emitter end def add_handler(event_type, handler) @@ -52,7 +52,8 @@ def set_provider(provider, domain: nil) begin old_provider.shutdown if old_provider.respond_to?(:shutdown) - rescue StandardError + rescue StandardError => e + @logger&.warn("Error shutting down previous provider #{old_provider.class.name}: #{e.message}") end new_providers = @providers.dup @@ -124,11 +125,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) result = completion_queue.pop if result[:status] == :error - @provider_mutex.synchronize do - new_providers = @providers.dup - new_providers[domain] = old_provider - @providers = new_providers - end + revert_provider_if_current(domain, provider, old_provider) raise ProviderInitializationError.new( "Provider initialization failed: #{result[:message]}", @@ -139,11 +136,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) end end rescue Timeout::Error => e - @provider_mutex.synchronize do - new_providers = @providers.dup - new_providers[domain] = old_provider - @providers = new_providers - end + revert_provider_if_current(domain, provider, old_provider) raise ProviderInitializationError.new( "Provider initialization timed out after #{timeout} seconds", @@ -158,6 +151,16 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) private + def revert_provider_if_current(domain, provider, old_provider) + @provider_mutex.synchronize do + if @providers[domain] == provider + new_providers = @providers.dup + new_providers[domain] = old_provider + @providers = new_providers + end + end + end + def dispatch_provider_event(provider, event_type, details = {}) @provider_state_registry.update_state_from_event(provider, event_type, details) diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb index 06dd1fc6..b3a35b1e 100644 --- a/lib/open_feature/sdk/event_emitter.rb +++ b/lib/open_feature/sdk/event_emitter.rb @@ -6,6 +6,8 @@ module OpenFeature module SDK # Thread-safe pub-sub for provider events class EventEmitter + attr_writer :logger + def initialize(logger = nil) @handlers = {} @mutex = Mutex.new From ce8d3f10555b0e44c046b7b288da4f0b848d7555 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 07:32:51 -0800 Subject: [PATCH 11/36] refactor: use dedicated ProviderEventDispatcher class - Replace dynamic Object creation with singleton methods with a proper private inner class - Improves code clarity, maintainability, and performance - Follows Ruby idioms for encapsulation - Suggested by Gemini code review Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 9078978b..7f51cc97 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -63,12 +63,7 @@ def set_provider(provider, domain: nil) @provider_state_registry.set_initial_state(provider) if provider.is_a?(Provider::EventHandler) - config = self - dispatcher = Object.new - dispatcher.define_singleton_method(:dispatch_event) do |prov, event_type, details| - config.send(:dispatch_provider_event, prov, event_type, details) - end - provider.attach(dispatcher) + provider.attach(ProviderEventDispatcher.new(self)) end Thread.new do @@ -172,6 +167,17 @@ def dispatch_provider_event(provider, event_type, details = {}) @event_emitter.trigger_event(event_type, event_details) end + + # Private inner class for dispatching provider events + class ProviderEventDispatcher + def initialize(config) + @config = config + end + + def dispatch_event(provider, event_type, details) + @config.send(:dispatch_provider_event, provider, event_type, details) + end + end end end end From 700ad79bbf34e5e32aef20dcea7ccb405a1dd64b Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 08:09:04 -0800 Subject: [PATCH 12/36] refactor: remove EventAwareNoOpProvider and reorganize compatibility tests - Remove EventAwareNoOpProvider as it doesn't exist in other OpenFeature SDKs - Rename provider_backward_compatibility_spec.rb to provider_compatibility_spec.rb - Reorganize tests into three logical sections: - Providers Without Event Capabilities - Mixed Provider Usage - Provider Interface Detection - Update all references and imports accordingly - Maintain 99.13% test coverage Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 8 +- lib/open_feature/sdk/provider.rb | 1 - .../provider/event_aware_no_op_provider.rb | 41 ------ .../sdk/provider/event_handler.rb | 2 +- .../provider_backward_compatibility_spec.rb | 118 ------------------ .../sdk/provider_compatibility_spec.rb | 90 +++++++++++++ 6 files changed, 96 insertions(+), 164 deletions(-) delete mode 100644 lib/open_feature/sdk/provider/event_aware_no_op_provider.rb delete mode 100644 spec/open_feature/sdk/provider_backward_compatibility_spec.rb create mode 100644 spec/open_feature/sdk/provider_compatibility_spec.rb diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 7f51cc97..ba081b56 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -122,11 +122,13 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) if result[:status] == :error revert_provider_if_current(domain, provider, old_provider) + error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL + message = result[:message] raise ProviderInitializationError.new( - "Provider initialization failed: #{result[:message]}", + "Provider initialization failed: #{message}", provider: provider, - error_code: result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL, - original_error: ProviderInitializationFailure.new(result[:message], result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL) + error_code: error_code, + original_error: ProviderInitializationFailure.new(message, error_code) ) end end diff --git a/lib/open_feature/sdk/provider.rb b/lib/open_feature/sdk/provider.rb index 2e1c368b..6a0641cf 100644 --- a/lib/open_feature/sdk/provider.rb +++ b/lib/open_feature/sdk/provider.rb @@ -10,7 +10,6 @@ # Provider implementations require_relative "provider/no_op_provider" require_relative "provider/in_memory_provider" -require_relative "provider/event_aware_no_op_provider" # Event system components require_relative "provider_event" diff --git a/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb b/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb deleted file mode 100644 index 045e05cf..00000000 --- a/lib/open_feature/sdk/provider/event_aware_no_op_provider.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require_relative 'no_op_provider' -require_relative 'state_handler' -require_relative 'event_handler' - -module OpenFeature - module SDK - module Provider - # EventAwareNoOpProvider extends NoOpProvider with event support. - # This demonstrates how providers can implement the new interfaces - # while maintaining backward compatibility. - # - # This provider: - # - Implements StateHandler for initialization/shutdown - # - Implements EventHandler for event emission - # - Emits PROVIDER_READY immediately on init - # - Returns default values like NoOpProvider - class EventAwareNoOpProvider < NoOpProvider - include StateHandler - include EventHandler - - def init(evaluation_context) - # NoOp provider initializes instantly - # In a real provider, this might connect to a service - emit_event(ProviderEvent::PROVIDER_READY, message: "NoOp provider initialized") - rescue StandardError => e - emit_event(ProviderEvent::PROVIDER_ERROR, - message: "Failed to initialize: #{e.message}", - error_code: 'INITIALIZATION_ERROR') - raise - end - - def shutdown - # NoOp provider has nothing to cleanup - # In a real provider, this might close connections - end - end - end - end -end diff --git a/lib/open_feature/sdk/provider/event_handler.rb b/lib/open_feature/sdk/provider/event_handler.rb index 3aec661e..e9b1f26e 100644 --- a/lib/open_feature/sdk/provider/event_handler.rb +++ b/lib/open_feature/sdk/provider/event_handler.rb @@ -18,7 +18,7 @@ def detach def emit_event(event_type, details = {}) return unless @event_dispatcher - unless ProviderEvent::ALL_EVENTS.include?(event_type) + unless ::OpenFeature::SDK::ProviderEvent::ALL_EVENTS.include?(event_type) raise ArgumentError, "Invalid event type: #{event_type}" end diff --git a/spec/open_feature/sdk/provider_backward_compatibility_spec.rb b/spec/open_feature/sdk/provider_backward_compatibility_spec.rb deleted file mode 100644 index a7c3f74f..00000000 --- a/spec/open_feature/sdk/provider_backward_compatibility_spec.rb +++ /dev/null @@ -1,118 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'open_feature/sdk/provider/no_op_provider' -require 'open_feature/sdk/provider/in_memory_provider' -require 'open_feature/sdk/provider/event_aware_no_op_provider' - -RSpec.describe 'Provider Backward Compatibility' do - describe 'Existing NoOpProvider' do - let(:provider) { OpenFeature::SDK::Provider::NoOpProvider.new } - - it 'continues to work without implementing new interfaces' do - expect { provider.fetch_boolean_value(flag_key: 'test', default_value: true) }.not_to raise_error - end - - it 'does not respond to new interface methods' do - expect(provider).not_to respond_to(:attach) - expect(provider).not_to respond_to(:detach) - expect(provider).not_to respond_to(:emit_event) - end - - it 'does not respond to init or shutdown' do - expect(provider).not_to respond_to(:init) - expect(provider).not_to respond_to(:shutdown) - end - end - - describe 'Existing InMemoryProvider' do - let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } - - it 'continues to work with existing init/shutdown methods' do - expect { provider.init }.not_to raise_error - expect { provider.shutdown }.not_to raise_error - end - - it 'does not automatically gain event capabilities' do - expect(provider).not_to respond_to(:attach) - expect(provider).not_to respond_to(:emit_event) - end - - it 'fetch methods continue to work' do - provider = OpenFeature::SDK::Provider::InMemoryProvider.new( - 'test-flag' => true - ) - - result = provider.fetch_boolean_value(flag_key: 'test-flag', default_value: false) - expect(result.value).to be true - end - end - - describe 'EventAwareNoOpProvider' do - let(:provider) { OpenFeature::SDK::Provider::EventAwareNoOpProvider.new } - - it 'inherits NoOpProvider functionality' do - result = provider.fetch_boolean_value(flag_key: 'test', default_value: true) - expect(result.value).to be true - expect(result.reason).to eq('No-op') - end - - it 'adds StateHandler capabilities' do - expect(provider).to respond_to(:init) - expect(provider).to respond_to(:shutdown) - end - - it 'adds EventHandler capabilities' do - expect(provider).to respond_to(:attach) - expect(provider).to respond_to(:detach) - expect(provider).to respond_to(:emit_event) - end - - it 'emits events when initialized' do - dispatcher = double('dispatcher') - provider.attach(dispatcher) - - expect(dispatcher).to receive(:dispatch_event).with( - provider, - OpenFeature::SDK::ProviderEvent::PROVIDER_READY, - hash_including(message: 'NoOp provider initialized') - ) - - provider.init({}) - end - end - - describe 'Mixed provider usage' do - it 'can use old and new providers together' do - old_provider = OpenFeature::SDK::Provider::NoOpProvider.new - new_provider = OpenFeature::SDK::Provider::EventAwareNoOpProvider.new - - # Both should work for fetching values - old_result = old_provider.fetch_string_value(flag_key: 'test', default_value: 'old') - new_result = new_provider.fetch_string_value(flag_key: 'test', default_value: 'new') - - expect(old_result.value).to eq('old') - expect(new_result.value).to eq('new') - end - end - - describe 'Provider interface detection' do - it 'can check if provider implements StateHandler' do - old_provider = OpenFeature::SDK::Provider::NoOpProvider.new - new_provider = OpenFeature::SDK::Provider::EventAwareNoOpProvider.new - - # Check using respond_to? (Ruby way) - expect(old_provider.respond_to?(:init)).to be false - expect(new_provider.respond_to?(:init)).to be true - end - - it 'can check if provider implements EventHandler' do - old_provider = OpenFeature::SDK::Provider::NoOpProvider.new - new_provider = OpenFeature::SDK::Provider::EventAwareNoOpProvider.new - - # Check using is_a? with module - expect(old_provider.class.included_modules).not_to include(OpenFeature::SDK::Provider::EventHandler) - expect(new_provider.class.included_modules).to include(OpenFeature::SDK::Provider::EventHandler) - end - end -end diff --git a/spec/open_feature/sdk/provider_compatibility_spec.rb b/spec/open_feature/sdk/provider_compatibility_spec.rb new file mode 100644 index 00000000..5cdfc718 --- /dev/null +++ b/spec/open_feature/sdk/provider_compatibility_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open_feature/sdk/provider/no_op_provider' +require 'open_feature/sdk/provider/in_memory_provider' + +RSpec.describe 'Providers Without Event Capabilities' do + describe 'NoOpProvider without event capabilities' do + let(:provider) { OpenFeature::SDK::Provider::NoOpProvider.new } + + it 'continues to work without implementing new interfaces' do + expect { provider.fetch_boolean_value(flag_key: 'test', default_value: true) }.not_to raise_error + end + + it 'does not respond to new interface methods' do + expect(provider).not_to respond_to(:attach) + expect(provider).not_to respond_to(:detach) + expect(provider).not_to respond_to(:emit_event) + end + + it 'does not respond to init or shutdown' do + expect(provider).not_to respond_to(:init) + expect(provider).not_to respond_to(:shutdown) + end + end + + describe 'InMemoryProvider without event capabilities' do + let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } + + it 'continues to work with existing init/shutdown methods' do + expect { provider.init }.not_to raise_error + expect { provider.shutdown }.not_to raise_error + end + + it 'does not automatically gain event capabilities' do + expect(provider).not_to respond_to(:attach) + expect(provider).not_to respond_to(:emit_event) + end + + it 'fetch methods continue to work' do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new( + 'test-flag' => true + ) + + result = provider.fetch_boolean_value(flag_key: 'test-flag', default_value: false) + expect(result.value).to be true + end + end + +end + +RSpec.describe 'Mixed Provider Usage' do + it 'can use different provider types together' do + noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new + inmemory_provider = OpenFeature::SDK::Provider::InMemoryProvider.new + + # Both should work for fetching values + noop_result = noop_provider.fetch_string_value(flag_key: 'test', default_value: 'noop') + inmemory_result = inmemory_provider.fetch_string_value(flag_key: 'test', default_value: 'memory') + + expect(noop_result.value).to eq('noop') + expect(inmemory_result.value).to eq('memory') + end +end + +RSpec.describe 'Provider Interface Detection' do + # Create a test provider that implements the new interfaces + let(:event_capable_provider) do + Class.new(OpenFeature::SDK::Provider::InMemoryProvider) do + include OpenFeature::SDK::Provider::EventHandler + end.new + end + + it 'can check if provider implements StateHandler' do + noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new + inmemory_provider = OpenFeature::SDK::Provider::InMemoryProvider.new + + # Check using respond_to? (Ruby way) + expect(noop_provider.respond_to?(:init)).to be false + expect(inmemory_provider.respond_to?(:init)).to be true + end + + it 'can check if provider implements EventHandler' do + noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new + + # Check using is_a? with module + expect(noop_provider.class.included_modules).not_to include(OpenFeature::SDK::Provider::EventHandler) + expect(event_capable_provider.class.included_modules).to include(OpenFeature::SDK::Provider::EventHandler) + end +end From 2fa7865e051bb02f5ad4a1b2cb96f5e26e6b2e88 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 08:29:49 -0800 Subject: [PATCH 13/36] fix: address race conditions in provider lifecycle management - Fix race condition in set_provider_and_wait where stale provider could be restored - Modified set_provider to return the old provider atomically - set_provider_and_wait now uses the atomically-retrieved old provider - Fix race condition in EventHandler#emit_event - Store @event_dispatcher in local variable before check and use - Prevents NoMethodError if detach is called between check and method call These changes ensure thread-safe operation under concurrent access patterns. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 10 ++++++---- lib/open_feature/sdk/provider/event_handler.rb | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index ba081b56..771f0e7f 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -47,6 +47,8 @@ def remove_handler(event_type, handler) end def set_provider(provider, domain: nil) + old_provider = nil + @provider_mutex.synchronize do old_provider = @providers[domain] @@ -86,12 +88,11 @@ def set_provider(provider, domain: nil) end end end + + old_provider end def set_provider_and_wait(provider, domain: nil, timeout: 30) - old_provider = nil - @provider_mutex.synchronize { old_provider = @providers[domain] } - completion_queue = Queue.new ready_handler = lambda do |event_details| @@ -114,7 +115,8 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) begin - set_provider(provider, domain: domain) + # set_provider now returns the old provider atomically + old_provider = set_provider(provider, domain: domain) Timeout.timeout(timeout) do result = completion_queue.pop diff --git a/lib/open_feature/sdk/provider/event_handler.rb b/lib/open_feature/sdk/provider/event_handler.rb index e9b1f26e..092d5eed 100644 --- a/lib/open_feature/sdk/provider/event_handler.rb +++ b/lib/open_feature/sdk/provider/event_handler.rb @@ -16,13 +16,14 @@ def detach end def emit_event(event_type, details = {}) - return unless @event_dispatcher + dispatcher = @event_dispatcher + return unless dispatcher unless ::OpenFeature::SDK::ProviderEvent::ALL_EVENTS.include?(event_type) raise ArgumentError, "Invalid event type: #{event_type}" end - @event_dispatcher.dispatch_event(self, event_type, details) + dispatcher.dispatch_event(self, event_type, details) end def event_dispatcher_attached? From fc88010857626a69c35e45b38b0d23b520513888 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 08:49:14 -0800 Subject: [PATCH 14/36] refactor: improve API structure and fix nil provider handling Critical fixes: - Fix NoMethodError when old_provider is nil during shutdown - Use safe navigation operators (&.) for nil provider handling - Add test coverage for setting provider to domain with no previous provider API improvements: - Refactor API class to follow OpenFeature SDK patterns - Replace long def_delegators list with explicit methods - Add clear_all_handlers method for improved test cleanup - Align with Go SDK's individual function approach vs bulk delegation All tests passing with 98.74% coverage maintained. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/api.rb | 25 ++++++++++++++++++++- lib/open_feature/sdk/configuration.rb | 8 +++++-- spec/open_feature/sdk/configuration_spec.rb | 7 ++++++ spec/specification/events_spec.rb | 15 +------------ 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/lib/open_feature/sdk/api.rb b/lib/open_feature/sdk/api.rb index 0b0b8d77..de510e26 100644 --- a/lib/open_feature/sdk/api.rb +++ b/lib/open_feature/sdk/api.rb @@ -32,7 +32,7 @@ class API include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1 extend Forwardable - def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :evaluation_context, :add_handler, :remove_handler, :logger, :logger= + def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :evaluation_context def configuration @configuration ||= Configuration.new @@ -51,6 +51,29 @@ def build_client(domain: nil, evaluation_context: nil) rescue Client.new(provider: Provider::NoOpProvider.new, evaluation_context:) end + + # Event handling methods + def add_handler(event_type, handler) + configuration.add_handler(event_type, handler) + end + + def remove_handler(event_type, handler) + configuration.remove_handler(event_type, handler) + end + + # Configuration methods + def logger + configuration.logger + end + + def logger=(new_logger) + configuration.logger = new_logger + end + + # Internal utility for testing - not part of OpenFeature spec + def clear_all_handlers + configuration.clear_all_handlers + end end end end diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 771f0e7f..82963376 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -46,6 +46,10 @@ def remove_handler(event_type, handler) @event_emitter.remove_handler(event_type, handler) end + def clear_all_handlers + @event_emitter.clear_all_handlers + end + def set_provider(provider, domain: nil) old_provider = nil @@ -53,9 +57,9 @@ def set_provider(provider, domain: nil) old_provider = @providers[domain] begin - old_provider.shutdown if old_provider.respond_to?(:shutdown) + old_provider.shutdown if old_provider&.respond_to?(:shutdown) rescue StandardError => e - @logger&.warn("Error shutting down previous provider #{old_provider.class.name}: #{e.message}") + @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || 'unknown'}: #{e.message}") end new_providers = @providers.dup diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index 81b5bf49..c5457d51 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -220,6 +220,13 @@ expect(configuration.provider).to be(provider) end + + it "handles setting provider to a domain with no previous provider" do + # This should not raise any errors even though old_provider will be nil + expect { configuration.set_provider_and_wait(provider, domain: "new-domain") }.not_to raise_error + + expect(configuration.provider(domain: "new-domain")).to be(provider) + end end end end diff --git a/spec/specification/events_spec.rb b/spec/specification/events_spec.rb index 73d477a6..0b23891b 100644 --- a/spec/specification/events_spec.rb +++ b/spec/specification/events_spec.rb @@ -10,20 +10,7 @@ # Remove all handlers after each test to avoid test pollution after(:each) do # Clean up any remaining handlers - [ - OpenFeature::SDK::ProviderEvent::PROVIDER_READY, - OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, - OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, - OpenFeature::SDK::ProviderEvent::PROVIDER_STALE - ].each do |event| - begin - # This is a bit hacky but we need to clean up - emitter = OpenFeature::SDK::API.instance.configuration.instance_variable_get(:@event_emitter) - emitter.instance_variable_get(:@handlers)[event].clear - rescue - # Ignore errors - end - end + OpenFeature::SDK::API.instance.clear_all_handlers end context "Requirement 5.1.1" do From d22ca3cfe60fc071e3aa8accb33e32f4fbf9fb2c Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 09:15:14 -0800 Subject: [PATCH 15/36] refactor: improve error messages and init parameter handling Address Gemini code review feedback: Error message improvements: - Include provider class names in all error messages for better debugging - 'Provider #{provider.class.name} initialization failed: #{message}' - 'Provider #{provider.class.name} initialization timed out after #{timeout} seconds' Init parameter handling improvements: - Replace fragile arity check with robust try/fallback approach - Always attempt init(evaluation_context) first (OpenFeature standard) - Fallback to init() only for legacy providers with 0-arity methods - Properly handle edge cases like providers expecting 2+ parameters - Update InMemoryProvider to accept optional evaluation_context parameter This ensures backward compatibility while aligning with OpenFeature spec and providing clear error messages for debugging multi-provider scenarios. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 16 +++++++++++----- .../sdk/provider/in_memory_provider.rb | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 82963376..d4af9b56 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -75,10 +75,16 @@ def set_provider(provider, domain: nil) Thread.new do begin if provider.respond_to?(:init) - if provider.method(:init).arity == 1 + begin provider.init(@evaluation_context) - else - provider.init + rescue ArgumentError => e + # Only fallback to no-args if it's specifically a "wrong number" error for 0-arity methods + if e.message =~ /wrong number of arguments.*given 1, expected 0/ + provider.init + else + # For any other ArgumentError (wrong types, too many params, etc), re-raise + raise e + end end end @@ -131,7 +137,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL message = result[:message] raise ProviderInitializationError.new( - "Provider initialization failed: #{message}", + "Provider #{provider.class.name} initialization failed: #{message}", provider: provider, error_code: error_code, original_error: ProviderInitializationFailure.new(message, error_code) @@ -142,7 +148,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) revert_provider_if_current(domain, provider, old_provider) raise ProviderInitializationError.new( - "Provider initialization timed out after #{timeout} seconds", + "Provider #{provider.class.name} initialization timed out after #{timeout} seconds", provider: provider, original_error: e ) diff --git a/lib/open_feature/sdk/provider/in_memory_provider.rb b/lib/open_feature/sdk/provider/in_memory_provider.rb index d5abd95e..3bfcb971 100644 --- a/lib/open_feature/sdk/provider/in_memory_provider.rb +++ b/lib/open_feature/sdk/provider/in_memory_provider.rb @@ -12,7 +12,7 @@ def initialize(flags = {}) @flags = flags end - def init + def init(evaluation_context = nil) # Intentional no-op, used for testing end From 2611c409a24f76cda1c66af8058716882707ba54 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 09:54:53 -0800 Subject: [PATCH 16/36] refactor: improve parameter inspection and simplify event details - Replace fragile error message matching with robust method parameter inspection - Simplify EventToStateMapper by removing EventDetails class and working with hashes directly - Standardize on symbol keys for event details since they are generated internally - Remove unnecessary string key support to reduce complexity Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 13 ++---- lib/open_feature/sdk/event_to_state_mapper.rb | 25 ++--------- .../sdk/event_to_state_mapper_spec.rb | 41 +++---------------- 3 files changed, 13 insertions(+), 66 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index d4af9b56..ee1b023f 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -75,16 +75,11 @@ def set_provider(provider, domain: nil) Thread.new do begin if provider.respond_to?(:init) - begin + init_method = provider.method(:init) + if init_method.parameters.empty? + provider.init + else provider.init(@evaluation_context) - rescue ArgumentError => e - # Only fallback to no-args if it's specifically a "wrong number" error for 0-arity methods - if e.message =~ /wrong number of arguments.*given 1, expected 0/ - provider.init - else - # For any other ArgumentError (wrong types, too many params, etc), re-raise - raise e - end end end diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb index 3fb01d33..b9f93e21 100644 --- a/lib/open_feature/sdk/event_to_state_mapper.rb +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -7,21 +7,13 @@ module OpenFeature module SDK # Maps provider events to provider states class EventToStateMapper - class EventDetails - attr_reader :message, :error_code - - def initialize(message: nil, error_code: nil) - @message = message - @error_code = error_code - end - end - STATE_MAPPING = { ProviderEvent::PROVIDER_READY => ProviderState::READY, ProviderEvent::PROVIDER_CONFIGURATION_CHANGED => ProviderState::READY, ProviderEvent::PROVIDER_STALE => ProviderState::STALE, ProviderEvent::PROVIDER_ERROR => lambda do |event_details| - if event_details&.error_code == 'PROVIDER_FATAL' + error_code = event_details&.dig(:error_code) + if error_code == 'PROVIDER_FATAL' ProviderState::FATAL else ProviderState::ERROR @@ -33,18 +25,7 @@ def self.state_from_event(event_type, event_details = nil) mapper = STATE_MAPPING[event_type] if mapper.respond_to?(:call) - details = case event_details - when EventDetails - event_details - when Hash - EventDetails.new( - message: event_details[:message] || event_details['message'], - error_code: event_details[:error_code] || event_details['error_code'] - ) - else - nil - end - mapper.call(details) + mapper.call(event_details) else mapper || ProviderState::NOT_READY end diff --git a/spec/open_feature/sdk/event_to_state_mapper_spec.rb b/spec/open_feature/sdk/event_to_state_mapper_spec.rb index 3a2185fb..b9d81590 100644 --- a/spec/open_feature/sdk/event_to_state_mapper_spec.rb +++ b/spec/open_feature/sdk/event_to_state_mapper_spec.rb @@ -35,20 +35,20 @@ end it 'returns ERROR state for non-fatal error' do - event_details = described_class::EventDetails.new( + event_details = { message: 'Connection failed', error_code: 'CONNECTION_ERROR' - ) + } state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) end it 'returns FATAL state for fatal error' do - event_details = described_class::EventDetails.new( + event_details = { message: 'Provider cannot recover', error_code: 'PROVIDER_FATAL' - ) + } state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) @@ -64,15 +64,6 @@ expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) end - it 'handles Hash with string keys' do - event_details_hash = { - 'message' => 'Provider cannot recover', - 'error_code' => 'PROVIDER_FATAL' - } - - state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details_hash) - expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) - end it 'handles nil event details gracefully' do state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, nil) @@ -89,26 +80,6 @@ end - describe 'EventDetails' do - describe '#initialize' do - it 'initializes with message and error_code' do - details = described_class::EventDetails.new( - message: 'Test message', - error_code: 'TEST_ERROR' - ) - - expect(details.message).to eq('Test message') - expect(details.error_code).to eq('TEST_ERROR') - end - - it 'initializes with nil values when not provided' do - details = described_class::EventDetails.new - - expect(details.message).to be_nil - expect(details.error_code).to be_nil - end - end - end describe 'STATE_MAPPING constant' do it 'is frozen to prevent modification' do @@ -143,8 +114,8 @@ # Test callable mappings (PROVIDER_ERROR) error_mapper = described_class::STATE_MAPPING[OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR] - fatal_state = error_mapper.call(described_class::EventDetails.new(error_code: 'PROVIDER_FATAL')) - error_state = error_mapper.call(described_class::EventDetails.new(error_code: 'SOME_ERROR')) + fatal_state = error_mapper.call({ error_code: 'PROVIDER_FATAL' }) + error_state = error_mapper.call({ error_code: 'SOME_ERROR' }) expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(fatal_state) expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(error_state) From e6e176a60137bc0aa0bbfa32d7081a8058dc56a4 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 11:32:18 -0800 Subject: [PATCH 17/36] refactor: replace PROVIDER_FATAL string literals with constants - Use Provider::ErrorCode::PROVIDER_FATAL constant instead of string literals - Add required import for error_code module in EventToStateMapper - Update all test files to use fully qualified constant names for consistency - Eliminates magic strings and improves maintainability Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/event_to_state_mapper.rb | 3 ++- spec/open_feature/sdk/configuration_async_spec.rb | 2 +- spec/open_feature/sdk/event_to_state_mapper_spec.rb | 4 ++-- spec/open_feature/sdk/provider_state_registry_spec.rb | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb index b9f93e21..8399e45b 100644 --- a/lib/open_feature/sdk/event_to_state_mapper.rb +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -2,6 +2,7 @@ require_relative 'provider_event' require_relative 'provider_state' +require_relative 'provider/error_code' module OpenFeature module SDK @@ -13,7 +14,7 @@ class EventToStateMapper ProviderEvent::PROVIDER_STALE => ProviderState::STALE, ProviderEvent::PROVIDER_ERROR => lambda do |event_details| error_code = event_details&.dig(:error_code) - if error_code == 'PROVIDER_FATAL' + if error_code == Provider::ErrorCode::PROVIDER_FATAL ProviderState::FATAL else ProviderState::ERROR diff --git a/spec/open_feature/sdk/configuration_async_spec.rb b/spec/open_feature/sdk/configuration_async_spec.rb index 077ae086..a2fe0155 100644 --- a/spec/open_feature/sdk/configuration_async_spec.rb +++ b/spec/open_feature/sdk/configuration_async_spec.rb @@ -60,7 +60,7 @@ def create_failing_provider(error_message = "Init failed") Thread.new do sleep(0.05) emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, - error_code: 'PROVIDER_FATAL', + error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL, message: error_message) end end diff --git a/spec/open_feature/sdk/event_to_state_mapper_spec.rb b/spec/open_feature/sdk/event_to_state_mapper_spec.rb index b9d81590..2424fb75 100644 --- a/spec/open_feature/sdk/event_to_state_mapper_spec.rb +++ b/spec/open_feature/sdk/event_to_state_mapper_spec.rb @@ -47,7 +47,7 @@ it 'returns FATAL state for fatal error' do event_details = { message: 'Provider cannot recover', - error_code: 'PROVIDER_FATAL' + error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL } state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) @@ -57,7 +57,7 @@ it 'handles Hash event details' do event_details_hash = { message: 'Provider cannot recover', - error_code: 'PROVIDER_FATAL' + error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL } state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details_hash) diff --git a/spec/open_feature/sdk/provider_state_registry_spec.rb b/spec/open_feature/sdk/provider_state_registry_spec.rb index e224a7a0..fa5bd560 100644 --- a/spec/open_feature/sdk/provider_state_registry_spec.rb +++ b/spec/open_feature/sdk/provider_state_registry_spec.rb @@ -51,7 +51,7 @@ new_state = registry.update_state_from_event( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, - { error_code: 'PROVIDER_FATAL' } + { error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL } ) expect(new_state).to eq(OpenFeature::SDK::ProviderState::FATAL) From fb37e18117a3546f0fd8c497f68f79e0436cc8af Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 11:44:04 -0800 Subject: [PATCH 18/36] improve: enhance error handling by propagating original exception object - Pass full exception object in provider initialization error events - Preserve stack traces and error context for better debugging - Align with Go/Java SDK error handling patterns that preserve full error information - Maintain backward compatibility by keeping both message and error fields - Improves developer experience when debugging provider initialization failures Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index ee1b023f..c4ed50fa 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -89,7 +89,8 @@ def set_provider(provider, domain: nil) rescue StandardError => e dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, error_code: Provider::ErrorCode::PROVIDER_FATAL, - message: e.message) + message: e.message, + error: e) end end end @@ -111,7 +112,8 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) completion_queue << { status: :error, message: event_details[:message] || "Provider initialization failed", - error_code: event_details[:error_code] + error_code: event_details[:error_code], + error: event_details[:error] } end end @@ -131,11 +133,12 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL message = result[:message] + original_error = result[:error] || ProviderInitializationFailure.new(message, error_code) raise ProviderInitializationError.new( "Provider #{provider.class.name} initialization failed: #{message}", provider: provider, error_code: error_code, - original_error: ProviderInitializationFailure.new(message, error_code) + original_error: original_error ) end end From 2d531a1428f432c0fd9db58ef7b6d6977474461c Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 11:57:26 -0800 Subject: [PATCH 19/36] fix: prevent memory leaks in provider state registry - Remove old provider states when providers are replaced to prevent memory accumulation - Clean up failed provider states during revert operations - Add nil guard to remove_provider method to handle edge cases gracefully - Restore old provider state during revert if it exists - Add test coverage for nil provider handling - Addresses potential memory leaks identified in code review Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 9 +++++++++ lib/open_feature/sdk/provider_state_registry.rb | 2 ++ spec/open_feature/sdk/provider_state_registry_spec.rb | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index c4ed50fa..e4ef0091 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -62,6 +62,9 @@ def set_provider(provider, domain: nil) @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || 'unknown'}: #{e.message}") end + # Remove old provider state to prevent memory leaks + @provider_state_registry.remove_provider(old_provider) + new_providers = @providers.dup new_providers[domain] = provider @providers = new_providers @@ -161,9 +164,15 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) def revert_provider_if_current(domain, provider, old_provider) @provider_mutex.synchronize do if @providers[domain] == provider + # Remove provider state (failed initialization) to prevent memory leaks + @provider_state_registry.remove_provider(provider) + new_providers = @providers.dup new_providers[domain] = old_provider @providers = new_providers + + # Restore old provider state if it exists + @provider_state_registry.set_initial_state(old_provider) if old_provider end end end diff --git a/lib/open_feature/sdk/provider_state_registry.rb b/lib/open_feature/sdk/provider_state_registry.rb index 3962e0d1..9e9e0e6d 100644 --- a/lib/open_feature/sdk/provider_state_registry.rb +++ b/lib/open_feature/sdk/provider_state_registry.rb @@ -36,6 +36,8 @@ def get_state(provider) end def remove_provider(provider) + return unless provider + @mutex.synchronize do @states.delete(provider.object_id) end diff --git a/spec/open_feature/sdk/provider_state_registry_spec.rb b/spec/open_feature/sdk/provider_state_registry_spec.rb index fa5bd560..76447b64 100644 --- a/spec/open_feature/sdk/provider_state_registry_spec.rb +++ b/spec/open_feature/sdk/provider_state_registry_spec.rb @@ -87,6 +87,10 @@ expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) end + + it 'handles nil provider gracefully' do + expect { registry.remove_provider(nil) }.not_to raise_error + end end describe '#ready?' do From fc2efaf939682c1b565127df3a59443cc5a86e27 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 12:10:35 -0800 Subject: [PATCH 20/36] fix: prevent race condition in async provider initialization - Capture evaluation context within synchronized block before creating thread - Ensure providers receive the context that was active when set_provider was called - Prevent race condition where global context changes could affect initialization - Add comprehensive test coverage for concurrent context modification scenarios - Ensures deterministic and thread-safe provider initialization behavior Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 4 ++- spec/open_feature/sdk/configuration_spec.rb | 37 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index e4ef0091..9635c2ea 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -75,6 +75,8 @@ def set_provider(provider, domain: nil) provider.attach(ProviderEventDispatcher.new(self)) end + # Capture evaluation context to prevent race condition + context_for_init = @evaluation_context Thread.new do begin if provider.respond_to?(:init) @@ -82,7 +84,7 @@ def set_provider(provider, domain: nil) if init_method.parameters.empty? provider.init else - provider.init(@evaluation_context) + provider.init(context_for_init) end end diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index c5457d51..7b2e7f39 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -228,5 +228,42 @@ expect(configuration.provider(domain: "new-domain")).to be(provider) end end + + context "when evaluation context changes during async initialization" do + let(:initial_context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "initial") } + let(:changed_context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "changed") } + let(:context_capturing_provider) do + Class.new do + attr_reader :received_context + + def init(context = nil) + @received_context = context + # Simulate slow initialization + sleep(0.1) + end + + def metadata + OpenFeature::SDK::Provider::ProviderMetadata.new(name: "ContextCapturingProvider") + end + end.new + end + + it "uses the evaluation context that was set when set_provider was called" do + configuration.evaluation_context = initial_context + + # Start provider initialization (async) + configuration.set_provider(context_capturing_provider) + + # Change global context immediately after + configuration.evaluation_context = changed_context + + # Wait for initialization to complete + sleep(0.2) + + # Provider should have received the initial context, not the changed one + expect(context_capturing_provider.received_context).to eq(initial_context) + expect(context_capturing_provider.received_context).not_to eq(changed_context) + end + end end end From 4cdc814dada6bc94ad8d4a8c4b04795005e7e9b7 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 12:31:09 -0800 Subject: [PATCH 21/36] improve: enhance diagnostics and robustness of provider eventing system - Include full backtraces in event handler error logs for better debugging - Add nil guards to all provider state registry public methods - Ensure robust handling of edge cases where providers might be nil - Add comprehensive test coverage for nil provider scenarios - Aligns error logging with Java/Python SDK patterns that include stack traces - Improves overall system resilience and debugging capabilities Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/event_emitter.rb | 2 +- .../sdk/provider_state_registry.rb | 6 +++++ .../sdk/provider_state_registry_spec.rb | 24 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb index b3a35b1e..79adb00b 100644 --- a/lib/open_feature/sdk/event_emitter.rb +++ b/lib/open_feature/sdk/event_emitter.rb @@ -54,7 +54,7 @@ def trigger_event(event_type, event_details = {}) handler.call(event_details) rescue StandardError => e if @logger - @logger.warn "Event handler failed for #{event_type}: #{e.message}" + @logger.warn "Event handler failed for #{event_type}: #{e.message}\n#{e.backtrace.join("\n")}" end end end diff --git a/lib/open_feature/sdk/provider_state_registry.rb b/lib/open_feature/sdk/provider_state_registry.rb index 9e9e0e6d..4b8ee372 100644 --- a/lib/open_feature/sdk/provider_state_registry.rb +++ b/lib/open_feature/sdk/provider_state_registry.rb @@ -14,12 +14,16 @@ def initialize end def set_initial_state(provider, state = ProviderState::NOT_READY) + return unless provider + @mutex.synchronize do @states[provider.object_id] = state end end def update_state_from_event(provider, event_type, event_details = nil) + return ProviderState::NOT_READY unless provider + new_state = EventToStateMapper.state_from_event(event_type, event_details) @mutex.synchronize do @@ -30,6 +34,8 @@ def update_state_from_event(provider, event_type, event_details = nil) end def get_state(provider) + return ProviderState::NOT_READY unless provider + @mutex.synchronize do @states[provider.object_id] || ProviderState::NOT_READY end diff --git a/spec/open_feature/sdk/provider_state_registry_spec.rb b/spec/open_feature/sdk/provider_state_registry_spec.rb index 76447b64..a464663c 100644 --- a/spec/open_feature/sdk/provider_state_registry_spec.rb +++ b/spec/open_feature/sdk/provider_state_registry_spec.rb @@ -93,6 +93,30 @@ end end + describe 'nil provider handling' do + it 'set_initial_state handles nil provider gracefully' do + expect { registry.set_initial_state(nil) }.not_to raise_error + end + + it 'update_state_from_event handles nil provider gracefully' do + result = registry.update_state_from_event(nil, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + expect(result).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it 'get_state handles nil provider gracefully' do + state = registry.get_state(nil) + expect(state).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it 'ready? handles nil provider gracefully' do + expect(registry.ready?(nil)).to be false + end + + it 'error? handles nil provider gracefully' do + expect(registry.error?(nil)).to be false + end + end + describe '#ready?' do it 'returns true when provider is READY' do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) From f858bfb252afd844b63cad46ea318b574bce11d1 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 12:42:32 -0800 Subject: [PATCH 22/36] fix: correct semantically incorrect original_error assignment The original_error attribute should only contain actual underlying exceptions, not manufactured ones. When result[:error] is nil, original_error should also be nil rather than creating a new ProviderInitializationFailure instance. This ensures semantic correctness where original_error represents the true cause of initialization failure. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 9635c2ea..a585a851 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -138,7 +138,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL message = result[:message] - original_error = result[:error] || ProviderInitializationFailure.new(message, error_code) + original_error = result[:error] raise ProviderInitializationError.new( "Provider #{provider.class.name} initialization failed: #{message}", provider: provider, From ca59659f09062894c2bfac7be29df3f23b008571 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 13:41:24 -0800 Subject: [PATCH 23/36] fix: remove problematic provider reversion logic and dead code - Remove revert_provider_if_current method that attempted to reuse shutdown providers - Simplify set_provider to no longer return old_provider since it's not needed - Remove unused ProviderInitializationFailure class (dead code) - Update tests to reflect correct behavior: failed providers remain in place with error state - Align with OpenFeature specification and other SDK implementations (Go/Java/Python) When provider initialization fails, the failed provider now remains active in an error state rather than reverting to the old provider. This provides: - Better consistency with provider state management - Alignment with other OpenFeature SDK implementations - More predictable behavior for applications handling provider failures Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 26 +------------------ .../sdk/provider_initialization_error.rb | 9 ------- spec/open_feature/sdk/configuration_spec.rb | 8 +++--- spec/specification/provider_spec.rb | 4 +-- 4 files changed, 7 insertions(+), 40 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index a585a851..9a94dec3 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -51,8 +51,6 @@ def clear_all_handlers end def set_provider(provider, domain: nil) - old_provider = nil - @provider_mutex.synchronize do old_provider = @providers[domain] @@ -99,8 +97,6 @@ def set_provider(provider, domain: nil) end end end - - old_provider end def set_provider_and_wait(provider, domain: nil, timeout: 30) @@ -127,15 +123,12 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) begin - # set_provider now returns the old provider atomically - old_provider = set_provider(provider, domain: domain) + set_provider(provider, domain: domain) Timeout.timeout(timeout) do result = completion_queue.pop if result[:status] == :error - revert_provider_if_current(domain, provider, old_provider) - error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL message = result[:message] original_error = result[:error] @@ -148,8 +141,6 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) end end rescue Timeout::Error => e - revert_provider_if_current(domain, provider, old_provider) - raise ProviderInitializationError.new( "Provider #{provider.class.name} initialization timed out after #{timeout} seconds", provider: provider, @@ -163,21 +154,6 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) private - def revert_provider_if_current(domain, provider, old_provider) - @provider_mutex.synchronize do - if @providers[domain] == provider - # Remove provider state (failed initialization) to prevent memory leaks - @provider_state_registry.remove_provider(provider) - - new_providers = @providers.dup - new_providers[domain] = old_provider - @providers = new_providers - - # Restore old provider state if it exists - @provider_state_registry.set_initial_state(old_provider) if old_provider - end - end - end def dispatch_provider_event(provider, event_type, details = {}) @provider_state_registry.update_state_from_event(provider, event_type, details) diff --git a/lib/open_feature/sdk/provider_initialization_error.rb b/lib/open_feature/sdk/provider_initialization_error.rb index 27436262..7ee36d1d 100644 --- a/lib/open_feature/sdk/provider_initialization_error.rb +++ b/lib/open_feature/sdk/provider_initialization_error.rb @@ -4,15 +4,6 @@ module OpenFeature module SDK - # Internal error class that captures provider initialization failure details - class ProviderInitializationFailure < StandardError - attr_reader :error_code - - def initialize(message, error_code) - super(message) - @error_code = error_code - end - end # Exception raised when a provider fails to initialize during setProviderAndWait # diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index 7b2e7f39..09b988b4 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -125,14 +125,14 @@ end end - it "does not set the provider when init fails" do + it "leaves the failed provider in place when init fails" do old_provider = configuration.provider expect do configuration.set_provider_and_wait(provider) end.to raise_error(OpenFeature::SDK::ProviderInitializationError) - expect(configuration.provider).to be(old_provider) + expect(configuration.provider).to be(provider) end end @@ -156,14 +156,14 @@ end end - it "does not set the provider when init times out" do + it "leaves the failed provider in place when init times out" do old_provider = configuration.provider expect do configuration.set_provider_and_wait(provider, timeout: 0.1) end.to raise_error(OpenFeature::SDK::ProviderInitializationError) - expect(configuration.provider).to be(old_provider) + expect(configuration.provider).to be(provider) end end diff --git a/spec/specification/provider_spec.rb b/spec/specification/provider_spec.rb index 201cef0a..bd019aa4 100644 --- a/spec/specification/provider_spec.rb +++ b/spec/specification/provider_spec.rb @@ -64,8 +64,8 @@ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) OpenFeature::SDK.set_provider_and_wait(failing_provider) }.to raise_error(OpenFeature::SDK::ProviderInitializationError) - # The old provider should still be in place - expect(OpenFeature::SDK.provider).to eq(old_provider) + # The failing provider should remain in place (with error state) + expect(OpenFeature::SDK.provider).to eq(failing_provider) end end end From e0e354cb8849297846b8a586035866a841492e1b Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 15:56:15 -0800 Subject: [PATCH 24/36] fix: address code review feedback on exception handling and test stability - Remove exception objects from PROVIDER_ERROR events to align with other SDKs - Fix brittle tests by adding private helper methods instead of accessing instance variables - Update README to clarify original_error behavior with timeout vs event errors - Simplify inline comment about nil original_error These changes ensure the Ruby SDK follows the same patterns as other OpenFeature SDKs by not exposing internal exception details through the event system while maintaining proper error information for debugging. Signed-off-by: Sameeran Kunche --- README.md | 6 +++-- lib/open_feature/sdk/configuration.rb | 23 ++++++++++------ .../sdk/configuration_async_spec.rb | 26 +++++++------------ spec/open_feature/sdk/configuration_spec.rb | 3 +-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 959bf9b6..c9e566cc 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,8 @@ begin rescue OpenFeature::SDK::ProviderInitializationError => e puts "Provider failed to initialize: #{e.message}" puts "Error code: #{e.error_code}" - puts "Original error: #{e.original_error}" + # Note: original_error may be nil for errors from provider events + puts "Original error: #{e.original_error}" if e.original_error end # With custom timeout (default is 30 seconds) @@ -164,7 +165,8 @@ end The `set_provider_and_wait` method: - Waits for the provider's `init` method to complete successfully - Raises `ProviderInitializationError` with `PROVIDER_FATAL` error code if initialization fails or times out -- Provides access to the original error, provider instance, and error code for debugging +- Provides access to the provider instance and error code for debugging +- The `original_error` field contains the underlying exception for timeout errors, but may be `nil` for errors that occur through the provider event system (aligning with other OpenFeature SDKs) - Uses the same thread-safe provider switching as `set_provider` In some situations, it may be beneficial to register multiple providers in the same application. diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 9a94dec3..b5ecc609 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -92,8 +92,7 @@ def set_provider(provider, domain: nil) rescue StandardError => e dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, error_code: Provider::ErrorCode::PROVIDER_FATAL, - message: e.message, - error: e) + message: e.message) end end end @@ -113,8 +112,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) completion_queue << { status: :error, message: event_details[:message] || "Provider initialization failed", - error_code: event_details[:error_code], - error: event_details[:error] + error_code: event_details[:error_code] } end end @@ -131,12 +129,11 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) if result[:status] == :error error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL message = result[:message] - original_error = result[:error] raise ProviderInitializationError.new( "Provider #{provider.class.name} initialization failed: #{message}", provider: provider, error_code: error_code, - original_error: original_error + original_error: nil # Exceptions not included in events ) end end @@ -154,7 +151,6 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) private - def dispatch_provider_event(provider, event_type, details = {}) @provider_state_registry.update_state_from_event(provider, event_type, details) @@ -167,7 +163,18 @@ def dispatch_provider_event(provider, event_type, details = {}) @event_emitter.trigger_event(event_type, event_details) end - # Private inner class for dispatching provider events + def handler_count(event_type) + @event_emitter.handler_count(event_type) + end + + def total_handler_count + ProviderEvent::ALL_EVENTS.sum { |event_type| handler_count(event_type) } + end + + def provider_state(provider) + @provider_state_registry.get_state(provider) + end + class ProviderEventDispatcher def initialize(config) @config = config diff --git a/spec/open_feature/sdk/configuration_async_spec.rb b/spec/open_feature/sdk/configuration_async_spec.rb index a2fe0155..3c7883c9 100644 --- a/spec/open_feature/sdk/configuration_async_spec.rb +++ b/spec/open_feature/sdk/configuration_async_spec.rb @@ -199,18 +199,14 @@ def shutdown provider = create_slow_provider(init_time: 0.05) # Get initial handler count - initial_ready_count = configuration.instance_variable_get(:@event_emitter) - .instance_variable_get(:@handlers)[OpenFeature::SDK::ProviderEvent::PROVIDER_READY].size - initial_error_count = configuration.instance_variable_get(:@event_emitter) - .instance_variable_get(:@handlers)[OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR].size + initial_ready_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + initial_error_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) configuration.set_provider_and_wait(provider, timeout: 1) # Handler counts should be back to initial - final_ready_count = configuration.instance_variable_get(:@event_emitter) - .instance_variable_get(:@handlers)[OpenFeature::SDK::ProviderEvent::PROVIDER_READY].size - final_error_count = configuration.instance_variable_get(:@event_emitter) - .instance_variable_get(:@handlers)[OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR].size + final_ready_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + final_error_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) expect(final_ready_count).to eq(initial_ready_count) expect(final_error_count).to eq(initial_error_count) @@ -220,16 +216,14 @@ def shutdown provider = create_failing_provider # Get initial handler count - initial_count = configuration.instance_variable_get(:@event_emitter) - .instance_variable_get(:@handlers).values.sum(&:size) + initial_count = configuration.send(:total_handler_count) expect { configuration.set_provider_and_wait(provider, timeout: 1) }.to raise_error(OpenFeature::SDK::ProviderInitializationError) # Handler count should be back to initial - final_count = configuration.instance_variable_get(:@event_emitter) - .instance_variable_get(:@handlers).values.sum(&:size) + final_count = configuration.send(:total_handler_count) expect(final_count).to eq(initial_count) end @@ -239,26 +233,24 @@ def shutdown describe "provider state tracking" do it "tracks provider state transitions" do provider = create_slow_provider(init_time: 0.05) - state_registry = configuration.instance_variable_get(:@provider_state_registry) # Initially NOT_READY configuration.set_provider(provider) - expect(state_registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) # Wait for initialization sleep(0.1) - expect(state_registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::READY) end it "tracks error states" do provider = create_failing_provider - state_registry = configuration.instance_variable_get(:@provider_state_registry) configuration.set_provider(provider) # Wait for initialization sleep(0.1) - expect(state_registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) + expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) end end diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index 09b988b4..70a4d6ea 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -119,8 +119,7 @@ expect(error.message).to include("Provider initialization failed") expect(error.message).to include(error_message) expect(error.provider).to be(provider) - expect(error.original_error).to be_a(StandardError) - expect(error.original_error.message).to eq(error_message) + expect(error.original_error).to be_nil # Provider init errors come through events, so no original exception expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL) end end From 8a13c74e3e8a8b702bf3a95f9e9075d2699d7010 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 16:05:53 -0800 Subject: [PATCH 25/36] fix: add thread-safety to set_provider_and_wait method Add mutex synchronization to prevent race conditions when set_provider_and_wait is called concurrently from multiple threads. This ensures only one thread can execute the method at a time. Signed-off-by: Sameeran Kunche Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 85 ++++++++++++++------------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index b5ecc609..6d598f20 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -24,6 +24,7 @@ def initialize @hooks = [] @providers = {} @provider_mutex = Mutex.new + @provider_wait_mutex = Mutex.new @logger = nil # Users can set a logger if needed @event_emitter = EventEmitter.new(@logger) @provider_state_registry = ProviderStateRegistry.new @@ -99,53 +100,55 @@ def set_provider(provider, domain: nil) end def set_provider_and_wait(provider, domain: nil, timeout: 30) - completion_queue = Queue.new - - ready_handler = lambda do |event_details| - if event_details[:provider] == provider - completion_queue << { status: :ready } + @provider_wait_mutex.synchronize do + completion_queue = Queue.new + + ready_handler = lambda do |event_details| + if event_details[:provider] == provider + completion_queue << { status: :ready } + end end - end - - error_handler = lambda do |event_details| - if event_details[:provider] == provider - completion_queue << { - status: :error, - message: event_details[:message] || "Provider initialization failed", - error_code: event_details[:error_code] - } + + error_handler = lambda do |event_details| + if event_details[:provider] == provider + completion_queue << { + status: :error, + message: event_details[:message] || "Provider initialization failed", + error_code: event_details[:error_code] + } + end end - end - - add_handler(ProviderEvent::PROVIDER_READY, ready_handler) - add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) - - begin - set_provider(provider, domain: domain) - Timeout.timeout(timeout) do - result = completion_queue.pop + add_handler(ProviderEvent::PROVIDER_READY, ready_handler) + add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) + + begin + set_provider(provider, domain: domain) - if result[:status] == :error - error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL - message = result[:message] - raise ProviderInitializationError.new( - "Provider #{provider.class.name} initialization failed: #{message}", - provider: provider, - error_code: error_code, - original_error: nil # Exceptions not included in events - ) + Timeout.timeout(timeout) do + result = completion_queue.pop + + if result[:status] == :error + error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL + message = result[:message] + raise ProviderInitializationError.new( + "Provider #{provider.class.name} initialization failed: #{message}", + provider: provider, + error_code: error_code, + original_error: nil # Exceptions not included in events + ) + end end + rescue Timeout::Error => e + raise ProviderInitializationError.new( + "Provider #{provider.class.name} initialization timed out after #{timeout} seconds", + provider: provider, + original_error: e + ) + ensure + remove_handler(ProviderEvent::PROVIDER_READY, ready_handler) + remove_handler(ProviderEvent::PROVIDER_ERROR, error_handler) end - rescue Timeout::Error => e - raise ProviderInitializationError.new( - "Provider #{provider.class.name} initialization timed out after #{timeout} seconds", - provider: provider, - original_error: e - ) - ensure - remove_handler(ProviderEvent::PROVIDER_READY, ready_handler) - remove_handler(ProviderEvent::PROVIDER_ERROR, error_handler) end end From 4f7c5476f055034fc2cb83fa15232ff4f9822bf6 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 16:15:02 -0800 Subject: [PATCH 26/36] fix: resolve race condition in set_provider_and_wait Refactored to use single mutex for all provider operations, preventing race conditions where concurrent calls to set_provider could change the active provider while set_provider_and_wait is waiting. - Removed separate @provider_wait_mutex - Extracted core provider logic to set_provider_internal - Both set_provider and set_provider_and_wait now use same mutex - Ensures atomicity of provider operations across all methods - Simplified set_provider_internal by removing unused async parameter Signed-off-by: Sameeran Kunche Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 100 ++++++++++++++------------ 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 6d598f20..98d3fbbb 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -24,8 +24,7 @@ def initialize @hooks = [] @providers = {} @provider_mutex = Mutex.new - @provider_wait_mutex = Mutex.new - @logger = nil # Users can set a logger if needed + @logger = nil @event_emitter = EventEmitter.new(@logger) @provider_state_registry = ProviderStateRegistry.new end @@ -53,54 +52,12 @@ def clear_all_handlers def set_provider(provider, domain: nil) @provider_mutex.synchronize do - old_provider = @providers[domain] - - begin - old_provider.shutdown if old_provider&.respond_to?(:shutdown) - rescue StandardError => e - @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || 'unknown'}: #{e.message}") - end - - # Remove old provider state to prevent memory leaks - @provider_state_registry.remove_provider(old_provider) - - new_providers = @providers.dup - new_providers[domain] = provider - @providers = new_providers - - @provider_state_registry.set_initial_state(provider) - - if provider.is_a?(Provider::EventHandler) - provider.attach(ProviderEventDispatcher.new(self)) - end - - # Capture evaluation context to prevent race condition - context_for_init = @evaluation_context - Thread.new do - begin - if provider.respond_to?(:init) - init_method = provider.method(:init) - if init_method.parameters.empty? - provider.init - else - provider.init(context_for_init) - end - end - - unless provider.is_a?(Provider::EventHandler) - dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) - end - rescue StandardError => e - dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, - error_code: Provider::ErrorCode::PROVIDER_FATAL, - message: e.message) - end - end + set_provider_internal(provider, domain: domain) end end def set_provider_and_wait(provider, domain: nil, timeout: 30) - @provider_wait_mutex.synchronize do + @provider_mutex.synchronize do completion_queue = Queue.new ready_handler = lambda do |event_details| @@ -123,8 +80,10 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) begin - set_provider(provider, domain: domain) + # Start provider initialization in a separate thread + set_provider_internal(provider, domain: domain) + # Wait for initialization to complete Timeout.timeout(timeout) do result = completion_queue.pop @@ -154,6 +113,53 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) private + def set_provider_internal(provider, domain: nil) + old_provider = @providers[domain] + + begin + old_provider.shutdown if old_provider&.respond_to?(:shutdown) + rescue StandardError => e + @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || 'unknown'}: #{e.message}") + end + + # Remove old provider state to prevent memory leaks + @provider_state_registry.remove_provider(old_provider) + + new_providers = @providers.dup + new_providers[domain] = provider + @providers = new_providers + + @provider_state_registry.set_initial_state(provider) + + if provider.is_a?(Provider::EventHandler) + provider.attach(ProviderEventDispatcher.new(self)) + end + + # Capture evaluation context to prevent race condition + context_for_init = @evaluation_context + + Thread.new do + begin + if provider.respond_to?(:init) + init_method = provider.method(:init) + if init_method.parameters.empty? + provider.init + else + provider.init(context_for_init) + end + end + + unless provider.is_a?(Provider::EventHandler) + dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) + end + rescue StandardError => e + dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, + error_code: Provider::ErrorCode::PROVIDER_FATAL, + message: e.message) + end + end + end + def dispatch_provider_event(provider, event_type, details = {}) @provider_state_registry.update_state_from_event(provider, event_type, details) From 2be203ea6e5dbb8245372e37fd0c82e3de9f7018 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 16:23:33 -0800 Subject: [PATCH 27/36] perf: improve concurrency in set_provider_and_wait Only hold the provider mutex during state mutation, not during the entire timeout period. This prevents blocking other threads from setting providers on different domains when one provider is taking a long time to initialize. - Moved event handler setup outside of mutex - Mutex now only protects the set_provider_internal call - Timeout and queue waiting happen outside the critical section - Significantly reduces contention in concurrent applications Signed-off-by: Sameeran Kunche Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 92 +++++++++++++-------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 98d3fbbb..8ab28075 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -57,57 +57,57 @@ def set_provider(provider, domain: nil) end def set_provider_and_wait(provider, domain: nil, timeout: 30) - @provider_mutex.synchronize do - completion_queue = Queue.new - - ready_handler = lambda do |event_details| - if event_details[:provider] == provider - completion_queue << { status: :ready } - end + completion_queue = Queue.new + + ready_handler = lambda do |event_details| + if event_details[:provider] == provider + completion_queue << { status: :ready } end - - error_handler = lambda do |event_details| - if event_details[:provider] == provider - completion_queue << { - status: :error, - message: event_details[:message] || "Provider initialization failed", - error_code: event_details[:error_code] - } - end + end + + error_handler = lambda do |event_details| + if event_details[:provider] == provider + completion_queue << { + status: :error, + message: event_details[:message] || "Provider initialization failed", + error_code: event_details[:error_code] + } end - - add_handler(ProviderEvent::PROVIDER_READY, ready_handler) - add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) - - begin - # Start provider initialization in a separate thread - set_provider_internal(provider, domain: domain) + end + + add_handler(ProviderEvent::PROVIDER_READY, ready_handler) + add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) + + # Lock only while mutating shared state + @provider_mutex.synchronize do + set_provider_internal(provider, domain: domain) + end + + begin + # Wait for initialization to complete, outside the main provider mutex + Timeout.timeout(timeout) do + result = completion_queue.pop - # Wait for initialization to complete - Timeout.timeout(timeout) do - result = completion_queue.pop - - if result[:status] == :error - error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL - message = result[:message] - raise ProviderInitializationError.new( - "Provider #{provider.class.name} initialization failed: #{message}", - provider: provider, - error_code: error_code, - original_error: nil # Exceptions not included in events - ) - end + if result[:status] == :error + error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL + message = result[:message] + raise ProviderInitializationError.new( + "Provider #{provider.class.name} initialization failed: #{message}", + provider: provider, + error_code: error_code, + original_error: nil # Exceptions not included in events + ) end - rescue Timeout::Error => e - raise ProviderInitializationError.new( - "Provider #{provider.class.name} initialization timed out after #{timeout} seconds", - provider: provider, - original_error: e - ) - ensure - remove_handler(ProviderEvent::PROVIDER_READY, ready_handler) - remove_handler(ProviderEvent::PROVIDER_ERROR, error_handler) end + rescue Timeout::Error => e + raise ProviderInitializationError.new( + "Provider #{provider.class.name} initialization timed out after #{timeout} seconds", + provider: provider, + original_error: e + ) + ensure + remove_handler(ProviderEvent::PROVIDER_READY, ready_handler) + remove_handler(ProviderEvent::PROVIDER_ERROR, error_handler) end end From ebe76213cb07728116570402b6b9dd56a3d596e3 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 17:11:48 -0800 Subject: [PATCH 28/36] refactor: improve error message clarity and code organization - Use clearer default error message "an unspecified error occurred" to avoid redundant messages like "Provider MyProvider initialization failed: Provider initialization failed" - Extract error state mapping logic to private method for better separation of concerns and improved readability Signed-off-by: Sameeran Kunche Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 2 +- lib/open_feature/sdk/event_to_state_mapper.rb | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 8ab28075..6c0285d0 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -69,7 +69,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) if event_details[:provider] == provider completion_queue << { status: :error, - message: event_details[:message] || "Provider initialization failed", + message: event_details[:message] || "an unspecified error occurred", error_code: event_details[:error_code] } end diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb index 8399e45b..fc45c53e 100644 --- a/lib/open_feature/sdk/event_to_state_mapper.rb +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -12,14 +12,7 @@ class EventToStateMapper ProviderEvent::PROVIDER_READY => ProviderState::READY, ProviderEvent::PROVIDER_CONFIGURATION_CHANGED => ProviderState::READY, ProviderEvent::PROVIDER_STALE => ProviderState::STALE, - ProviderEvent::PROVIDER_ERROR => lambda do |event_details| - error_code = event_details&.dig(:error_code) - if error_code == Provider::ErrorCode::PROVIDER_FATAL - ProviderState::FATAL - else - ProviderState::ERROR - end - end + ProviderEvent::PROVIDER_ERROR => lambda { |event_details| state_from_error_event(event_details) } }.freeze def self.state_from_event(event_type, event_details = nil) @@ -32,6 +25,17 @@ def self.state_from_event(event_type, event_details = nil) end end + private + + def self.state_from_error_event(event_details) + error_code = event_details&.dig(:error_code) + if error_code == Provider::ErrorCode::PROVIDER_FATAL + ProviderState::FATAL + else + ProviderState::ERROR + end + end + end end end From bc37195ba7eca3296735586d44bff5bf5c2a7b79 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 17:19:31 -0800 Subject: [PATCH 29/36] refactor: replace hash mapping with idiomatic case statement Replaced STATE_MAPPING constant with a more readable and idiomatic Ruby case statement in EventToStateMapper.state_from_event method. - Removed complex lambda logic from constant hash - Centralized all mapping logic in single method - More readable with clear case/when structure - Updated tests to verify behavior rather than implementation - Maintains identical functionality with cleaner code Signed-off-by: Sameeran Kunche Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/event_to_state_mapper.rb | 20 ++++---- .../sdk/event_to_state_mapper_spec.rb | 46 ++++++------------- 2 files changed, 23 insertions(+), 43 deletions(-) diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb index fc45c53e..50f30f95 100644 --- a/lib/open_feature/sdk/event_to_state_mapper.rb +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -8,20 +8,16 @@ module OpenFeature module SDK # Maps provider events to provider states class EventToStateMapper - STATE_MAPPING = { - ProviderEvent::PROVIDER_READY => ProviderState::READY, - ProviderEvent::PROVIDER_CONFIGURATION_CHANGED => ProviderState::READY, - ProviderEvent::PROVIDER_STALE => ProviderState::STALE, - ProviderEvent::PROVIDER_ERROR => lambda { |event_details| state_from_error_event(event_details) } - }.freeze - def self.state_from_event(event_type, event_details = nil) - mapper = STATE_MAPPING[event_type] - - if mapper.respond_to?(:call) - mapper.call(event_details) + case event_type + when ProviderEvent::PROVIDER_READY, ProviderEvent::PROVIDER_CONFIGURATION_CHANGED + ProviderState::READY + when ProviderEvent::PROVIDER_STALE + ProviderState::STALE + when ProviderEvent::PROVIDER_ERROR + state_from_error_event(event_details) else - mapper || ProviderState::NOT_READY + ProviderState::NOT_READY end end diff --git a/spec/open_feature/sdk/event_to_state_mapper_spec.rb b/spec/open_feature/sdk/event_to_state_mapper_spec.rb index 2424fb75..e364d873 100644 --- a/spec/open_feature/sdk/event_to_state_mapper_spec.rb +++ b/spec/open_feature/sdk/event_to_state_mapper_spec.rb @@ -81,44 +81,28 @@ - describe 'STATE_MAPPING constant' do - it 'is frozen to prevent modification' do - expect(described_class::STATE_MAPPING).to be_frozen - end - - it 'contains mappings for all provider events' do - expected_events = [ - OpenFeature::SDK::ProviderEvent::PROVIDER_READY, - OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, - OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, - OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR - ] - - expect(described_class::STATE_MAPPING.keys).to contain_exactly(*expected_events) - end - end - describe 'integration with ProviderEvent and ProviderState constants' do - it 'uses valid provider events' do - described_class::STATE_MAPPING.keys.each do |event_type| - expect(OpenFeature::SDK::ProviderEvent::ALL_EVENTS).to include(event_type) + it 'handles all valid provider events' do + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + expect do + described_class.state_from_event(event_type) + end.not_to raise_error end end it 'maps to valid provider states' do - # Test non-callable mappings - non_callable_mappings = described_class::STATE_MAPPING.reject { |k, v| v.respond_to?(:call) } - non_callable_mappings.values.each do |state| - expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(state) - end - - # Test callable mappings (PROVIDER_ERROR) - error_mapper = described_class::STATE_MAPPING[OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR] - fatal_state = error_mapper.call({ error_code: 'PROVIDER_FATAL' }) - error_state = error_mapper.call({ error_code: 'SOME_ERROR' }) + # Test all known events return valid states + ready_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + config_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) + stale_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE) + error_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) + fatal_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, { error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL }) - expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(fatal_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(ready_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(config_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(stale_state) expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(error_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(fatal_state) end end end From 3f0e6596de7d8a09b9861dd780dd676f4dbfbb85 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 17:59:48 -0800 Subject: [PATCH 30/36] test: achieve 100% test coverage with comprehensive edge case testing Add targeted test coverage for previously uncovered lines to reach 100% coverage (471/471 lines). Focus on meaningful edge cases and newly added API functionality from the provider eventing system. Coverage improvements: - API logger methods delegation (api.rb lines 66, 70) - Event handler error logging with logger present - Provider initialization without parameters - Method missing super call for non-existent methods All new tests validate real functionality and error handling paths, ensuring robust behavior across the eventing system. Signed-off-by: Sameeran Kunche --- spec/open_feature/sdk/configuration_spec.rb | 52 +++++++++++++++++++ spec/open_feature/sdk_spec.rb | 8 +++ .../specification/flag_evaluation_api_spec.rb | 16 ++++++ 3 files changed, 76 insertions(+) diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index 70a4d6ea..44f46656 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -265,4 +265,56 @@ def metadata end end end + + describe "logger" do + it "sets logger and propagates to event emitter" do + logger = double("Logger") + + expect do + configuration.logger = logger + end.not_to raise_error + + expect(configuration.logger).to eq(logger) + expect(configuration.instance_variable_get(:@event_emitter).instance_variable_get(:@logger)).to eq(logger) + end + end + + describe "provider initialization with different init signatures" do + it "calls init without parameters when init method has no parameters" do + provider = Class.new do + attr_accessor :init_called + + def init + @init_called = true + end + + def metadata + OpenFeature::SDK::Provider::ProviderMetadata.new(name: "TestProvider") + end + end.new + + configuration.set_provider(provider) + + sleep(0.1) + + expect(provider.init_called).to be true + end + end + + describe "event handler error logging" do + it "logs error when event handler fails and logger is present" do + logger = double("Logger") + configuration.logger = logger + + failing_handler = proc { |_| raise StandardError.new("Handler failed") } + + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) + + expect(logger).to receive(:warn).with(/Event handler failed for/) + + configuration.send(:dispatch_provider_event, + OpenFeature::SDK::Provider::NoOpProvider.new, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + end end diff --git a/spec/open_feature/sdk_spec.rb b/spec/open_feature/sdk_spec.rb index 2725bb29..d1c88848 100644 --- a/spec/open_feature/sdk_spec.rb +++ b/spec/open_feature/sdk_spec.rb @@ -52,4 +52,12 @@ OpenFeature::SDK.set_provider(OpenFeature::SDK::Provider::NoOpProvider.new) end end + + describe "method_missing delegation" do + it "raises NoMethodError for non-existent methods" do + expect do + OpenFeature::SDK.some_non_existent_method + end.to raise_error(NoMethodError) + end + end end diff --git a/spec/specification/flag_evaluation_api_spec.rb b/spec/specification/flag_evaluation_api_spec.rb index 70123ecc..3127bf75 100644 --- a/spec/specification/flag_evaluation_api_spec.rb +++ b/spec/specification/flag_evaluation_api_spec.rb @@ -189,4 +189,20 @@ end end end + + context "Logger Methods" do + specify "delegates logger getter to configuration" do + logger = double("Logger") + allow(OpenFeature::SDK::API.instance.configuration).to receive(:logger).and_return(logger) + + expect(OpenFeature::SDK::API.instance.logger).to eq(logger) + end + + specify "delegates logger setter to configuration" do + logger = double("Logger") + expect(OpenFeature::SDK::API.instance.configuration).to receive(:logger=).with(logger) + + OpenFeature::SDK::API.instance.logger = logger + end + end end From feb988bcbf563c3d536fa544fbcd4a6f72c6abd9 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Thu, 11 Dec 2025 19:43:19 -0800 Subject: [PATCH 31/36] refactor: remove redundant comments and add OpenFeature spec reference Remove unnecessary inline comments that simply restate obvious constant names and method purposes. Add proper OpenFeature specification URL reference for provider states. Changes: - Remove redundant comments from ProviderEvent constants - Remove redundant comments from ProviderState constants - Remove unnecessary section headers in API class - Add OpenFeature spec URL for provider states documentation Code is now cleaner and self-documenting while maintaining valuable specification references. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/api.rb | 2 -- lib/open_feature/sdk/provider_event.rb | 8 -------- lib/open_feature/sdk/provider_state.rb | 13 ++----------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/lib/open_feature/sdk/api.rb b/lib/open_feature/sdk/api.rb index de510e26..73c1014a 100644 --- a/lib/open_feature/sdk/api.rb +++ b/lib/open_feature/sdk/api.rb @@ -52,7 +52,6 @@ def build_client(domain: nil, evaluation_context: nil) Client.new(provider: Provider::NoOpProvider.new, evaluation_context:) end - # Event handling methods def add_handler(event_type, handler) configuration.add_handler(event_type, handler) end @@ -61,7 +60,6 @@ def remove_handler(event_type, handler) configuration.remove_handler(event_type, handler) end - # Configuration methods def logger configuration.logger end diff --git a/lib/open_feature/sdk/provider_event.rb b/lib/open_feature/sdk/provider_event.rb index 6fd2defd..c9616631 100644 --- a/lib/open_feature/sdk/provider_event.rb +++ b/lib/open_feature/sdk/provider_event.rb @@ -9,19 +9,11 @@ module SDK # https://openfeature.dev/specification/sections/events/ # module ProviderEvent - # Emitted when provider initialization completes successfully PROVIDER_READY = 'PROVIDER_READY' - - # Emitted when provider initialization fails PROVIDER_ERROR = 'PROVIDER_ERROR' - - # Emitted when provider configuration changes PROVIDER_CONFIGURATION_CHANGED = 'PROVIDER_CONFIGURATION_CHANGED' - - # Emitted when provider enters a stale state PROVIDER_STALE = 'PROVIDER_STALE' - # All supported event types for validation ALL_EVENTS = [ PROVIDER_READY, PROVIDER_ERROR, diff --git a/lib/open_feature/sdk/provider_state.rb b/lib/open_feature/sdk/provider_state.rb index 3c9af550..0a082e73 100644 --- a/lib/open_feature/sdk/provider_state.rb +++ b/lib/open_feature/sdk/provider_state.rb @@ -5,25 +5,16 @@ module SDK # Provider State Types # # Defines the standard states that providers can be in during their lifecycle. - # These states correspond to the OpenFeature specification provider states. + # These states correspond to the OpenFeature specification provider states: + # https://openfeature.dev/specification/types#provider-status # module ProviderState - # Provider is not ready to serve flag evaluations NOT_READY = 'NOT_READY' - - # Provider is ready to serve flag evaluations READY = 'READY' - - # Provider encountered an error but may recover ERROR = 'ERROR' - - # Provider data is stale and should be refreshed STALE = 'STALE' - - # Provider encountered a fatal error and cannot recover FATAL = 'FATAL' - # All supported provider states for validation ALL_STATES = [ NOT_READY, READY, From b301cf90201c8446db8a9276e4ddb087d8620ee3 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Fri, 12 Dec 2025 13:33:09 -0800 Subject: [PATCH 32/36] style: apply Standard Ruby lint fixes for CI compliance - Fix string literal style preferences (single -> double quotes) - Correct hash syntax and spacing formatting - Remove trailing whitespace and useless assignments - Ensure all files comply with Standard Ruby configuration - Resolve 550+ lint violations identified in CI logs All 52 files now pass Standard Ruby linting with 0 offenses detected. This ensures consistency with CI pipeline linting configuration. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/configuration.rb | 88 +++++----- lib/open_feature/sdk/event_emitter.rb | 12 +- lib/open_feature/sdk/event_to_state_mapper.rb | 9 +- .../sdk/provider/event_handler.rb | 2 +- lib/open_feature/sdk/provider_event.rb | 8 +- .../sdk/provider_initialization_error.rb | 1 - lib/open_feature/sdk/provider_state.rb | 10 +- .../sdk/provider_state_registry.rb | 12 +- .../sdk/configuration_async_spec.rb | 164 +++++++++--------- spec/open_feature/sdk/configuration_spec.rb | 60 +++---- spec/open_feature/sdk/event_emitter_spec.rb | 138 +++++++-------- .../sdk/event_to_state_mapper_spec.rb | 68 ++++---- .../sdk/provider/event_handler_spec.rb | 110 ++++++------ .../sdk/provider/state_handler_spec.rb | 76 ++++---- .../sdk/provider_compatibility_spec.rb | 77 ++++---- spec/open_feature/sdk/provider_event_spec.rb | 32 ++-- .../sdk/provider_state_registry_spec.rb | 164 +++++++++--------- spec/specification/events_spec.rb | 117 +++++++------ .../specification/flag_evaluation_api_spec.rb | 20 +-- spec/specification/provider_spec.rb | 34 ++-- 20 files changed, 595 insertions(+), 607 deletions(-) diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 6c0285d0..ba44d3dc 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -52,49 +52,49 @@ def clear_all_handlers def set_provider(provider, domain: nil) @provider_mutex.synchronize do - set_provider_internal(provider, domain: domain) + set_provider_internal(provider, domain:) end end def set_provider_and_wait(provider, domain: nil, timeout: 30) completion_queue = Queue.new - + ready_handler = lambda do |event_details| if event_details[:provider] == provider - completion_queue << { status: :ready } + completion_queue << {status: :ready} end end - + error_handler = lambda do |event_details| if event_details[:provider] == provider - completion_queue << { - status: :error, + completion_queue << { + status: :error, message: event_details[:message] || "an unspecified error occurred", error_code: event_details[:error_code] } end end - + add_handler(ProviderEvent::PROVIDER_READY, ready_handler) add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) - + # Lock only while mutating shared state @provider_mutex.synchronize do - set_provider_internal(provider, domain: domain) + set_provider_internal(provider, domain:) end - + begin # Wait for initialization to complete, outside the main provider mutex Timeout.timeout(timeout) do result = completion_queue.pop - + if result[:status] == :error error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL message = result[:message] raise ProviderInitializationError.new( "Provider #{provider.class.name} initialization failed: #{message}", - provider: provider, - error_code: error_code, + provider:, + error_code:, original_error: nil # Exceptions not included in events ) end @@ -102,7 +102,7 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) rescue Timeout::Error => e raise ProviderInitializationError.new( "Provider #{provider.class.name} initialization timed out after #{timeout} seconds", - provider: provider, + provider:, original_error: e ) ensure @@ -115,60 +115,56 @@ def set_provider_and_wait(provider, domain: nil, timeout: 30) def set_provider_internal(provider, domain: nil) old_provider = @providers[domain] - + begin - old_provider.shutdown if old_provider&.respond_to?(:shutdown) - rescue StandardError => e - @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || 'unknown'}: #{e.message}") + old_provider.shutdown if old_provider.respond_to?(:shutdown) + rescue => e + @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || "unknown"}: #{e.message}") end - + # Remove old provider state to prevent memory leaks @provider_state_registry.remove_provider(old_provider) - + new_providers = @providers.dup new_providers[domain] = provider @providers = new_providers - + @provider_state_registry.set_initial_state(provider) - - if provider.is_a?(Provider::EventHandler) - provider.attach(ProviderEventDispatcher.new(self)) - end - + + provider.attach(ProviderEventDispatcher.new(self)) if provider.is_a?(Provider::EventHandler) + # Capture evaluation context to prevent race condition context_for_init = @evaluation_context - + Thread.new do - begin - if provider.respond_to?(:init) - init_method = provider.method(:init) - if init_method.parameters.empty? - provider.init - else - provider.init(context_for_init) - end - end - - unless provider.is_a?(Provider::EventHandler) - dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) + if provider.respond_to?(:init) + init_method = provider.method(:init) + if init_method.parameters.empty? + provider.init + else + provider.init(context_for_init) end - rescue StandardError => e - dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, - error_code: Provider::ErrorCode::PROVIDER_FATAL, - message: e.message) end + + unless provider.is_a?(Provider::EventHandler) + dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) + end + rescue => e + dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, + error_code: Provider::ErrorCode::PROVIDER_FATAL, + message: e.message) end end def dispatch_provider_event(provider, event_type, details = {}) @provider_state_registry.update_state_from_event(provider, event_type, details) - + # Trigger event handlers event_details = { - provider: provider, + provider:, provider_name: provider.class.name }.merge(details) - + @event_emitter.trigger_event(event_type, event_details) end diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb index 79adb00b..972ad5bc 100644 --- a/lib/open_feature/sdk/event_emitter.rb +++ b/lib/open_feature/sdk/event_emitter.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative 'provider_event' +require_relative "provider_event" module OpenFeature module SDK @@ -50,13 +50,9 @@ def trigger_event(event_type, event_details = {}) # Call handlers outside of mutex to avoid deadlocks handlers_to_call.each do |handler| - begin - handler.call(event_details) - rescue StandardError => e - if @logger - @logger.warn "Event handler failed for #{event_type}: #{e.message}\n#{e.backtrace.join("\n")}" - end - end + handler.call(event_details) + rescue => e + @logger&.warn "Event handler failed for #{event_type}: #{e.message}\n#{e.backtrace.join("\n")}" end end diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb index 50f30f95..bd49b8c2 100644 --- a/lib/open_feature/sdk/event_to_state_mapper.rb +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative 'provider_event' -require_relative 'provider_state' -require_relative 'provider/error_code' +require_relative "provider_event" +require_relative "provider_state" +require_relative "provider/error_code" module OpenFeature module SDK @@ -21,8 +21,6 @@ def self.state_from_event(event_type, event_details = nil) end end - private - def self.state_from_error_event(event_details) error_code = event_details&.dig(:error_code) if error_code == Provider::ErrorCode::PROVIDER_FATAL @@ -31,7 +29,6 @@ def self.state_from_error_event(event_details) ProviderState::ERROR end end - end end end diff --git a/lib/open_feature/sdk/provider/event_handler.rb b/lib/open_feature/sdk/provider/event_handler.rb index 092d5eed..01873b6f 100644 --- a/lib/open_feature/sdk/provider/event_handler.rb +++ b/lib/open_feature/sdk/provider/event_handler.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative '../provider_event' +require_relative "../provider_event" module OpenFeature module SDK diff --git a/lib/open_feature/sdk/provider_event.rb b/lib/open_feature/sdk/provider_event.rb index c9616631..15482caa 100644 --- a/lib/open_feature/sdk/provider_event.rb +++ b/lib/open_feature/sdk/provider_event.rb @@ -9,10 +9,10 @@ module SDK # https://openfeature.dev/specification/sections/events/ # module ProviderEvent - PROVIDER_READY = 'PROVIDER_READY' - PROVIDER_ERROR = 'PROVIDER_ERROR' - PROVIDER_CONFIGURATION_CHANGED = 'PROVIDER_CONFIGURATION_CHANGED' - PROVIDER_STALE = 'PROVIDER_STALE' + PROVIDER_READY = "PROVIDER_READY" + PROVIDER_ERROR = "PROVIDER_ERROR" + PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED" + PROVIDER_STALE = "PROVIDER_STALE" ALL_EVENTS = [ PROVIDER_READY, diff --git a/lib/open_feature/sdk/provider_initialization_error.rb b/lib/open_feature/sdk/provider_initialization_error.rb index 7ee36d1d..88b210be 100644 --- a/lib/open_feature/sdk/provider_initialization_error.rb +++ b/lib/open_feature/sdk/provider_initialization_error.rb @@ -4,7 +4,6 @@ module OpenFeature module SDK - # Exception raised when a provider fails to initialize during setProviderAndWait # # This exception provides access to both the original error that caused the diff --git a/lib/open_feature/sdk/provider_state.rb b/lib/open_feature/sdk/provider_state.rb index 0a082e73..f4a46a2e 100644 --- a/lib/open_feature/sdk/provider_state.rb +++ b/lib/open_feature/sdk/provider_state.rb @@ -9,11 +9,11 @@ module SDK # https://openfeature.dev/specification/types#provider-status # module ProviderState - NOT_READY = 'NOT_READY' - READY = 'READY' - ERROR = 'ERROR' - STALE = 'STALE' - FATAL = 'FATAL' + NOT_READY = "NOT_READY" + READY = "READY" + ERROR = "ERROR" + STALE = "STALE" + FATAL = "FATAL" ALL_STATES = [ NOT_READY, diff --git a/lib/open_feature/sdk/provider_state_registry.rb b/lib/open_feature/sdk/provider_state_registry.rb index 4b8ee372..e58f6ad2 100644 --- a/lib/open_feature/sdk/provider_state_registry.rb +++ b/lib/open_feature/sdk/provider_state_registry.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative 'provider_state' -require_relative 'provider_event' -require_relative 'event_to_state_mapper' +require_relative "provider_state" +require_relative "provider_event" +require_relative "event_to_state_mapper" module OpenFeature module SDK @@ -25,11 +25,11 @@ def update_state_from_event(provider, event_type, event_details = nil) return ProviderState::NOT_READY unless provider new_state = EventToStateMapper.state_from_event(event_type, event_details) - + @mutex.synchronize do @states[provider.object_id] = new_state end - + new_state end @@ -55,7 +55,7 @@ def ready?(provider) def error?(provider) state = get_state(provider) - state == ProviderState::ERROR || state == ProviderState::FATAL + [ProviderState::ERROR, ProviderState::FATAL].include?(state) end def clear diff --git a/spec/open_feature/sdk/configuration_async_spec.rb b/spec/open_feature/sdk/configuration_async_spec.rb index 3c7883c9..c0f232b7 100644 --- a/spec/open_feature/sdk/configuration_async_spec.rb +++ b/spec/open_feature/sdk/configuration_async_spec.rb @@ -1,21 +1,23 @@ +# frozen_string_literal: true + require "spec_helper" require_relative "../../../lib/open_feature/sdk" RSpec.describe OpenFeature::SDK::Configuration do let(:configuration) { described_class.new } - + # Helper to create a provider that takes time to initialize def create_slow_provider(init_time: 0.1, &on_init) Class.new do - define_method :init do |evaluation_context| + define_method :init do |_evaluation_context| sleep(init_time) on_init&.call end - + def shutdown # no-op end - + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) OpenFeature::SDK::Provider::ResolutionDetails.new( value: default_value, @@ -24,24 +26,24 @@ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) end end.new end - + # Helper to create an event-aware provider def create_event_aware_provider(init_time: 0.1, &on_init) Class.new do include OpenFeature::SDK::Provider::EventHandler - - define_method :init do |evaluation_context| + + define_method :init do |_evaluation_context| Thread.new do sleep(init_time) on_init&.call emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end end - + def shutdown # no-op end - + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) OpenFeature::SDK::Provider::ResolutionDetails.new( value: default_value, @@ -50,218 +52,218 @@ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) end end.new end - + # Helper to create a failing provider def create_failing_provider(error_message = "Init failed") Class.new do include OpenFeature::SDK::Provider::EventHandler - - define_method :init do |evaluation_context| + + define_method :init do |_evaluation_context| Thread.new do sleep(0.05) - emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, - error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL, - message: error_message) + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL, + message: error_message) end end - + def shutdown # no-op end end.new end - + describe "#set_provider" do context "non-blocking behavior" do it "returns immediately without waiting for initialization" do initialized = false provider = create_slow_provider(init_time: 0.2) { initialized = true } - + start_time = Time.now configuration.set_provider(provider) elapsed = Time.now - start_time - - expect(elapsed).to be < 0.1 # Should return in less than 100ms - expect(initialized).to be false # Should not be initialized yet - + + expect(elapsed).to be < 0.1 # Should return in less than 100ms + expect(initialized).to be false # Should not be initialized yet + # Wait for initialization to complete sleep(0.3) expect(initialized).to be true end - + it "sets the provider before initialization completes" do provider = create_slow_provider(init_time: 0.1) - + configuration.set_provider(provider) - + # Provider should be set immediately expect(configuration.provider).to eq(provider) end end - + context "event emission" do it "emits PROVIDER_READY event after successful initialization" do ready_events = [] - configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, - ->(event) { ready_events << event }) - + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + ->(event) { ready_events << event }) + provider = create_slow_provider(init_time: 0.05) configuration.set_provider(provider) - + # Wait for initialization sleep(0.1) - + expect(ready_events.size).to eq(1) expect(ready_events.first[:provider]).to eq(provider) end - + it "emits PROVIDER_ERROR event on initialization failure" do error_events = [] - configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, - ->(event) { error_events << event }) - + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + ->(event) { error_events << event }) + provider = create_slow_provider { raise "Init error" } configuration.set_provider(provider) - + # Wait for initialization sleep(0.2) - + expect(error_events.size).to eq(1) expect(error_events.first[:provider]).to eq(provider) expect(error_events.first[:message]).to include("Init error") end end - + context "with event-aware providers" do it "does not emit duplicate PROVIDER_READY events" do ready_events = [] - configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, - ->(event) { ready_events << event }) - + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + ->(event) { ready_events << event }) + provider = create_event_aware_provider(init_time: 0.05) configuration.set_provider(provider) - + # Wait for initialization sleep(0.15) - + # Should only have one event (from the provider itself) expect(ready_events.size).to eq(1) end end end - + describe "#set_provider_and_wait" do context "blocking behavior" do it "blocks until provider initialization completes" do initialized = false provider = create_slow_provider(init_time: 0.1) { initialized = true } - + expect(initialized).to be false configuration.set_provider_and_wait(provider, timeout: 1) expect(initialized).to be true end - + it "returns only after PROVIDER_READY event" do provider = create_event_aware_provider(init_time: 0.1) - + start_time = Time.now configuration.set_provider_and_wait(provider, timeout: 1) elapsed = Time.now - start_time - - expect(elapsed).to be >= 0.1 # Should wait at least as long as init time + + expect(elapsed).to be >= 0.1 # Should wait at least as long as init time end end - + context "error handling" do it "raises ProviderInitializationError on provider initialization failure" do provider = create_failing_provider("Custom error") - - expect { + + expect do configuration.set_provider_and_wait(provider, timeout: 1) - }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| expect(error.message).to include("Custom error") end end - + it "raises ProviderInitializationError on timeout" do - provider = create_slow_provider(init_time: 2.0) # 2 seconds - - expect { + provider = create_slow_provider(init_time: 2.0) # 2 seconds + + expect do configuration.set_provider_and_wait(provider, timeout: 0.5) - }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| expect(error.message).to include("timed out after 0.5 seconds") end end end - + context "event handler cleanup" do it "removes event handlers after completion" do provider = create_slow_provider(init_time: 0.05) - + # Get initial handler count initial_ready_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) initial_error_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) - + configuration.set_provider_and_wait(provider, timeout: 1) - + # Handler counts should be back to initial final_ready_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) final_error_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) - + expect(final_ready_count).to eq(initial_ready_count) expect(final_error_count).to eq(initial_error_count) end - + it "removes event handlers even on error" do provider = create_failing_provider - + # Get initial handler count initial_count = configuration.send(:total_handler_count) - - expect { + + expect do configuration.set_provider_and_wait(provider, timeout: 1) - }.to raise_error(OpenFeature::SDK::ProviderInitializationError) - + end.to raise_error(OpenFeature::SDK::ProviderInitializationError) + # Handler count should be back to initial final_count = configuration.send(:total_handler_count) - + expect(final_count).to eq(initial_count) end end end - + describe "provider state tracking" do it "tracks provider state transitions" do provider = create_slow_provider(init_time: 0.05) - + # Initially NOT_READY configuration.set_provider(provider) expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) - + # Wait for initialization sleep(0.1) expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::READY) end - + it "tracks error states" do provider = create_failing_provider - + configuration.set_provider(provider) - + # Wait for initialization sleep(0.1) expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) end end - + describe "backward compatibility" do it "works with providers that don't use events" do provider = OpenFeature::SDK::Provider::NoOpProvider.new - - expect { + + expect do configuration.set_provider_and_wait(provider, timeout: 1) - }.not_to raise_error - + end.not_to raise_error + expect(configuration.provider).to eq(provider) end end diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index 44f46656..9f62a721 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -15,7 +15,7 @@ configuration.set_provider(provider) expect(configuration.provider).to be(provider) - + # Wait for async initialization sleep(0.1) end @@ -39,7 +39,7 @@ configuration.set_provider(provider, domain: "testing") expect(configuration.provider(domain: "testing")).to be(provider) - + # Wait for async initialization sleep(0.1) end @@ -119,13 +119,13 @@ expect(error.message).to include("Provider initialization failed") expect(error.message).to include(error_message) expect(error.provider).to be(provider) - expect(error.original_error).to be_nil # Provider init errors come through events, so no original exception + expect(error.original_error).to be_nil # Provider init errors come through events, so no original exception expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL) end end it "leaves the failed provider in place when init fails" do - old_provider = configuration.provider + configuration.provider expect do configuration.set_provider_and_wait(provider) @@ -156,7 +156,7 @@ end it "leaves the failed provider in place when init times out" do - old_provider = configuration.provider + configuration.provider expect do configuration.set_provider_and_wait(provider, timeout: 0.1) @@ -219,46 +219,46 @@ expect(configuration.provider).to be(provider) end - + it "handles setting provider to a domain with no previous provider" do # This should not raise any errors even though old_provider will be nil expect { configuration.set_provider_and_wait(provider, domain: "new-domain") }.not_to raise_error - + expect(configuration.provider(domain: "new-domain")).to be(provider) end end - + context "when evaluation context changes during async initialization" do let(:initial_context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "initial") } let(:changed_context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "changed") } let(:context_capturing_provider) do Class.new do attr_reader :received_context - + def init(context = nil) @received_context = context # Simulate slow initialization sleep(0.1) end - + def metadata OpenFeature::SDK::Provider::ProviderMetadata.new(name: "ContextCapturingProvider") end end.new end - + it "uses the evaluation context that was set when set_provider was called" do configuration.evaluation_context = initial_context - + # Start provider initialization (async) configuration.set_provider(context_capturing_provider) - + # Change global context immediately after configuration.evaluation_context = changed_context - + # Wait for initialization to complete sleep(0.2) - + # Provider should have received the initial context, not the changed one expect(context_capturing_provider.received_context).to eq(initial_context) expect(context_capturing_provider.received_context).not_to eq(changed_context) @@ -269,11 +269,11 @@ def metadata describe "logger" do it "sets logger and propagates to event emitter" do logger = double("Logger") - + expect do configuration.logger = logger end.not_to raise_error - + expect(configuration.logger).to eq(logger) expect(configuration.instance_variable_get(:@event_emitter).instance_variable_get(:@logger)).to eq(logger) end @@ -283,20 +283,20 @@ def metadata it "calls init without parameters when init method has no parameters" do provider = Class.new do attr_accessor :init_called - + def init @init_called = true end - + def metadata OpenFeature::SDK::Provider::ProviderMetadata.new(name: "TestProvider") end end.new - + configuration.set_provider(provider) - + sleep(0.1) - + expect(provider.init_called).to be true end end @@ -305,16 +305,16 @@ def metadata it "logs error when event handler fails and logger is present" do logger = double("Logger") configuration.logger = logger - - failing_handler = proc { |_| raise StandardError.new("Handler failed") } - + + failing_handler = proc { |_| raise StandardError, "Handler failed" } + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) - + expect(logger).to receive(:warn).with(/Event handler failed for/) - - configuration.send(:dispatch_provider_event, - OpenFeature::SDK::Provider::NoOpProvider.new, - OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + + configuration.send(:dispatch_provider_event, + OpenFeature::SDK::Provider::NoOpProvider.new, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end end end diff --git a/spec/open_feature/sdk/event_emitter_spec.rb b/spec/open_feature/sdk/event_emitter_spec.rb index c93f9390..781fdf27 100644 --- a/spec/open_feature/sdk/event_emitter_spec.rb +++ b/spec/open_feature/sdk/event_emitter_spec.rb @@ -1,44 +1,44 @@ # frozen_string_literal: true -require 'spec_helper' -require 'open_feature/sdk/event_emitter' -require 'open_feature/sdk/provider_event' +require "spec_helper" +require "open_feature/sdk/event_emitter" +require "open_feature/sdk/provider_event" RSpec.describe OpenFeature::SDK::EventEmitter do subject(:event_emitter) { described_class.new } - describe '#initialize' do - it 'initializes with empty handlers for all event types' do + describe "#initialize" do + it "initializes with empty handlers for all event types" do OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| expect(event_emitter.handler_count(event_type)).to eq(0) end end end - describe '#add_handler' do + describe "#add_handler" do let(:handler) { ->(event_details) { puts "Event received: #{event_details}" } } - it 'adds a handler for a valid event type' do + it "adds a handler for a valid event type" do expect do event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(0).to(1) end - it 'raises error for invalid event type' do + it "raises error for invalid event type" do expect do - event_emitter.add_handler('INVALID_EVENT', handler) + event_emitter.add_handler("INVALID_EVENT", handler) end.to raise_error(ArgumentError, /Invalid event type/) end - it 'raises error for non-callable handler' do + it "raises error for non-callable handler" do expect do - event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, 'not callable') + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, "not callable") end.to raise_error(ArgumentError, /Handler must respond to call/) end - it 'allows multiple handlers for the same event type' do - handler2 = ->(event_details) { puts "Handler 2" } - + it "allows multiple handlers for the same event type" do + handler2 = ->(_event_details) { puts "Handler 2" } + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) @@ -46,53 +46,53 @@ end end - describe '#remove_handler' do - let(:handler1) { ->(event_details) { puts "Handler 1" } } - let(:handler2) { ->(event_details) { puts "Handler 2" } } + describe "#remove_handler" do + let(:handler1) { ->(_event_details) { puts "Handler 1" } } + let(:handler2) { ->(_event_details) { puts "Handler 2" } } before do event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) end - it 'removes a specific handler' do + it "removes a specific handler" do expect do event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(2).to(1) end - it 'does nothing for invalid event type' do + it "does nothing for invalid event type" do expect do - event_emitter.remove_handler('INVALID_EVENT', handler1) - end.not_to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) } + event_emitter.remove_handler("INVALID_EVENT", handler1) + end.not_to(change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }) end - it 'does nothing if handler is not registered' do - unregistered_handler = ->(event_details) { puts "Unregistered" } - + it "does nothing if handler is not registered" do + unregistered_handler = ->(_event_details) { puts "Unregistered" } + expect do event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, unregistered_handler) - end.not_to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) } + end.not_to(change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }) end end - describe '#remove_all_handlers' do - let(:handler1) { ->(event_details) { puts "Handler 1" } } - let(:handler2) { ->(event_details) { puts "Handler 2" } } + describe "#remove_all_handlers" do + let(:handler1) { ->(_event_details) { puts "Handler 1" } } + let(:handler2) { ->(_event_details) { puts "Handler 2" } } before do event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) end - it 'removes all handlers for an event type' do + it "removes all handlers for an event type" do expect do event_emitter.remove_all_handlers(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(2).to(0) end end - describe '#trigger_event' do + describe "#trigger_event" do let(:received_events) { [] } let(:handler) { ->(event_details) { received_events << event_details } } @@ -100,115 +100,117 @@ event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) end - it 'triggers handlers with event details' do - event_details = { provider: 'test-provider', message: 'Ready' } - + it "triggers handlers with event details" do + event_details = {provider: "test-provider", message: "Ready"} + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) - + expect(received_events).to contain_exactly(event_details) end - it 'triggers handlers with empty event details if none provided' do + it "triggers handlers with empty event details if none provided" do event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) - + expect(received_events).to contain_exactly({}) end - it 'triggers multiple handlers for the same event' do + it "triggers multiple handlers for the same event" do received_events2 = [] handler2 = ->(event_details) { received_events2 << event_details } event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) - - event_details = { test: 'data' } + + event_details = {test: "data"} event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) - + expect(received_events).to contain_exactly(event_details) expect(received_events2).to contain_exactly(event_details) end - it 'does nothing for invalid event type' do - event_emitter.trigger_event('INVALID_EVENT', { test: 'data' }) - + it "does nothing for invalid event type" do + event_emitter.trigger_event("INVALID_EVENT", {test: "data"}) + expect(received_events).to be_empty end - it 'continues executing other handlers even if one fails' do - failing_handler = ->(event_details) { raise 'Handler failed' } + it "continues executing other handlers even if one fails" do + failing_handler = ->(_event_details) { raise "Handler failed" } received_events2 = [] working_handler = ->(event_details) { received_events2 << event_details } - + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, working_handler) - - event_details = { test: 'data' } - + + event_details = {test: "data"} + # Should not raise error and should still call working handlers - expect { event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) }.not_to raise_error + expect do + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) + end.not_to raise_error expect(received_events).to contain_exactly(event_details) expect(received_events2).to contain_exactly(event_details) end end - describe '#clear_all_handlers' do + describe "#clear_all_handlers" do before do OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| - event_emitter.add_handler(event_type, ->(event_details) { puts "Handler for #{event_type}" }) + event_emitter.add_handler(event_type, ->(_event_details) { puts "Handler for #{event_type}" }) end end - it 'clears all handlers for all event types' do + it "clears all handlers for all event types" do event_emitter.clear_all_handlers - + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| expect(event_emitter.handler_count(event_type)).to eq(0) end end end - describe 'thread safety' do - let(:handler) { ->(event_details) { sleep(0.001) } } # Small delay to increase chance of race conditions + describe "thread safety" do + let(:handler) { ->(_event_details) { sleep(0.001) } } # Small delay to increase chance of race conditions - it 'handles concurrent add/remove operations safely' do + it "handles concurrent add/remove operations safely" do threads = [] - + # Concurrent additions 10.times do threads << Thread.new do event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) end end - + # Concurrent removals 5.times do threads << Thread.new do event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) end end - + threads.each(&:join) - + # Should not crash and should have some handlers remaining expect(event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)).to be >= 0 end - it 'handles concurrent triggering safely' do + it "handles concurrent triggering safely" do received_count = 0 counter_mutex = Mutex.new - counting_handler = lambda do |event_details| + counting_handler = lambda do |_event_details| counter_mutex.synchronize { received_count += 1 } end - + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, counting_handler) - + threads = [] 10.times do threads << Thread.new do - event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, { test: 'concurrent' }) + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, {test: "concurrent"}) end end - + threads.each(&:join) - + expect(received_count).to eq(10) end end diff --git a/spec/open_feature/sdk/event_to_state_mapper_spec.rb b/spec/open_feature/sdk/event_to_state_mapper_spec.rb index e364d873..188af556 100644 --- a/spec/open_feature/sdk/event_to_state_mapper_spec.rb +++ b/spec/open_feature/sdk/event_to_state_mapper_spec.rb @@ -1,88 +1,85 @@ # frozen_string_literal: true -require 'spec_helper' -require 'open_feature/sdk/event_to_state_mapper' -require 'open_feature/sdk/provider_event' -require 'open_feature/sdk/provider_state' +require "spec_helper" +require "open_feature/sdk/event_to_state_mapper" +require "open_feature/sdk/provider_event" +require "open_feature/sdk/provider_state" RSpec.describe OpenFeature::SDK::EventToStateMapper do - describe '.state_from_event' do - context 'with PROVIDER_READY event' do - it 'returns READY state' do + describe ".state_from_event" do + context "with PROVIDER_READY event" do + it "returns READY state" do state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) expect(state).to eq(OpenFeature::SDK::ProviderState::READY) end end - context 'with PROVIDER_CONFIGURATION_CHANGED event' do - it 'returns READY state' do + context "with PROVIDER_CONFIGURATION_CHANGED event" do + it "returns READY state" do state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) expect(state).to eq(OpenFeature::SDK::ProviderState::READY) end end - context 'with PROVIDER_STALE event' do - it 'returns STALE state' do + context "with PROVIDER_STALE event" do + it "returns STALE state" do state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE) expect(state).to eq(OpenFeature::SDK::ProviderState::STALE) end end - context 'with PROVIDER_ERROR event' do - it 'returns ERROR state by default' do + context "with PROVIDER_ERROR event" do + it "returns ERROR state by default" do state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) end - it 'returns ERROR state for non-fatal error' do + it "returns ERROR state for non-fatal error" do event_details = { - message: 'Connection failed', - error_code: 'CONNECTION_ERROR' + message: "Connection failed", + error_code: "CONNECTION_ERROR" } - + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) end - it 'returns FATAL state for fatal error' do + it "returns FATAL state for fatal error" do event_details = { - message: 'Provider cannot recover', + message: "Provider cannot recover", error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL } - + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) end - it 'handles Hash event details' do + it "handles Hash event details" do event_details_hash = { - message: 'Provider cannot recover', + message: "Provider cannot recover", error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL } - + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details_hash) expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) end - - it 'handles nil event details gracefully' do + it "handles nil event details gracefully" do state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, nil) expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) end end - context 'with unknown event type' do - it 'returns NOT_READY state as fallback' do - state = described_class.state_from_event('UNKNOWN_EVENT') + context "with unknown event type" do + it "returns NOT_READY state as fallback" do + state = described_class.state_from_event("UNKNOWN_EVENT") expect(state).to eq(OpenFeature::SDK::ProviderState::NOT_READY) end end end - - - describe 'integration with ProviderEvent and ProviderState constants' do - it 'handles all valid provider events' do + describe "integration with ProviderEvent and ProviderState constants" do + it "handles all valid provider events" do OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| expect do described_class.state_from_event(event_type) @@ -90,14 +87,15 @@ end end - it 'maps to valid provider states' do + it "maps to valid provider states" do # Test all known events return valid states ready_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) config_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) stale_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE) error_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) - fatal_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, { error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL }) - + fatal_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + {error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL}) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(ready_state) expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(config_state) expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(stale_state) diff --git a/spec/open_feature/sdk/provider/event_handler_spec.rb b/spec/open_feature/sdk/provider/event_handler_spec.rb index 643502e8..9d3d7873 100644 --- a/spec/open_feature/sdk/provider/event_handler_spec.rb +++ b/spec/open_feature/sdk/provider/event_handler_spec.rb @@ -1,138 +1,136 @@ # frozen_string_literal: true -require 'spec_helper' -require 'open_feature/sdk/provider/event_handler' -require 'open_feature/sdk/provider_event' +require "spec_helper" +require "open_feature/sdk/provider/event_handler" +require "open_feature/sdk/provider_event" RSpec.describe OpenFeature::SDK::Provider::EventHandler do let(:test_class) do Class.new do include OpenFeature::SDK::Provider::EventHandler - + def name "TestProvider" end end end - + let(:provider) { test_class.new } - let(:event_dispatcher) { double('EventDispatcher') } - - describe 'interface methods' do - it 'responds to attach' do + let(:event_dispatcher) { double("EventDispatcher") } + + describe "interface methods" do + it "responds to attach" do expect(provider).to respond_to(:attach).with(1).argument end - - it 'responds to detach' do + + it "responds to detach" do expect(provider).to respond_to(:detach).with(0).arguments end - - it 'responds to emit_event' do + + it "responds to emit_event" do expect(provider).to respond_to(:emit_event).with(1..2).arguments end - - it 'responds to event_dispatcher_attached?' do + + it "responds to event_dispatcher_attached?" do expect(provider).to respond_to(:event_dispatcher_attached?).with(0).arguments end end - - describe '#attach' do - it 'attaches an event dispatcher' do + + describe "#attach" do + it "attaches an event dispatcher" do provider.attach(event_dispatcher) expect(provider.event_dispatcher_attached?).to be true end end - - describe '#detach' do - it 'detaches the event dispatcher' do + + describe "#detach" do + it "detaches the event dispatcher" do provider.attach(event_dispatcher) provider.detach expect(provider.event_dispatcher_attached?).to be false end end - - describe '#emit_event' do + + describe "#emit_event" do before do provider.attach(event_dispatcher) end - - it 'dispatches events through the attached dispatcher' do + + it "dispatches events through the attached dispatcher" do expect(event_dispatcher).to receive(:dispatch_event).with( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_READY, {} ) - + provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end - - it 'includes custom details in dispatched event' do - custom_details = { message: 'Provider is ready', custom_field: 'value' } - + + it "includes custom details in dispatched event" do + custom_details = {message: "Provider is ready", custom_field: "value"} + expect(event_dispatcher).to receive(:dispatch_event).with( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_READY, - { message: 'Provider is ready', custom_field: 'value' } + {message: "Provider is ready", custom_field: "value"} ) - + provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, custom_details) end - - it 'does nothing when no dispatcher is attached' do + + it "does nothing when no dispatcher is attached" do provider.detach - + expect { provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.not_to raise_error end - - it 'raises error for invalid event type' do + + it "raises error for invalid event type" do expect do - provider.emit_event('INVALID_EVENT') + provider.emit_event("INVALID_EVENT") end.to raise_error(ArgumentError, /Invalid event type/) end - - it 'works with all valid event types' do + + it "works with all valid event types" do OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| expect(event_dispatcher).to receive(:dispatch_event).with( provider, event_type, {} ) - end - - OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + provider.emit_event(event_type) end end end - - describe '#event_dispatcher_attached?' do - it 'returns false when no dispatcher attached' do + + describe "#event_dispatcher_attached?" do + it "returns false when no dispatcher attached" do expect(provider.event_dispatcher_attached?).to be false end - - it 'returns true when dispatcher attached' do + + it "returns true when dispatcher attached" do provider.attach(event_dispatcher) expect(provider.event_dispatcher_attached?).to be true end - - it 'returns false after detaching' do + + it "returns false after detaching" do provider.attach(event_dispatcher) provider.detach expect(provider.event_dispatcher_attached?).to be false end end - - describe 'thread safety' do - it 'handles concurrent attach/detach operations' do + + describe "thread safety" do + it "handles concurrent attach/detach operations" do threads = [] - + 5.times do threads << Thread.new { provider.attach(event_dispatcher) } threads << Thread.new { provider.detach } end - + threads.each(&:join) - + # Should not crash and should be in a valid state expect([true, false]).to include(provider.event_dispatcher_attached?) end diff --git a/spec/open_feature/sdk/provider/state_handler_spec.rb b/spec/open_feature/sdk/provider/state_handler_spec.rb index 11dc07a5..d8762411 100644 --- a/spec/open_feature/sdk/provider/state_handler_spec.rb +++ b/spec/open_feature/sdk/provider/state_handler_spec.rb @@ -1,99 +1,97 @@ # frozen_string_literal: true -require 'spec_helper' -require 'open_feature/sdk/provider/state_handler' +require "spec_helper" +require "open_feature/sdk/provider/state_handler" RSpec.describe OpenFeature::SDK::Provider::StateHandler do let(:test_class) do Class.new do include OpenFeature::SDK::Provider::StateHandler - + attr_reader :initialized, :shutdown_called - + def init(evaluation_context) @initialized = true @init_context = evaluation_context end - + def shutdown @shutdown_called = true end - - def init_context - @init_context - end + + attr_reader :init_context end end - + let(:provider) { test_class.new } - - describe 'interface methods' do - it 'responds to init' do + + describe "interface methods" do + it "responds to init" do expect(provider).to respond_to(:init).with(1).argument end - - it 'responds to shutdown' do + + it "responds to shutdown" do expect(provider).to respond_to(:shutdown).with(0).arguments end end - - describe '#init' do - it 'can be called with evaluation context' do - context = { user_id: '123' } + + describe "#init" do + it "can be called with evaluation context" do + context = {user_id: "123"} provider.init(context) - + expect(provider.initialized).to be true expect(provider.init_context).to eq(context) end - - it 'has a default implementation that does nothing' do + + it "has a default implementation that does nothing" do minimal_class = Class.new do include OpenFeature::SDK::Provider::StateHandler end - + minimal_provider = minimal_class.new expect { minimal_provider.init({}) }.not_to raise_error end end - - describe '#shutdown' do - it 'can be called' do + + describe "#shutdown" do + it "can be called" do provider.shutdown expect(provider.shutdown_called).to be true end - - it 'has a default implementation that does nothing' do + + it "has a default implementation that does nothing" do minimal_class = Class.new do include OpenFeature::SDK::Provider::StateHandler end - + minimal_provider = minimal_class.new expect { minimal_provider.shutdown }.not_to raise_error end end - - describe 'error handling' do + + describe "error handling" do let(:error_provider_class) do Class.new do include OpenFeature::SDK::Provider::StateHandler - - def init(evaluation_context) + + def init(_evaluation_context) raise StandardError, "Initialization failed" end - + def shutdown raise StandardError, "Shutdown failed" end end end - + let(:error_provider) { error_provider_class.new } - - it 'propagates init errors' do + + it "propagates init errors" do expect { error_provider.init({}) }.to raise_error(StandardError, "Initialization failed") end - - it 'propagates shutdown errors' do + + it "propagates shutdown errors" do expect { error_provider.shutdown }.to raise_error(StandardError, "Shutdown failed") end end diff --git a/spec/open_feature/sdk/provider_compatibility_spec.rb b/spec/open_feature/sdk/provider_compatibility_spec.rb index 5cdfc718..febe08de 100644 --- a/spec/open_feature/sdk/provider_compatibility_spec.rb +++ b/spec/open_feature/sdk/provider_compatibility_spec.rb @@ -1,88 +1,87 @@ # frozen_string_literal: true -require 'spec_helper' -require 'open_feature/sdk/provider/no_op_provider' -require 'open_feature/sdk/provider/in_memory_provider' +require "spec_helper" +require "open_feature/sdk/provider/no_op_provider" +require "open_feature/sdk/provider/in_memory_provider" -RSpec.describe 'Providers Without Event Capabilities' do - describe 'NoOpProvider without event capabilities' do +RSpec.describe "Providers Without Event Capabilities" do + describe "NoOpProvider without event capabilities" do let(:provider) { OpenFeature::SDK::Provider::NoOpProvider.new } - - it 'continues to work without implementing new interfaces' do - expect { provider.fetch_boolean_value(flag_key: 'test', default_value: true) }.not_to raise_error + + it "continues to work without implementing new interfaces" do + expect { provider.fetch_boolean_value(flag_key: "test", default_value: true) }.not_to raise_error end - - it 'does not respond to new interface methods' do + + it "does not respond to new interface methods" do expect(provider).not_to respond_to(:attach) expect(provider).not_to respond_to(:detach) expect(provider).not_to respond_to(:emit_event) end - - it 'does not respond to init or shutdown' do + + it "does not respond to init or shutdown" do expect(provider).not_to respond_to(:init) expect(provider).not_to respond_to(:shutdown) end end - - describe 'InMemoryProvider without event capabilities' do + + describe "InMemoryProvider without event capabilities" do let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } - - it 'continues to work with existing init/shutdown methods' do + + it "continues to work with existing init/shutdown methods" do expect { provider.init }.not_to raise_error expect { provider.shutdown }.not_to raise_error end - - it 'does not automatically gain event capabilities' do + + it "does not automatically gain event capabilities" do expect(provider).not_to respond_to(:attach) expect(provider).not_to respond_to(:emit_event) end - - it 'fetch methods continue to work' do + + it "fetch methods continue to work" do provider = OpenFeature::SDK::Provider::InMemoryProvider.new( - 'test-flag' => true + "test-flag" => true ) - - result = provider.fetch_boolean_value(flag_key: 'test-flag', default_value: false) + + result = provider.fetch_boolean_value(flag_key: "test-flag", default_value: false) expect(result.value).to be true end end - end -RSpec.describe 'Mixed Provider Usage' do - it 'can use different provider types together' do +RSpec.describe "Mixed Provider Usage" do + it "can use different provider types together" do noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new inmemory_provider = OpenFeature::SDK::Provider::InMemoryProvider.new - + # Both should work for fetching values - noop_result = noop_provider.fetch_string_value(flag_key: 'test', default_value: 'noop') - inmemory_result = inmemory_provider.fetch_string_value(flag_key: 'test', default_value: 'memory') - - expect(noop_result.value).to eq('noop') - expect(inmemory_result.value).to eq('memory') + noop_result = noop_provider.fetch_string_value(flag_key: "test", default_value: "noop") + inmemory_result = inmemory_provider.fetch_string_value(flag_key: "test", default_value: "memory") + + expect(noop_result.value).to eq("noop") + expect(inmemory_result.value).to eq("memory") end end -RSpec.describe 'Provider Interface Detection' do +RSpec.describe "Provider Interface Detection" do # Create a test provider that implements the new interfaces let(:event_capable_provider) do Class.new(OpenFeature::SDK::Provider::InMemoryProvider) do include OpenFeature::SDK::Provider::EventHandler end.new end - - it 'can check if provider implements StateHandler' do + + it "can check if provider implements StateHandler" do noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new inmemory_provider = OpenFeature::SDK::Provider::InMemoryProvider.new - + # Check using respond_to? (Ruby way) expect(noop_provider.respond_to?(:init)).to be false expect(inmemory_provider.respond_to?(:init)).to be true end - - it 'can check if provider implements EventHandler' do + + it "can check if provider implements EventHandler" do noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new - + # Check using is_a? with module expect(noop_provider.class.included_modules).not_to include(OpenFeature::SDK::Provider::EventHandler) expect(event_capable_provider.class.included_modules).to include(OpenFeature::SDK::Provider::EventHandler) diff --git a/spec/open_feature/sdk/provider_event_spec.rb b/spec/open_feature/sdk/provider_event_spec.rb index 971e7a4a..c883f009 100644 --- a/spec/open_feature/sdk/provider_event_spec.rb +++ b/spec/open_feature/sdk/provider_event_spec.rb @@ -1,35 +1,35 @@ # frozen_string_literal: true -require 'spec_helper' -require 'open_feature/sdk/provider_event' +require "spec_helper" +require "open_feature/sdk/provider_event" RSpec.describe OpenFeature::SDK::ProviderEvent do - it 'defines PROVIDER_READY constant' do - expect(described_class::PROVIDER_READY).to eq('PROVIDER_READY') + it "defines PROVIDER_READY constant" do + expect(described_class::PROVIDER_READY).to eq("PROVIDER_READY") end - it 'defines PROVIDER_ERROR constant' do - expect(described_class::PROVIDER_ERROR).to eq('PROVIDER_ERROR') + it "defines PROVIDER_ERROR constant" do + expect(described_class::PROVIDER_ERROR).to eq("PROVIDER_ERROR") end - it 'defines PROVIDER_CONFIGURATION_CHANGED constant' do - expect(described_class::PROVIDER_CONFIGURATION_CHANGED).to eq('PROVIDER_CONFIGURATION_CHANGED') + it "defines PROVIDER_CONFIGURATION_CHANGED constant" do + expect(described_class::PROVIDER_CONFIGURATION_CHANGED).to eq("PROVIDER_CONFIGURATION_CHANGED") end - it 'defines PROVIDER_STALE constant' do - expect(described_class::PROVIDER_STALE).to eq('PROVIDER_STALE') + it "defines PROVIDER_STALE constant" do + expect(described_class::PROVIDER_STALE).to eq("PROVIDER_STALE") end - it 'defines ALL_EVENTS with all event types' do + it "defines ALL_EVENTS with all event types" do expect(described_class::ALL_EVENTS).to contain_exactly( - 'PROVIDER_READY', - 'PROVIDER_ERROR', - 'PROVIDER_CONFIGURATION_CHANGED', - 'PROVIDER_STALE' + "PROVIDER_READY", + "PROVIDER_ERROR", + "PROVIDER_CONFIGURATION_CHANGED", + "PROVIDER_STALE" ) end - it 'has frozen ALL_EVENTS array' do + it "has frozen ALL_EVENTS array" do expect(described_class::ALL_EVENTS).to be_frozen end end diff --git a/spec/open_feature/sdk/provider_state_registry_spec.rb b/spec/open_feature/sdk/provider_state_registry_spec.rb index a464663c..0236e41b 100644 --- a/spec/open_feature/sdk/provider_state_registry_spec.rb +++ b/spec/open_feature/sdk/provider_state_registry_spec.rb @@ -1,178 +1,178 @@ # frozen_string_literal: true -require 'spec_helper' -require 'open_feature/sdk/provider_state_registry' -require 'open_feature/sdk/provider_state' -require 'open_feature/sdk/provider_event' +require "spec_helper" +require "open_feature/sdk/provider_state_registry" +require "open_feature/sdk/provider_state" +require "open_feature/sdk/provider_event" RSpec.describe OpenFeature::SDK::ProviderStateRegistry do let(:registry) { described_class.new } - let(:provider) { double('Provider', object_id: 12345) } - let(:provider2) { double('Provider2', object_id: 67890) } - - describe '#set_initial_state' do - it 'sets NOT_READY as default state' do + let(:provider) { double("Provider", object_id: 12_345) } + let(:provider2) { double("Provider2", object_id: 67_890) } + + describe "#set_initial_state" do + it "sets NOT_READY as default state" do registry.set_initial_state(provider) expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) end - - it 'sets custom initial state' do + + it "sets custom initial state" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) end end - - describe '#update_state_from_event' do + + describe "#update_state_from_event" do before do registry.set_initial_state(provider) end - - it 'updates state to READY on PROVIDER_READY event' do + + it "updates state to READY on PROVIDER_READY event" do new_state = registry.update_state_from_event( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_READY ) - + expect(new_state).to eq(OpenFeature::SDK::ProviderState::READY) expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) end - - it 'updates state to ERROR on PROVIDER_ERROR event' do + + it "updates state to ERROR on PROVIDER_ERROR event" do new_state = registry.update_state_from_event( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR ) - + expect(new_state).to eq(OpenFeature::SDK::ProviderState::ERROR) expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::ERROR) end - - it 'updates state to FATAL on PROVIDER_ERROR with fatal error code' do + + it "updates state to FATAL on PROVIDER_ERROR with fatal error code" do new_state = registry.update_state_from_event( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, - { error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL } + {error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL} ) - + expect(new_state).to eq(OpenFeature::SDK::ProviderState::FATAL) expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) end - - it 'updates state to STALE on PROVIDER_STALE event' do + + it "updates state to STALE on PROVIDER_STALE event" do new_state = registry.update_state_from_event( provider, OpenFeature::SDK::ProviderEvent::PROVIDER_STALE ) - + expect(new_state).to eq(OpenFeature::SDK::ProviderState::STALE) expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::STALE) end end - - describe '#get_state' do - it 'returns NOT_READY for untracked provider' do + + describe "#get_state" do + it "returns NOT_READY for untracked provider" do expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) end - - it 'returns the current state for tracked provider' do + + it "returns the current state for tracked provider" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) end end - - describe '#remove_provider' do - it 'removes provider from tracking' do + + describe "#remove_provider" do + it "removes provider from tracking" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) registry.remove_provider(provider) - + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) end - - it 'handles nil provider gracefully' do + + it "handles nil provider gracefully" do expect { registry.remove_provider(nil) }.not_to raise_error end end - - describe 'nil provider handling' do - it 'set_initial_state handles nil provider gracefully' do + + describe "nil provider handling" do + it "set_initial_state handles nil provider gracefully" do expect { registry.set_initial_state(nil) }.not_to raise_error end - - it 'update_state_from_event handles nil provider gracefully' do + + it "update_state_from_event handles nil provider gracefully" do result = registry.update_state_from_event(nil, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) expect(result).to eq(OpenFeature::SDK::ProviderState::NOT_READY) end - - it 'get_state handles nil provider gracefully' do + + it "get_state handles nil provider gracefully" do state = registry.get_state(nil) expect(state).to eq(OpenFeature::SDK::ProviderState::NOT_READY) end - - it 'ready? handles nil provider gracefully' do + + it "ready? handles nil provider gracefully" do expect(registry.ready?(nil)).to be false end - - it 'error? handles nil provider gracefully' do + + it "error? handles nil provider gracefully" do expect(registry.error?(nil)).to be false end end - - describe '#ready?' do - it 'returns true when provider is READY' do + + describe "#ready?" do + it "returns true when provider is READY" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) expect(registry.ready?(provider)).to be true end - - it 'returns false when provider is not READY' do + + it "returns false when provider is not READY" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::ERROR) expect(registry.ready?(provider)).to be false end - - it 'returns false for untracked provider' do + + it "returns false for untracked provider" do expect(registry.ready?(provider)).to be false end end - - describe '#error?' do - it 'returns true when provider is in ERROR state' do + + describe "#error?" do + it "returns true when provider is in ERROR state" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::ERROR) expect(registry.error?(provider)).to be true end - - it 'returns true when provider is in FATAL state' do + + it "returns true when provider is in FATAL state" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::FATAL) expect(registry.error?(provider)).to be true end - - it 'returns false when provider is READY' do + + it "returns false when provider is READY" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) expect(registry.error?(provider)).to be false end - - it 'returns false for untracked provider' do + + it "returns false for untracked provider" do expect(registry.error?(provider)).to be false end end - - describe '#clear' do - it 'removes all provider states' do + + describe "#clear" do + it "removes all provider states" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) registry.set_initial_state(provider2, OpenFeature::SDK::ProviderState::ERROR) - + registry.clear - + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) expect(registry.get_state(provider2)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) end end - - describe 'thread safety' do - it 'handles concurrent state updates' do + + describe "thread safety" do + it "handles concurrent state updates" do threads = [] - + # Start provider as NOT_READY registry.set_initial_state(provider) - + # Concurrent updates 10.times do |i| threads << Thread.new do @@ -183,9 +183,9 @@ end end end - + threads.each(&:join) - + # Should be in one of the valid states final_state = registry.get_state(provider) expect([ @@ -194,18 +194,18 @@ ]).to include(final_state) end end - - describe 'multiple providers' do - it 'tracks states independently' do + + describe "multiple providers" do + it "tracks states independently" do registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) registry.set_initial_state(provider2, OpenFeature::SDK::ProviderState::ERROR) - + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) expect(registry.get_state(provider2)).to eq(OpenFeature::SDK::ProviderState::ERROR) - + expect(registry.ready?(provider)).to be true expect(registry.ready?(provider2)).to be false - + expect(registry.error?(provider)).to be false expect(registry.error?(provider2)).to be true end diff --git a/spec/specification/events_spec.rb b/spec/specification/events_spec.rb index 0b23891b..4cc3feef 100644 --- a/spec/specification/events_spec.rb +++ b/spec/specification/events_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "spec_helper" require_relative "../../lib/open_feature/sdk" @@ -6,7 +8,7 @@ # Reset to default provider OpenFeature::SDK.set_provider(OpenFeature::SDK::Provider::NoOpProvider.new) end - + # Remove all handlers after each test to avoid test pollution after(:each) do # Clean up any remaining handlers @@ -18,17 +20,17 @@ # Verify that the EventHandler mixin exists and can be included provider_class = Class.new do include OpenFeature::SDK::Provider::EventHandler - - def init(evaluation_context) + + def init(_evaluation_context) # Provider can emit events emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end - + def shutdown # no-op end end - + provider = provider_class.new expect(provider).to respond_to(:emit_event) expect(provider).to respond_to(:attach) @@ -39,33 +41,34 @@ def shutdown context "Requirement 5.1.2" do specify "When a provider signals the occurrence of a particular event, the associated client and API event handlers MUST run" do event_received = false - handler = ->(event_details) { event_received = true } - + handler = ->(_event_details) { event_received = true } + # Add API-level handler OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) - + # Create event-aware provider provider_class = Class.new do include OpenFeature::SDK::Provider::EventHandler - - def init(evaluation_context) + + def init(_evaluation_context) Thread.new do sleep(0.05) emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end end - - def shutdown; end + + def shutdown + end end - + provider = provider_class.new OpenFeature::SDK.set_provider(provider) - + # Wait for event sleep(0.1) - + expect(event_received).to be true - + # Cleanup OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) end @@ -84,17 +87,17 @@ def shutdown; end specify "The API MUST provide a function for associating handler functions with provider event types" do expect(OpenFeature::SDK).to respond_to(:add_handler) expect(OpenFeature::SDK).to respond_to(:remove_handler) - + # Verify handlers can be added and removed - handler = ->(event_details) { } - - expect { + handler = ->(event_details) {} + + expect do OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) - }.not_to raise_error - - expect { + end.not_to raise_error + + expect do OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) - }.not_to raise_error + end.not_to raise_error end end @@ -102,21 +105,21 @@ def shutdown; end specify "Event handler functions MUST accept an event details parameter" do event_details_received = nil handler = ->(event_details) { event_details_received = event_details } - + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, handler) - + # Set provider that fails initialization provider = OpenFeature::SDK::Provider::InMemoryProvider.new allow(provider).to receive(:init).and_raise("Init failed") - + OpenFeature::SDK.set_provider(provider) - sleep(0.1) # Wait for async initialization - + sleep(0.1) # Wait for async initialization + expect(event_details_received).not_to be_nil expect(event_details_received).to be_a(Hash) expect(event_details_received[:provider]).to eq(provider) expect(event_details_received[:message]).to include("Init failed") - + # Cleanup OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, handler) end @@ -127,28 +130,28 @@ def shutdown; end handler1_called = false handler2_called = false handler3_called = false - - failing_handler = ->(event_details) { raise "Handler error" } - handler1 = ->(event_details) { handler1_called = true } - handler2 = ->(event_details) { handler2_called = true } - handler3 = ->(event_details) { handler3_called = true } - + + failing_handler = ->(_event_details) { raise "Handler error" } + handler1 = ->(_event_details) { handler1_called = true } + handler2 = ->(_event_details) { handler2_called = true } + handler3 = ->(_event_details) { handler3_called = true } + # Add handlers in order OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler3) - + # Set provider provider = OpenFeature::SDK::Provider::InMemoryProvider.new OpenFeature::SDK.set_provider(provider) - sleep(0.1) # Wait for async initialization - + sleep(0.1) # Wait for async initialization + # All handlers should have been called despite the failure expect(handler1_called).to be true expect(handler2_called).to be true expect(handler3_called).to be true - + # Cleanup OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) @@ -161,27 +164,27 @@ def shutdown; end specify "Event handlers MUST persist across provider changes" do # Wait for initial provider to be ready sleep(0.1) - + handler_call_count = 0 - handler = ->(event_details) { handler_call_count += 1 } - + handler = ->(_event_details) { handler_call_count += 1 } + # Add handler after initial provider is already set and ready OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) - + # Set first provider provider1 = OpenFeature::SDK::Provider::InMemoryProvider.new OpenFeature::SDK.set_provider(provider1) sleep(0.1) - + expect(handler_call_count).to eq(1) - + # Set second provider - handler should still be active - provider2 = OpenFeature::SDK::Provider::NoOpProvider.new + provider2 = OpenFeature::SDK::Provider::NoOpProvider.new OpenFeature::SDK.set_provider(provider2) sleep(0.1) - + expect(handler_call_count).to eq(2) - + # Cleanup OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) end @@ -190,19 +193,19 @@ def shutdown; end context "Requirement 5.3.1" do specify "If the provider's initialize function terminates normally, PROVIDER_READY handlers MUST run" do ready_event_received = false - handler = ->(event_details) { ready_event_received = true } - + handler = ->(_event_details) { ready_event_received = true } + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) - + # Provider with successful init provider = OpenFeature::SDK::Provider::InMemoryProvider.new - allow(provider).to receive(:init).and_return(nil) # Normal termination - + allow(provider).to receive(:init).and_return(nil) # Normal termination + OpenFeature::SDK.set_provider(provider) - sleep(0.1) # Wait for async initialization - + sleep(0.1) # Wait for async initialization + expect(ready_event_received).to be true - + # Cleanup OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) end diff --git a/spec/specification/flag_evaluation_api_spec.rb b/spec/specification/flag_evaluation_api_spec.rb index 3127bf75..4126c3c0 100644 --- a/spec/specification/flag_evaluation_api_spec.rb +++ b/spec/specification/flag_evaluation_api_spec.rb @@ -26,7 +26,7 @@ expect(provider).to receive(:init) OpenFeature::SDK.set_provider(provider) - + # Wait for async initialization sleep(0.1) end @@ -48,29 +48,29 @@ context "Requirement 1.1.2.4" do specify "The API SHOULD provide functions to set a provider and wait for the initialize function to complete or abnormally terminate" do provider = OpenFeature::SDK::Provider::InMemoryProvider.new - + # set_provider_and_wait should exist expect(OpenFeature::SDK).to respond_to(:set_provider_and_wait) - + # It should block until initialization completes allow(provider).to receive(:init) do sleep(0.1) # Simulate initialization time end - + start_time = Time.now OpenFeature::SDK.set_provider_and_wait(provider) elapsed = Time.now - start_time - + expect(elapsed).to be >= 0.1 expect(OpenFeature::SDK.provider).to be(provider) end - + specify "set_provider_and_wait must handle initialization errors" do provider = OpenFeature::SDK::Provider::InMemoryProvider.new error_message = "Initialization failed" - + allow(provider).to receive(:init).and_raise(StandardError.new(error_message)) - + expect { OpenFeature::SDK.set_provider_and_wait(provider) }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| @@ -194,14 +194,14 @@ specify "delegates logger getter to configuration" do logger = double("Logger") allow(OpenFeature::SDK::API.instance.configuration).to receive(:logger).and_return(logger) - + expect(OpenFeature::SDK::API.instance.logger).to eq(logger) end specify "delegates logger setter to configuration" do logger = double("Logger") expect(OpenFeature::SDK::API.instance.configuration).to receive(:logger=).with(logger) - + OpenFeature::SDK::API.instance.logger = logger end end diff --git a/spec/specification/provider_spec.rb b/spec/specification/provider_spec.rb index bd019aa4..decdd12b 100644 --- a/spec/specification/provider_spec.rb +++ b/spec/specification/provider_spec.rb @@ -19,20 +19,20 @@ # Create a provider that cannot initialize properly failing_provider_class = Class.new do include OpenFeature::SDK::Provider::EventHandler - + def metadata OpenFeature::SDK::Provider::ProviderMetadata.new(name: "Failing Provider") end - - def init(evaluation_context) + + def init(_evaluation_context) # Simulate inability to connect to flag service - raise StandardError.new("Cannot connect to flag service") + raise StandardError, "Cannot connect to flag service" end - + def shutdown # no-op end - + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) OpenFeature::SDK::Provider::ResolutionDetails.new( value: default_value, @@ -40,30 +40,30 @@ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) ) end end - + provider = failing_provider_class.new - + # Using set_provider_and_wait should raise an error when init fails - expect { + expect do OpenFeature::SDK.set_provider_and_wait(provider) - }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| expect(error.message).to include("Cannot connect to flag service") end end - + specify "Provider initialization errors should prevent the provider from being used" do # Store the old provider - old_provider = OpenFeature::SDK.provider - + OpenFeature::SDK.provider + # Create a provider that fails initialization failing_provider = OpenFeature::SDK::Provider::InMemoryProvider.new allow(failing_provider).to receive(:init).and_raise("Init failed") - + # Try to set the failing provider - expect { + expect do OpenFeature::SDK.set_provider_and_wait(failing_provider) - }.to raise_error(OpenFeature::SDK::ProviderInitializationError) - + end.to raise_error(OpenFeature::SDK::ProviderInitializationError) + # The failing provider should remain in place (with error state) expect(OpenFeature::SDK.provider).to eq(failing_provider) end From 124b6897b992a538324ed996e842b8bdfafa3ae4 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Fri, 12 Dec 2025 14:27:05 -0800 Subject: [PATCH 33/36] docs: update README with eventing documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark eventing feature as completed (✅) in features table - Add eventing section with API-level event handler examples - Document EventHandler mixin for provider event emission - Show proper handler management with variable references for removal - Clarify original_error behavior for ProviderInitializationError - Align documentation with OpenFeature specification and other SDKs Provides users with clear guidance on implementing event-driven provider lifecycle management in their applications. Signed-off-by: Sameeran Kunche --- README.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c9e566cc..a4498ce0 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ object = client.fetch_object_value(flag_key: 'object_value', default_value: { na | ⚠️ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | | ❌ | [Logging](#logging) | Integrate with popular logging packages. | | ✅ | [Domains](#domains) | Logically bind clients with providers. | -| ❌ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | +| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | | ⚠️ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | | ❌ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | | ⚠️ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | @@ -142,7 +142,7 @@ begin rescue OpenFeature::SDK::ProviderInitializationError => e puts "Provider failed to initialize: #{e.message}" puts "Error code: #{e.error_code}" - # Note: original_error may be nil for errors from provider events + # Note: original_error is only present for timeout errors, nil for provider event errors puts "Original error: #{e.original_error}" if e.original_error end @@ -166,7 +166,7 @@ The `set_provider_and_wait` method: - Waits for the provider's `init` method to complete successfully - Raises `ProviderInitializationError` with `PROVIDER_FATAL` error code if initialization fails or times out - Provides access to the provider instance and error code for debugging -- The `original_error` field contains the underlying exception for timeout errors, but may be `nil` for errors that occur through the provider event system (aligning with other OpenFeature SDKs) +- The `original_error` field only contains the underlying exception for timeout errors; it is `nil` for errors that occur through the provider event system - Uses the same thread-safe provider switching as `set_provider` In some situations, it may be beneficial to register multiple providers in the same application. @@ -233,15 +233,33 @@ legacy_flag_client = OpenFeature::SDK.build_client(domain: "legacy_flags") ### Eventing -Coming Soon! [Issue available](https://github.com/open-feature/ruby-sdk/issues/51) to be worked on. - - +Please refer to the documentation of the provider you're using to see what events are supported. + +```ruby +# Register event handlers at the API (global) level +ready_handler = ->(event_details) do + puts "Provider #{event_details[:provider].metadata.name} is ready!" +end + +OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler) - +# Providers can emit events using the EventHandler mixin +class MyEventAwareProvider + include OpenFeature::SDK::Provider::EventHandler + + def init(evaluation_context) + # During initialization, emit PROVIDER_READY when ready + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end +end + +# Remove specific handlers when no longer needed +OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler) +``` ### Shutdown From 19dd12f34d1c9856438cbddf6a67a8c6e81d795c Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Fri, 12 Dec 2025 14:38:05 -0800 Subject: [PATCH 34/36] refactor: improve API encapsulation by making testing utilities private - Move clear_all_handlers in API class to private section - Move handler_count and total_handler_count in Configuration to private - Update tests to use send() for accessing private testing methods - Ensure ProviderEventDispatcher remains internal implementation detail These methods are testing utilities not part of the OpenFeature specification and should not be exposed in the public API. Maintains 100% test coverage and functionality while providing cleaner public interface. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/api.rb | 3 ++- lib/open_feature/sdk/configuration.rb | 10 ++++++---- spec/specification/events_spec.rb | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/open_feature/sdk/api.rb b/lib/open_feature/sdk/api.rb index 73c1014a..e993f8c8 100644 --- a/lib/open_feature/sdk/api.rb +++ b/lib/open_feature/sdk/api.rb @@ -68,7 +68,8 @@ def logger=(new_logger) configuration.logger = new_logger end - # Internal utility for testing - not part of OpenFeature spec + private + def clear_all_handlers configuration.clear_all_handlers end diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index ba44d3dc..4307f126 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -168,6 +168,12 @@ def dispatch_provider_event(provider, event_type, details = {}) @event_emitter.trigger_event(event_type, event_details) end + def provider_state(provider) + @provider_state_registry.get_state(provider) + end + + private + def handler_count(event_type) @event_emitter.handler_count(event_type) end @@ -176,10 +182,6 @@ def total_handler_count ProviderEvent::ALL_EVENTS.sum { |event_type| handler_count(event_type) } end - def provider_state(provider) - @provider_state_registry.get_state(provider) - end - class ProviderEventDispatcher def initialize(config) @config = config diff --git a/spec/specification/events_spec.rb b/spec/specification/events_spec.rb index 4cc3feef..65f56394 100644 --- a/spec/specification/events_spec.rb +++ b/spec/specification/events_spec.rb @@ -12,7 +12,7 @@ # Remove all handlers after each test to avoid test pollution after(:each) do # Clean up any remaining handlers - OpenFeature::SDK::API.instance.clear_all_handlers + OpenFeature::SDK::API.instance.send(:clear_all_handlers) end context "Requirement 5.1.1" do From ba4a1367dd56639d37e6c4d871f55897cd89ff24 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Fri, 12 Dec 2025 16:15:09 -0800 Subject: [PATCH 35/36] refactor: remove unnecessary StateHandler module to simplify PR - Remove StateHandler module and related tests - Update provider compatibility test to reflect duck typing usage - Update PR description to remove StateHandler references - No functional changes - duck typing already used in SDK StateHandler provided no real value since Ruby's duck typing makes interfaces optional. Providers can implement init/shutdown directly without any mixin. This simplifies the PR while maintaining full functionality and backward compatibility. Signed-off-by: Sameeran Kunche --- lib/open_feature/sdk/provider.rb | 1 - .../sdk/provider/state_handler.rb | 16 --- .../sdk/provider/state_handler_spec.rb | 98 ------------------- .../sdk/provider_compatibility_spec.rb | 2 +- 4 files changed, 1 insertion(+), 116 deletions(-) delete mode 100644 lib/open_feature/sdk/provider/state_handler.rb delete mode 100644 spec/open_feature/sdk/provider/state_handler_spec.rb diff --git a/lib/open_feature/sdk/provider.rb b/lib/open_feature/sdk/provider.rb index 6a0641cf..edd393c1 100644 --- a/lib/open_feature/sdk/provider.rb +++ b/lib/open_feature/sdk/provider.rb @@ -4,7 +4,6 @@ require_relative "provider/provider_metadata" # Provider interfaces -require_relative "provider/state_handler" require_relative "provider/event_handler" # Provider implementations diff --git a/lib/open_feature/sdk/provider/state_handler.rb b/lib/open_feature/sdk/provider/state_handler.rb deleted file mode 100644 index e3701175..00000000 --- a/lib/open_feature/sdk/provider/state_handler.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module OpenFeature - module SDK - module Provider - # Mixin for providers that need initialization and shutdown - module StateHandler - def init(evaluation_context) - end - - def shutdown - end - end - end - end -end diff --git a/spec/open_feature/sdk/provider/state_handler_spec.rb b/spec/open_feature/sdk/provider/state_handler_spec.rb deleted file mode 100644 index d8762411..00000000 --- a/spec/open_feature/sdk/provider/state_handler_spec.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" -require "open_feature/sdk/provider/state_handler" - -RSpec.describe OpenFeature::SDK::Provider::StateHandler do - let(:test_class) do - Class.new do - include OpenFeature::SDK::Provider::StateHandler - - attr_reader :initialized, :shutdown_called - - def init(evaluation_context) - @initialized = true - @init_context = evaluation_context - end - - def shutdown - @shutdown_called = true - end - - attr_reader :init_context - end - end - - let(:provider) { test_class.new } - - describe "interface methods" do - it "responds to init" do - expect(provider).to respond_to(:init).with(1).argument - end - - it "responds to shutdown" do - expect(provider).to respond_to(:shutdown).with(0).arguments - end - end - - describe "#init" do - it "can be called with evaluation context" do - context = {user_id: "123"} - provider.init(context) - - expect(provider.initialized).to be true - expect(provider.init_context).to eq(context) - end - - it "has a default implementation that does nothing" do - minimal_class = Class.new do - include OpenFeature::SDK::Provider::StateHandler - end - - minimal_provider = minimal_class.new - expect { minimal_provider.init({}) }.not_to raise_error - end - end - - describe "#shutdown" do - it "can be called" do - provider.shutdown - expect(provider.shutdown_called).to be true - end - - it "has a default implementation that does nothing" do - minimal_class = Class.new do - include OpenFeature::SDK::Provider::StateHandler - end - - minimal_provider = minimal_class.new - expect { minimal_provider.shutdown }.not_to raise_error - end - end - - describe "error handling" do - let(:error_provider_class) do - Class.new do - include OpenFeature::SDK::Provider::StateHandler - - def init(_evaluation_context) - raise StandardError, "Initialization failed" - end - - def shutdown - raise StandardError, "Shutdown failed" - end - end - end - - let(:error_provider) { error_provider_class.new } - - it "propagates init errors" do - expect { error_provider.init({}) }.to raise_error(StandardError, "Initialization failed") - end - - it "propagates shutdown errors" do - expect { error_provider.shutdown }.to raise_error(StandardError, "Shutdown failed") - end - end -end diff --git a/spec/open_feature/sdk/provider_compatibility_spec.rb b/spec/open_feature/sdk/provider_compatibility_spec.rb index febe08de..9de11478 100644 --- a/spec/open_feature/sdk/provider_compatibility_spec.rb +++ b/spec/open_feature/sdk/provider_compatibility_spec.rb @@ -70,7 +70,7 @@ end.new end - it "can check if provider implements StateHandler" do + it "can check if provider implements lifecycle methods using duck typing" do noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new inmemory_provider = OpenFeature::SDK::Provider::InMemoryProvider.new From e5ba150cad58a02f2a36f212bdfe24e191fdbf2f Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Mon, 15 Dec 2025 20:55:51 -0800 Subject: [PATCH 36/36] refactor: simplify provider initialization with explicit blocking control Replace event-based flow control in set_provider_and_wait with direct blocking initialization following Go/Java SDK patterns. - Add wait_for_init parameter to set_provider_internal - Remove timeout and event handler cleanup from set_provider_and_wait - Extract init_provider helper for unified initialization logic - Preserve event emission in both sync and async paths - Remove unused private methods Reduces complexity by 118 lines while maintaining 100% test coverage. Signed-off-by: Sameeran Kunche --- README.md | 17 ++- lib/open_feature/sdk/configuration.rb | 109 ++++++------------ .../sdk/configuration_async_spec.rb | 69 ++--------- spec/open_feature/sdk/configuration_spec.rb | 37 +----- 4 files changed, 62 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index a4498ce0..4e5a4f51 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,21 @@ class MyEventAwareProvider include OpenFeature::SDK::Provider::EventHandler def init(evaluation_context) - # During initialization, emit PROVIDER_READY when ready - emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + # Start background process to monitor for configuration changes + # Note: SDK automatically emits PROVIDER_READY when init completes successfully + start_background_process + end + + def start_background_process + Thread.new do + # Monitor for configuration changes and emit events when they occur + if configuration_changed? + emit_event( + OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, + message: "Flag configuration updated" + ) + end + end end end diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 4307f126..27acf2cd 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -52,68 +52,19 @@ def clear_all_handlers def set_provider(provider, domain: nil) @provider_mutex.synchronize do - set_provider_internal(provider, domain:) + set_provider_internal(provider, domain: domain, wait_for_init: false) end end - def set_provider_and_wait(provider, domain: nil, timeout: 30) - completion_queue = Queue.new - - ready_handler = lambda do |event_details| - if event_details[:provider] == provider - completion_queue << {status: :ready} - end - end - - error_handler = lambda do |event_details| - if event_details[:provider] == provider - completion_queue << { - status: :error, - message: event_details[:message] || "an unspecified error occurred", - error_code: event_details[:error_code] - } - end - end - - add_handler(ProviderEvent::PROVIDER_READY, ready_handler) - add_handler(ProviderEvent::PROVIDER_ERROR, error_handler) - - # Lock only while mutating shared state + def set_provider_and_wait(provider, domain: nil) @provider_mutex.synchronize do - set_provider_internal(provider, domain:) - end - - begin - # Wait for initialization to complete, outside the main provider mutex - Timeout.timeout(timeout) do - result = completion_queue.pop - - if result[:status] == :error - error_code = result[:error_code] || Provider::ErrorCode::PROVIDER_FATAL - message = result[:message] - raise ProviderInitializationError.new( - "Provider #{provider.class.name} initialization failed: #{message}", - provider:, - error_code:, - original_error: nil # Exceptions not included in events - ) - end - end - rescue Timeout::Error => e - raise ProviderInitializationError.new( - "Provider #{provider.class.name} initialization timed out after #{timeout} seconds", - provider:, - original_error: e - ) - ensure - remove_handler(ProviderEvent::PROVIDER_READY, ready_handler) - remove_handler(ProviderEvent::PROVIDER_ERROR, error_handler) + set_provider_internal(provider, domain: domain, wait_for_init: true) end end private - def set_provider_internal(provider, domain: nil) + def set_provider_internal(provider, domain:, wait_for_init:) old_provider = @providers[domain] begin @@ -136,23 +87,41 @@ def set_provider_internal(provider, domain: nil) # Capture evaluation context to prevent race condition context_for_init = @evaluation_context - Thread.new do - if provider.respond_to?(:init) - init_method = provider.method(:init) - if init_method.parameters.empty? - provider.init - else - provider.init(context_for_init) - end + if wait_for_init + init_provider(provider, context_for_init, raise_on_error: true) + else + Thread.new do + init_provider(provider, context_for_init, raise_on_error: false) end + end + end - unless provider.is_a?(Provider::EventHandler) - dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) + def init_provider(provider, context, raise_on_error: false) + if provider.respond_to?(:init) + init_method = provider.method(:init) + if init_method.parameters.empty? + provider.init + else + provider.init(context) end - rescue => e - dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, + end + + unless provider.is_a?(Provider::EventHandler) + dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) + end + rescue => e + dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, + error_code: Provider::ErrorCode::PROVIDER_FATAL, + message: e.message) + + if raise_on_error + # Re-raise as ProviderInitializationError for synchronous callers + raise ProviderInitializationError.new( + "Provider #{provider.class.name} initialization failed: #{e.message}", + provider:, error_code: Provider::ErrorCode::PROVIDER_FATAL, - message: e.message) + original_error: e + ) end end @@ -174,14 +143,6 @@ def provider_state(provider) private - def handler_count(event_type) - @event_emitter.handler_count(event_type) - end - - def total_handler_count - ProviderEvent::ALL_EVENTS.sum { |event_type| handler_count(event_type) } - end - class ProviderEventDispatcher def initialize(config) @config = config diff --git a/spec/open_feature/sdk/configuration_async_spec.rb b/spec/open_feature/sdk/configuration_async_spec.rb index c0f232b7..ee567787 100644 --- a/spec/open_feature/sdk/configuration_async_spec.rb +++ b/spec/open_feature/sdk/configuration_async_spec.rb @@ -33,11 +33,9 @@ def create_event_aware_provider(init_time: 0.1, &on_init) include OpenFeature::SDK::Provider::EventHandler define_method :init do |_evaluation_context| - Thread.new do - sleep(init_time) - on_init&.call - emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) - end + sleep(init_time) + on_init&.call + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end def shutdown @@ -59,12 +57,8 @@ def create_failing_provider(error_message = "Init failed") include OpenFeature::SDK::Provider::EventHandler define_method :init do |_evaluation_context| - Thread.new do - sleep(0.05) - emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, - error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL, - message: error_message) - end + sleep(0.05) # Simulate some initialization time + raise StandardError, error_message end def shutdown @@ -159,7 +153,7 @@ def shutdown provider = create_slow_provider(init_time: 0.1) { initialized = true } expect(initialized).to be false - configuration.set_provider_and_wait(provider, timeout: 1) + configuration.set_provider_and_wait(provider) expect(initialized).to be true end @@ -167,7 +161,7 @@ def shutdown provider = create_event_aware_provider(init_time: 0.1) start_time = Time.now - configuration.set_provider_and_wait(provider, timeout: 1) + configuration.set_provider_and_wait(provider) elapsed = Time.now - start_time expect(elapsed).to be >= 0.1 # Should wait at least as long as init time @@ -179,56 +173,11 @@ def shutdown provider = create_failing_provider("Custom error") expect do - configuration.set_provider_and_wait(provider, timeout: 1) + configuration.set_provider_and_wait(provider) end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| expect(error.message).to include("Custom error") end end - - it "raises ProviderInitializationError on timeout" do - provider = create_slow_provider(init_time: 2.0) # 2 seconds - - expect do - configuration.set_provider_and_wait(provider, timeout: 0.5) - end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| - expect(error.message).to include("timed out after 0.5 seconds") - end - end - end - - context "event handler cleanup" do - it "removes event handlers after completion" do - provider = create_slow_provider(init_time: 0.05) - - # Get initial handler count - initial_ready_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) - initial_error_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) - - configuration.set_provider_and_wait(provider, timeout: 1) - - # Handler counts should be back to initial - final_ready_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) - final_error_count = configuration.send(:handler_count, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) - - expect(final_ready_count).to eq(initial_ready_count) - expect(final_error_count).to eq(initial_error_count) - end - - it "removes event handlers even on error" do - provider = create_failing_provider - - # Get initial handler count - initial_count = configuration.send(:total_handler_count) - - expect do - configuration.set_provider_and_wait(provider, timeout: 1) - end.to raise_error(OpenFeature::SDK::ProviderInitializationError) - - # Handler count should be back to initial - final_count = configuration.send(:total_handler_count) - - expect(final_count).to eq(initial_count) - end end end @@ -261,7 +210,7 @@ def shutdown provider = OpenFeature::SDK::Provider::NoOpProvider.new expect do - configuration.set_provider_and_wait(provider, timeout: 1) + configuration.set_provider_and_wait(provider) end.not_to raise_error expect(configuration.provider).to eq(provider) diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index 9f62a721..b192f418 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -74,10 +74,10 @@ expect(configuration.provider).to be(provider) end - it "supports custom timeout" do + it "initializes the provider synchronously" do expect(provider).to receive(:init).once - configuration.set_provider_and_wait(provider, timeout: 60) + configuration.set_provider_and_wait(provider) expect(configuration.provider).to be(provider) end @@ -119,7 +119,7 @@ expect(error.message).to include("Provider initialization failed") expect(error.message).to include(error_message) expect(error.provider).to be(provider) - expect(error.original_error).to be_nil # Provider init errors come through events, so no original exception + expect(error.original_error).to be_a(StandardError) # Synchronous init preserves original exception expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL) end end @@ -135,37 +135,6 @@ end end - context "when provider init times out" do - let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } - - before do - allow(provider).to receive(:init) do - sleep 2 # Simulate slow initialization - end - end - - it "raises ProviderInitializationError after timeout" do - expect do - configuration.set_provider_and_wait(provider, timeout: 0.1) - end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| - expect(error.message).to include("Provider initialization timed out after 0.1 seconds") - expect(error.provider).to be(provider) - expect(error.original_error).to be_a(Timeout::Error) - expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL) - end - end - - it "leaves the failed provider in place when init times out" do - configuration.provider - - expect do - configuration.set_provider_and_wait(provider, timeout: 0.1) - end.to raise_error(OpenFeature::SDK::ProviderInitializationError) - - expect(configuration.provider).to be(provider) - end - end - context "when shutting down the old provider fails" do let(:old_provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } let(:new_provider) { OpenFeature::SDK::Provider::InMemoryProvider.new }