Skip to content

Commit 9a87d04

Browse files
josecolellaclaude
andauthored
feat: close spec compliance gaps for OpenFeature v0.8.0 (#237)
Signed-off-by: Jose Colella <jose.colella@gusto.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 95845ba commit 9a87d04

9 files changed

Lines changed: 258 additions & 37 deletions

File tree

lib/open_feature/sdk/api.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class API
3434
include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1
3535
extend Forwardable
3636

37-
def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :evaluation_context
37+
def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :add_hooks, :evaluation_context
3838

3939
def configuration
4040
@configuration ||= Configuration.new

lib/open_feature/sdk/client.rb

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ def initialize(provider:, domain: nil, evaluation_context: nil)
2828
@hooks = []
2929
end
3030

31+
def add_hooks(*new_hooks)
32+
@hooks.concat(new_hooks.flatten)
33+
end
34+
3135
def provider_status
3236
OpenFeature::SDK.configuration.provider_state(@provider)
3337
end
@@ -70,26 +74,29 @@ def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context
7074
def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
7175
validate_default_value_type(type, default_value)
7276

73-
if OpenFeature::SDK.configuration.provider_tracked?(@provider)
74-
error_code = short_circuit_error_code(provider_status)
75-
if error_code
76-
resolution = Provider::ResolutionDetails.new(
77-
value: default_value,
78-
error_code: error_code,
79-
reason: Provider::Reason::ERROR
80-
)
81-
return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
82-
end
83-
end
84-
8577
built_context = build_evaluation_context(evaluation_context)
8678

8779
# Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
8880
provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
8981
ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
9082

83+
# Check for short-circuit conditions (spec 1.7.6 + 1.7.7)
84+
short_circuit_code = nil
85+
if OpenFeature::SDK.configuration.provider_tracked?(@provider)
86+
short_circuit_code = short_circuit_error_code(provider_status)
87+
end
88+
9189
# Fast path: skip hook ceremony when no hooks are registered
9290
if ordered_hooks.empty?
91+
if short_circuit_code
92+
resolution = Provider::ResolutionDetails.new(
93+
value: default_value,
94+
error_code: short_circuit_code,
95+
reason: Provider::Reason::ERROR
96+
)
97+
return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
98+
end
99+
93100
return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
94101
end
95102

@@ -111,8 +118,21 @@ def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, inv
111118
end
112119

113120
executor = Hooks::HookExecutor.new(logger: OpenFeature::SDK.configuration.logger)
114-
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
115-
evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
121+
122+
if short_circuit_code
123+
# Spec 1.7.6 + 1.7.7: short-circuit must still run error hooks and finally hooks
124+
resolution = Provider::ResolutionDetails.new(
125+
value: default_value,
126+
error_code: short_circuit_code,
127+
reason: Provider::Reason::ERROR
128+
)
129+
evaluation_details = EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
130+
executor.run_short_circuit(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, evaluation_details: evaluation_details)
131+
evaluation_details
132+
else
133+
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
134+
evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
135+
end
116136
end
117137
end
118138

lib/open_feature/sdk/client_metadata.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module OpenFeature
44
module SDK
5-
ClientMetadata = Struct.new(:domain, keyword_init: true)
5+
ClientMetadata = Struct.new(:domain, keyword_init: true) do
6+
alias_method :name, :domain
7+
end
68
end
79
end

lib/open_feature/sdk/configuration.rb

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ def initialize
3131
@client_handlers_mutex = Mutex.new
3232
end
3333

34+
def add_hooks(*new_hooks)
35+
@hooks.concat(new_hooks.flatten)
36+
end
37+
3438
def provider(domain: nil)
3539
@providers[domain] || @providers[nil]
3640
end
@@ -85,9 +89,12 @@ def provider_tracked?(provider)
8589
end
8690

8791
def shutdown
88-
providers_to_shutdown = @provider_mutex.synchronize { @providers.values.uniq }
92+
providers_to_shutdown = @provider_mutex.synchronize { @providers.values.uniq(&:object_id) }
8993

9094
providers_to_shutdown.each do |prov|
95+
# Spec 1.7.9: Set provider state to NOT_READY before shutdown
96+
@provider_state_registry.update_state_from_event(prov, ProviderEvent::PROVIDER_READY)
97+
@provider_state_registry.set_initial_state(prov, ProviderState::NOT_READY)
9198
prov.shutdown if prov.respond_to?(:shutdown)
9299
rescue => e
93100
@logger&.warn("Error shutting down provider #{prov&.class&.name || "unknown"}: #{e.message}")
@@ -113,28 +120,37 @@ def set_provider_internal(provider, domain:, wait_for_init:)
113120
# Capture evaluation context before acquiring mutex to prevent race conditions
114121
context_for_init = @evaluation_context
115122

116-
old_provider, provider_to_init = nil
123+
old_provider = nil
124+
needs_init = false
125+
needs_shutdown = false
117126

118127
@provider_mutex.synchronize do
119128
old_provider = @providers[domain]
120129

121-
# Remove old provider state to prevent memory leaks
122-
@provider_state_registry.remove_provider(old_provider)
123-
124130
new_providers = @providers.dup
125131
new_providers[domain] = provider
126132
@providers = new_providers
127133

128-
@provider_state_registry.set_initial_state(provider)
134+
# Spec 1.1.2.2: Only initialize if the provider is not already active
135+
# (i.e., not already bound to another domain)
136+
already_active = @providers.any? { |d, p| d != domain && p.equal?(provider) && @provider_state_registry.tracked?(p) }
137+
needs_init = !already_active
129138

130-
provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
139+
if needs_init
140+
@provider_state_registry.set_initial_state(provider)
141+
provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
142+
end
131143

132-
provider_to_init = provider
144+
# Spec 1.1.2.3: Only shutdown old provider if it's no longer bound to any domain
145+
if old_provider && !old_provider.equal?(provider)
146+
still_bound = @providers.any? { |_, p| p.equal?(old_provider) }
147+
needs_shutdown = !still_bound
148+
@provider_state_registry.remove_provider(old_provider) unless still_bound
149+
end
133150
end
134151

135152
# Shutdown old provider outside mutex to avoid blocking other operations
136-
# Only shutdown if it's a different provider to prevent race condition
137-
if old_provider && old_provider != provider
153+
if needs_shutdown
138154
begin
139155
old_provider.shutdown if old_provider.respond_to?(:shutdown)
140156
rescue => e
@@ -143,12 +159,17 @@ def set_provider_internal(provider, domain:, wait_for_init:)
143159
end
144160

145161
# Initialize provider outside the mutex to avoid blocking other operations
146-
if wait_for_init
147-
init_provider(provider_to_init, context_for_init, raise_on_error: true)
148-
else
149-
Thread.new do
150-
init_provider(provider_to_init, context_for_init, raise_on_error: false)
162+
if needs_init
163+
if wait_for_init
164+
init_provider(provider, context_for_init, raise_on_error: true)
165+
else
166+
Thread.new do
167+
init_provider(provider, context_for_init, raise_on_error: false)
168+
end
151169
end
170+
elsif wait_for_init
171+
# Provider already active; no init needed but still dispatch READY
172+
dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
152173
end
153174
end
154175

@@ -164,16 +185,22 @@ def init_provider(provider, context, raise_on_error: false)
164185

165186
dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
166187
rescue => e
188+
# Spec 1.7.8: Propagate error code from provider if available
189+
error_code = if e.respond_to?(:error_code) && e.error_code
190+
e.error_code
191+
else
192+
Provider::ErrorCode::GENERAL
193+
end
194+
167195
dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
168-
error_code: Provider::ErrorCode::GENERAL,
196+
error_code: error_code,
169197
message: e.message)
170198

171199
if raise_on_error
172-
# Re-raise as ProviderInitializationError for synchronous callers
173200
raise ProviderInitializationError.new(
174201
"Provider #{provider.class.name} initialization failed: #{e.message}",
175202
provider:,
176-
error_code: Provider::ErrorCode::GENERAL,
203+
error_code: error_code,
177204
original_error: e
178205
)
179206
end

lib/open_feature/sdk/hooks/hook_executor.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ def initialize(logger: nil)
1818
@logger = logger
1919
end
2020

21+
# Runs error hooks and finally hooks for a short-circuit evaluation
22+
# (spec 1.7.6 + 1.7.7). Before hooks and after hooks are NOT run.
23+
#
24+
# @param ordered_hooks [Array] hooks in before-order
25+
# @param hook_context [HookContext] the hook context
26+
# @param hints [Hints] hook hints
27+
# @param evaluation_details [EvaluationDetails] the short-circuit result
28+
def run_short_circuit(ordered_hooks:, hook_context:, hints:, evaluation_details:)
29+
error = StandardError.new(evaluation_details.error_code)
30+
run_error_hooks(ordered_hooks, hook_context, error, hints)
31+
ensure
32+
run_finally_hooks(ordered_hooks, hook_context, evaluation_details, hints)
33+
end
34+
2135
# Executes the full hook lifecycle around the flag evaluation block.
2236
#
2337
# @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)

lib/open_feature/sdk/provider/in_memory_provider.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ def add_flag(flag_key:, value:)
2929
end
3030

3131
def update_flags(new_flags)
32+
old_keys = @flags.keys
3233
@flags = new_flags.dup
33-
emit_provider_changed(new_flags.keys)
34+
changed_keys = (old_keys | new_flags.keys)
35+
emit_provider_changed(changed_keys)
3436
end
3537

3638
def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)

spec/open_feature/sdk/provider/in_memory_provider_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
end
7373

7474
context "when attached to configuration" do
75-
it "emits PROVIDER_CONFIGURATION_CHANGED event with all flag keys" do
75+
it "emits PROVIDER_CONFIGURATION_CHANGED event with union of old and new flag keys" do
7676
config = instance_double("OpenFeature::SDK::Configuration")
7777
allow(config).to receive(:dispatch_provider_event)
7878
provider.send(:attach, config)
@@ -82,7 +82,7 @@
8282
expect(config).to have_received(:dispatch_provider_event).with(
8383
provider,
8484
OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED,
85-
flags_changed: contain_exactly("flag_a", "flag_b")
85+
flags_changed: contain_exactly("bool", "str", "int", "float", "struct", "flag_a", "flag_b")
8686
)
8787
end
8888
end

0 commit comments

Comments
 (0)