openfeature-sdk 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf1041c34e962f832b25f1be3e9eeab7fee3fd4272dc9e1e0e8dabe85b6d21e8
4
- data.tar.gz: f45667a6a898d3521ea87c31aea62491ace0682c72ef2cfa2a16d5f8a0f3f0b5
3
+ metadata.gz: cf021dbf998f01e1b0f6e69723e65e32b7f5d36e91381963988af89daabcbfd9
4
+ data.tar.gz: bbe22d9c478d5e2608d888d8a627dcdf3477b99f31b9b5114fce071fa18fb669
5
5
  SHA512:
6
- metadata.gz: 393adb8b637763e5b397f6ee702017705bf355c8785ceb1280aa4f95b585a061f8da6e4a6d67ed9e8ab20de1f341b36d4dc3febb38dbbcc7a41a68cfb8002b8c
7
- data.tar.gz: d145cc98fb609aa75ad06485d4486cc6aec19ea953cd627f9f71b33e991fafd3a57fa139fbe07f878e5bc105885ce35a74c0ab12485e50649d63b79a100eec98
6
+ metadata.gz: 417abbc5e8c3e216e5bfd6299bad2f4f46396ed7e3e2a712c7e1a9c92639fa82cde9a904616854428ad54de10986e8537183b6a5efc959e3c7350943874e7a37
7
+ data.tar.gz: e715404890c82fd6b36b3d7c965a6c0e4eea06c80973ca25f3932aba943636ceb5addfe46ce06fdd59de4d5332e704e29dcb28f16ad7a690869b4da94a217524
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.7.1"
2
+ ".": "0.8.1"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.1](https://github.com/open-feature/ruby-sdk/compare/v0.8.0...v0.8.1) (2026-10-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * close the spec v0.9.0 gaps the compliance audit found ([#361](https://github.com/open-feature/ruby-sdk/issues/361)) ([1ecbb58](https://github.com/open-feature/ruby-sdk/commit/1ecbb58d52a26cdc6c2e187bdf61d037d7309b20))
9
+
10
+ ## [0.8.0](https://github.com/open-feature/ruby-sdk/compare/v0.7.1...v0.8.0) (2026-10-23)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * make hook inputs immutable ([#357](https://github.com/open-feature/ruby-sdk/issues/357))
16
+
17
+ ### Bug Fixes
18
+
19
+ * do not mutate the provider's ResolutionDetails ([#358](https://github.com/open-feature/ruby-sdk/issues/358)) ([ec657a5](https://github.com/open-feature/ruby-sdk/commit/ec657a5fc7b1514828ea2fa1cb6bfe017029f5af))
20
+ * make hook inputs immutable ([#357](https://github.com/open-feature/ruby-sdk/issues/357)) ([9e166fe](https://github.com/open-feature/ruby-sdk/commit/9e166fe09ec2a10d220295b600141cb351516b24))
21
+
3
22
  ## [0.7.1](https://github.com/open-feature/ruby-sdk/compare/v0.7.0...v0.7.1) (2026-10-23)
4
23
 
5
24
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openfeature-sdk (0.7.1)
4
+ openfeature-sdk (0.8.1)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -12,13 +12,13 @@
12
12
  <!-- x-hide-in-docs-end -->
13
13
  <!-- The 'github-badges' class is used in the docs -->
14
14
  <p align="center" class="github-badges">
15
- <a href="https://github.com/open-feature/spec/releases/tag/v0.8.0">
16
- <img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge" />
15
+ <a href="https://github.com/open-feature/spec/releases/tag/v0.9.0">
16
+ <img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.9.0&color=yellow&style=for-the-badge" />
17
17
  </a>
18
18
  <!-- x-release-please-start-version -->
19
19
 
20
- <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.7.1">
21
- <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.7.1&color=blue&style=for-the-badge" />
20
+ <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.8.1">
21
+ <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.8.1&color=blue&style=for-the-badge" />
22
22
  </a>
23
23
 
24
24
  <!-- x-release-please-end -->
@@ -506,6 +506,12 @@ class MyProvider
506
506
  # Return value is ignored
507
507
  end
508
508
 
509
+ # Optional: hooks the SDK runs on every evaluation that uses this provider (spec 2.3.1).
510
+ # They run after API, client and invocation hooks in the before stage, and first in the after, error and finally stages.
511
+ def hooks
512
+ []
513
+ end
514
+
509
515
  def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
510
516
  # Retrieve a boolean value from provider source
511
517
  # To signal an error, return ResolutionDetails with an error_code, or raise
@@ -566,6 +572,12 @@ class MyLoggingHook
566
572
  end
567
573
  ```
568
574
 
575
+ Hooks can read the hook context and hints but not change them:
576
+
577
+ - Only `before` hooks can change the evaluation context: return an `EvaluationContext` for the SDK to merge in, or assign `hook_context.evaluation_context`. By the time `after`, `error` and `finally` run, it's a deep-frozen copy, so assigning it or changing its values raises `FrozenError`. Your own context isn't frozen.
578
+ - The flag key, the default value, the hints, and the client and provider metadata are frozen copies. Your own objects aren't frozen.
579
+ - Only the spec's value types are copied: strings, numbers, booleans, times, hashes and arrays. Other objects, such as a user or a record you put in the context, are passed to hooks as they are. A time is copied but left unfrozen, so `utc` and `localtime` still work, and a hash's default proc isn't copied.
580
+
569
581
  > Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
570
582
 
571
583
  <!-- x-hide-in-docs-start -->
@@ -30,7 +30,7 @@ module OpenFeature
30
30
  # If no provider is specified, the <tt>NoOpProvider</tt> is set as the default <tt>Provider</tt>.
31
31
  # Once the SDK has been configured, a client can be built
32
32
  #
33
- # client = OpenFeature::SDK::API.instance.build_client(name: 'my-open-feature-client')
33
+ # client = OpenFeature::SDK::API.instance.build_client(domain: 'my-open-feature-client')
34
34
  class API
35
35
  include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1; Isolated.create_api builds others (1.8.1)
36
36
  extend Forwardable
@@ -58,7 +58,8 @@ module OpenFeature
58
58
  built_context = build_evaluation_context(evaluation_context)
59
59
 
60
60
  provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
61
- rescue StandardError, NotImplementedError
61
+ nil # Spec 6.1.1.1: returns nothing
62
+ rescue *RECOVERABLE_ERRORS
62
63
  nil
63
64
  end
64
65
 
@@ -134,12 +135,12 @@ module OpenFeature
134
135
  executor.run_short_circuit(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, evaluation_details: evaluation_details)
135
136
  evaluation_details
136
137
  else
137
- executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
138
+ executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, default_value:) do |hctx|
138
139
  evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
139
140
  end
140
141
  end
141
- rescue StandardError, NotImplementedError => e
142
- # Spec 1.4.10 and 1.4.11: report through the details, never raise or log. NotImplementedError is the abstract-method idiom.
142
+ rescue *RECOVERABLE_ERRORS => e
143
+ # Spec 1.4.10 and 1.4.11: report through the details, never raise or log
143
144
  resolution = Provider::ResolutionDetails.new(
144
145
  value: default_value,
145
146
  error_code: EvaluationError.code_for(e),
@@ -157,11 +158,16 @@ module OpenFeature
157
158
  evaluation_context: evaluation_context
158
159
  )
159
160
 
160
- if resolution_details.error_code || TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
161
- resolution_details.value = default_value
162
- resolution_details.error_code ||= Provider::ErrorCode::TYPE_MISMATCH
163
- resolution_details.reason = Provider::Reason::ERROR
164
- resolution_details.variant = nil
161
+ provider_error = resolution_details.error_code
162
+ if provider_error || TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
163
+ # A new struct, since a provider may cache and reuse the one it returned
164
+ resolution_details = Provider::ResolutionDetails.new(
165
+ value: default_value,
166
+ reason: Provider::Reason::ERROR,
167
+ error_code: provider_error || Provider::ErrorCode::TYPE_MISMATCH,
168
+ error_message: provider_error ? resolution_details.error_message : "Expected #{type}, got #{resolution_details.value.class}",
169
+ flag_metadata: resolution_details.flag_metadata
170
+ )
165
171
  end
166
172
 
167
173
  EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
@@ -110,7 +110,7 @@ module OpenFeature
110
110
  @provider_state_registry.set_initial_state(prov, ProviderState::NOT_READY)
111
111
  prov.send(:detach) if prov.is_a?(Provider::EventEmitter)
112
112
  prov.shutdown if prov.respond_to?(:shutdown)
113
- rescue => e
113
+ rescue *RECOVERABLE_ERRORS => e
114
114
  @logger&.warn("Error shutting down provider #{prov&.class&.name || "unknown"}: #{e.message}")
115
115
  end
116
116
 
@@ -165,7 +165,8 @@ module OpenFeature
165
165
  # First, so a provider registered with another API (1.8.4) leaves no state behind
166
166
  provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
167
167
  attempt = @init_attempts[provider] = InitAttempt.new(:running, nil, Thread::Queue.new)
168
- @provider_state_registry.set_initial_state(provider)
168
+ # Spec 2.8.5.1: a provider without init is READY from registration
169
+ @provider_state_registry.set_initial_state(provider, has_init ? ProviderState::NOT_READY : ProviderState::READY)
169
170
  end
170
171
 
171
172
  # Publish last: clients read @providers without the lock, and must not see an untracked provider
@@ -184,30 +185,42 @@ module OpenFeature
184
185
  end
185
186
  end
186
187
 
187
- # Shutdown old provider outside mutex to avoid blocking other operations
188
- if needs_shutdown
189
- begin
190
- old_provider.shutdown if old_provider.respond_to?(:shutdown)
191
- rescue => e
192
- @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || "unknown"}: #{e.message}")
188
+ begin
189
+ # Spec 2.8.5.1: a provider without init is READY from registration, so its READY handlers run now, before the
190
+ # old provider's shutdown; after it, a handler added during the shutdown would run twice
191
+ unless has_init
192
+ if needs_init
193
+ init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: wait_for_init)
194
+ raise incomplete_init_error(provider, "initialization did not complete") if wait_for_init && attempt.state != :succeeded
195
+ elsif !old_provider.equal?(provider)
196
+ # Each new binding runs its domain's READY handlers
197
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt)
198
+ end
199
+ end
200
+ ensure
201
+ # Shutdown old provider outside mutex to avoid blocking other operations. Checked again, since a READY
202
+ # handler above may have bound it again (a rollback)
203
+ if needs_shutdown && !provider_bound?(old_provider)
204
+ begin
205
+ old_provider.shutdown if old_provider.respond_to?(:shutdown)
206
+ rescue *RECOVERABLE_ERRORS => e
207
+ @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || "unknown"}: #{e.message}")
208
+ end
193
209
  end
194
210
  end
211
+ return unless has_init
195
212
 
196
213
  # Initialize provider outside the mutex to avoid blocking other operations
197
214
  if needs_init
198
- # Spec 2.8.5.1: a provider without init is READY, and its handlers have run, when set_provider returns
199
- if wait_for_init || !has_init
200
- init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: wait_for_init)
215
+ if wait_for_init
216
+ init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: true)
201
217
  # Replaced or shut down while init ran: fail like the waiters on this attempt did
202
- raise incomplete_init_error(provider, "initialization did not complete") if wait_for_init && attempt.state != :succeeded
218
+ raise incomplete_init_error(provider, "initialization did not complete") if attempt.state != :succeeded
203
219
  else
204
220
  init_thread = Thread.new do
205
221
  init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: false)
206
222
  end
207
223
  end
208
- elsif !has_init
209
- # Spec 2.8.5.1: each new binding of a provider without init runs its domain's READY handlers
210
- dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) unless old_provider.equal?(provider)
211
224
  elsif wait_for_init
212
225
  # Already registered: wait for an init still in flight, then report its outcome.
213
226
  # From inside that init (e.g. a handler of an event it emits) the wait could never end.
@@ -232,7 +245,7 @@ module OpenFeature
232
245
  # An abandoned attempt (provider replaced or shut down meanwhile) reports no READY.
233
246
  # Spec 2.8.2/2.8.3: a provider that emits its own lifecycle events gets no synthetic READY or ERROR
234
247
  dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if finish_init_attempt(attempt, :succeeded) && !emits_lifecycle_events
235
- rescue => e
248
+ rescue *RECOVERABLE_ERRORS => e
236
249
  # Spec 1.7.5: PROVIDER_FATAL from init makes the provider FATAL
237
250
  error_code = EvaluationError.code_for(e)
238
251
  error = ProviderInitializationError.new(
@@ -250,7 +263,7 @@ module OpenFeature
250
263
 
251
264
  raise error if raise_on_error
252
265
  ensure
253
- # Reached unsettled only when init raised something other than a StandardError
266
+ # Reached unsettled only when init raised something not in RECOVERABLE_ERRORS, such as an Interrupt
254
267
  finish_init_attempt(attempt, :failed)
255
268
  end
256
269
 
@@ -296,7 +309,7 @@ module OpenFeature
296
309
  until queue.empty?
297
310
  begin
298
311
  deliver_provider_event(provider, *queue.shift)
299
- rescue => e
312
+ rescue *RECOVERABLE_ERRORS => e
300
313
  # Its own emitter already returned, so log rather than fail the event that was dispatching
301
314
  @logger&.error("Queued provider event failed: #{e.message}")
302
315
  end
@@ -405,7 +418,7 @@ module OpenFeature
405
418
 
406
419
  handlers.each do |handler|
407
420
  handler.call(event_details)
408
- rescue => e
421
+ rescue *RECOVERABLE_ERRORS => e
409
422
  @logger&.error("Client event handler failed: #{e.message}")
410
423
  end
411
424
  end
@@ -430,7 +443,7 @@ module OpenFeature
430
443
 
431
444
  begin
432
445
  handler.call(event_details)
433
- rescue => e
446
+ rescue *RECOVERABLE_ERRORS => e
434
447
  @logger&.error("Immediate API event handler failed: #{e.message}")
435
448
  end
436
449
  end
@@ -447,7 +460,7 @@ module OpenFeature
447
460
 
448
461
  begin
449
462
  handler.call(event_details)
450
- rescue => e
463
+ rescue *RECOVERABLE_ERRORS => e
451
464
  @logger&.error("Immediate client event handler failed: #{e.message}")
452
465
  end
453
466
  end
@@ -26,6 +26,16 @@ module OpenFeature
26
26
  )
27
27
  end
28
28
 
29
+ def initialize_copy(source)
30
+ super
31
+ @fields = @fields.dup
32
+ end
33
+
34
+ def freeze
35
+ fields.freeze
36
+ super
37
+ end
38
+
29
39
  def ==(other)
30
40
  fields == other.fields
31
41
  end
@@ -4,6 +4,10 @@ require_relative "provider/error_code"
4
4
 
5
5
  module OpenFeature
6
6
  module SDK
7
+ # What the SDK reports or logs, rather than raises, when provider, hook or handler code fails.
8
+ # NotImplementedError isn't a StandardError, but it's Ruby's abstract-method idiom; Interrupt and the rest still propagate.
9
+ RECOVERABLE_ERRORS = [StandardError, NotImplementedError].freeze
10
+
7
11
  # Raised by a provider to signal an evaluation error with an OpenFeature
8
12
  # error code (spec 2.2.7). Any exception responding to +error_code+ is
9
13
  # honored the same way.
@@ -55,7 +55,7 @@ module OpenFeature
55
55
  # Call handlers outside of mutex to avoid deadlocks
56
56
  handlers_to_call.each do |handler|
57
57
  handler.call(event_details)
58
- rescue => e
58
+ rescue *RECOVERABLE_ERRORS => e
59
59
  @logger&.warn "Event handler failed for #{event_type}: #{e.message}\n#{e.backtrace.join("\n")}"
60
60
  end
61
61
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFeature
4
+ module SDK
5
+ # Deep-frozen copies of the spec's value types (spec 3.1.2), so whoever gets one can't change it and the original is
6
+ # never frozen (spec 1.4.15.1, 4.1.3, 4.1.4.1, 4.2.2)
7
+ module FrozenCopy
8
+ def self.of(value, seen = nil)
9
+ return value if defined?(Ractor) && Ractor.shareable?(value)
10
+
11
+ case value
12
+ when String then value.frozen? ? value : value.dup.freeze
13
+ when Time then value.dup # Left unfrozen: utc and localtime convert in place, and on a copy they touch nothing else
14
+ when Hash, Array
15
+ seen ||= {}.compare_by_identity
16
+ return seen[value] if seen.key?(value)
17
+
18
+ # Registered before its items, so a cycle or a shared structure maps to this one copy
19
+ copy = seen[value] = value.dup
20
+ if copy.is_a?(Hash)
21
+ if copy.default_proc
22
+ copy.default_proc = nil # Behavior rather than data, and on a frozen copy one that stores would raise
23
+ elsif !copy.default.nil?
24
+ copy.default = of(copy.default, seen)
25
+ end
26
+ copy.transform_values! { |item| of(item, seen) }
27
+ else
28
+ copy.map! { |item| of(item, seen) }
29
+ end
30
+ copy.freeze
31
+ else
32
+ value # Anything else, like a user object or a record, is passed through as is
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "delegate"
4
+ require_relative "../frozen_copy"
4
5
 
5
6
  module OpenFeature
6
7
  module SDK
@@ -13,8 +14,7 @@ module OpenFeature
13
14
  assert_allowed_key(key)
14
15
  assert_allowed_value(value)
15
16
  end
16
- @hash = hash.dup
17
- super(@hash)
17
+ super(FrozenCopy.of(hash))
18
18
  freeze
19
19
  end
20
20
 
@@ -14,17 +14,17 @@ module OpenFeature
14
14
  nil
15
15
  end
16
16
 
17
- # Called after successful flag evaluation (spec 4.3.3).
17
+ # Called after successful flag evaluation (spec 4.3.6).
18
18
  def after(hook_context:, evaluation_details:, hints:)
19
19
  nil
20
20
  end
21
21
 
22
- # Called when an error occurs during flag evaluation (spec 4.3.6).
22
+ # Called when an error occurs during flag evaluation (spec 4.3.7).
23
23
  def error(hook_context:, exception:, hints:)
24
24
  nil
25
25
  end
26
26
 
27
- # Called unconditionally after flag evaluation (spec 4.3.7).
27
+ # Called unconditionally after flag evaluation (spec 4.3.8).
28
28
  def finally(hook_context:, evaluation_details:, hints:)
29
29
  nil
30
30
  end
@@ -1,28 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../frozen_copy"
4
+
3
5
  module OpenFeature
4
6
  module SDK
5
7
  module Hooks
6
8
  # Provides context to hook stages during flag evaluation.
7
9
  #
8
- # Per spec 4.1.1-4.1.5:
10
+ # Per spec 4.1.1-4.1.5 and 4.2.2:
9
11
  # - flag_key, flag_value_type, default_value are immutable (4.1.3)
10
- # - client_metadata, provider_metadata are optional (4.1.2)
11
- # - evaluation_context is mutable (for before hooks to modify, 4.1.4.1)
12
+ # - client_metadata, provider_metadata are optional (4.1.2) and immutable (4.2.2.2, 4.2.2.3)
13
+ # - evaluation_context is mutable only in before hooks; HookExecutor freezes the hook context after them (4.1.4.1)
12
14
  class HookContext
13
- attr_reader :flag_key, :flag_value_type, :default_value,
14
- :client_metadata, :provider_metadata
15
- attr_accessor :evaluation_context
15
+ attr_reader :flag_key, :flag_value_type, :client_metadata, :provider_metadata
16
+ attr_writer :evaluation_context
16
17
 
17
18
  def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
18
19
  client_metadata: nil, provider_metadata: nil)
19
- @flag_key = flag_key.freeze
20
+ @flag_key = FrozenCopy.of(flag_key)
20
21
  @flag_value_type = flag_value_type.freeze
21
- @default_value = default_value.freeze
22
+ @source_default_value = default_value
22
23
  @evaluation_context = evaluation_context
23
24
  @client_metadata = client_metadata
24
- @provider_metadata = provider_metadata
25
+ @provider_metadata = frozen_metadata(provider_metadata)
25
26
  @hook_data = {}
27
+ @copies = {}
28
+ end
29
+
30
+ # Copied on first read: most hooks never read it, and the copy grows with the value
31
+ def default_value
32
+ @copies.fetch(:default_value) { @copies[:default_value] = FrozenCopy.of(@source_default_value) }
33
+ end
34
+
35
+ # Live during the before stage; once frozen, a deep-frozen copy made on first read, so neither the caller's
36
+ # context nor one a before hook assigned is frozen
37
+ def evaluation_context
38
+ return @evaluation_context unless frozen?
39
+
40
+ @copies.fetch(:evaluation_context) do
41
+ @copies[:evaluation_context] = @evaluation_context&.dup&.tap do |copy|
42
+ seen = {}.compare_by_identity # One map for every field, so a structure two fields share is copied once
43
+ copy.fields.transform_values! { |value| FrozenCopy.of(value, seen) }
44
+ end&.freeze
45
+ end
26
46
  end
27
47
 
28
48
  # Returns a mutable hash scoped to the given hook instance.
@@ -31,6 +51,15 @@ module OpenFeature
31
51
  def hook_data_for(hook)
32
52
  @hook_data[hook.object_id] ||= {}
33
53
  end
54
+
55
+ private
56
+
57
+ # FrozenCopy passes Structs through, so ProviderMetadata's members are copied here (spec 4.2.2.3)
58
+ def frozen_metadata(metadata)
59
+ return metadata if !metadata.is_a?(Struct) || (defined?(Ractor) && Ractor.shareable?(metadata))
60
+
61
+ metadata.dup.tap { |copy| copy.each_pair { |member, value| copy[member] = FrozenCopy.of(value) } }.freeze
62
+ end
34
63
  end
35
64
  end
36
65
  end
@@ -13,6 +13,9 @@ module OpenFeature
13
13
  # - Before/after hook error → stop remaining hooks, run error hooks, return default
14
14
  # - Error hook error → log, continue remaining error hooks
15
15
  # - Finally hook error → log, continue remaining finally hooks
16
+ #
17
+ # The hook context is frozen before after, error and finally hooks run, so only before hooks
18
+ # can change the evaluation context (spec 4.1.4.1). Not earlier: some providers write to it.
16
19
  class HookExecutor
17
20
  def initialize(logger: nil)
18
21
  @logger = logger
@@ -37,16 +40,17 @@ module OpenFeature
37
40
  # @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)
38
41
  # @param hook_context [HookContext] the hook context
39
42
  # @param hints [Hints] hook hints
43
+ # @param default_value [Object] returned if evaluation raises; the caller's own object, not the hooks' frozen copy
40
44
  # @param evaluate_block [Proc] the flag evaluation to wrap
41
45
  # @return [EvaluationDetails] the evaluation result
42
- def execute(ordered_hooks:, hook_context:, hints:, &evaluate_block)
46
+ def execute(ordered_hooks:, hook_context:, hints:, default_value:, &evaluate_block)
43
47
  evaluation_details = nil
44
48
 
45
49
  begin
46
50
  run_before_hooks(ordered_hooks, hook_context, hints)
47
51
  evaluation_details = evaluate_block.call(hook_context)
48
52
 
49
- # Spec 4.3.6: If evaluation resulted in an error (e.g. FLAG_NOT_FOUND,
53
+ # Spec 4.3.7: If evaluation resulted in an error (e.g. FLAG_NOT_FOUND,
50
54
  # TYPE_MISMATCH), run error hooks instead of after hooks.
51
55
  if evaluation_details.error_code
52
56
  error = EvaluationError.new(evaluation_details.error_message, error_code: evaluation_details.error_code)
@@ -54,13 +58,13 @@ module OpenFeature
54
58
  else
55
59
  run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
56
60
  end
57
- rescue StandardError, NotImplementedError => e
61
+ rescue *RECOVERABLE_ERRORS => e
58
62
  run_error_hooks(ordered_hooks, hook_context, e, hints)
59
63
 
60
64
  evaluation_details = EvaluationDetails.new(
61
65
  flag_key: hook_context.flag_key,
62
66
  resolution_details: Provider::ResolutionDetails.new(
63
- value: hook_context.default_value,
67
+ value: default_value,
64
68
  error_code: EvaluationError.code_for(e),
65
69
  reason: Provider::Reason::ERROR,
66
70
  error_message: e.message
@@ -91,6 +95,7 @@ module OpenFeature
91
95
 
92
96
  # Spec 4.4.2: After hooks run in reverse order: Provider → Invocation → Client → API
93
97
  def run_after_hooks(hooks, hook_context, evaluation_details, hints)
98
+ hook_context.freeze
94
99
  hooks.reverse_each do |hook|
95
100
  next unless hook.respond_to?(:after)
96
101
  hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
@@ -100,10 +105,11 @@ module OpenFeature
100
105
  # Spec 4.4.4: Error hooks run in reverse order.
101
106
  # If an error hook itself errors, log and continue remaining error hooks.
102
107
  def run_error_hooks(hooks, hook_context, exception, hints)
108
+ hook_context.freeze
103
109
  hooks.reverse_each do |hook|
104
110
  next unless hook.respond_to?(:error)
105
111
  hook.error(hook_context: hook_context, exception: exception, hints: hints)
106
- rescue => e
112
+ rescue *RECOVERABLE_ERRORS => e
107
113
  @logger&.error("Error hook #{hook.class.name} failed: #{e.message}")
108
114
  end
109
115
  end
@@ -111,10 +117,11 @@ module OpenFeature
111
117
  # Spec 4.4.3: Finally hooks run in reverse order unconditionally.
112
118
  # If a finally hook errors, log and continue remaining finally hooks.
113
119
  def run_finally_hooks(hooks, hook_context, evaluation_details, hints)
120
+ hook_context.freeze
114
121
  hooks.reverse_each do |hook|
115
122
  next unless hook.respond_to?(:finally)
116
123
  hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
117
- rescue => e
124
+ rescue *RECOVERABLE_ERRORS => e
118
125
  @logger&.error("Finally hook #{hook.class.name} failed: #{e.message}")
119
126
  end
120
127
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../frozen_copy"
4
+
3
5
  module OpenFeature
4
6
  module SDK
5
7
  module Provider
@@ -7,14 +9,7 @@ module OpenFeature
7
9
 
8
10
  ResolutionDetails = Struct.new(:value, :reason, :variant, :error_code, :error_message, :flag_metadata, keyword_init: true) do
9
11
  def flag_metadata
10
- raw = self[:flag_metadata]
11
- if raw.nil?
12
- EMPTY_FLAG_METADATA
13
- elsif raw.frozen?
14
- raw
15
- else
16
- raw.dup.freeze
17
- end
12
+ FrozenCopy.of(self[:flag_metadata] || EMPTY_FLAG_METADATA)
18
13
  end
19
14
  end
20
15
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- VERSION = "0.7.1"
5
+ VERSION = "0.8.1"
6
6
  end
7
7
  end
@@ -13,6 +13,10 @@ module OpenFeature
13
13
 
14
14
  def merge: (EvaluationContext overriding_context) -> EvaluationContext
15
15
 
16
+ def initialize_copy: (EvaluationContext source) -> void
17
+
18
+ def freeze: () -> self
19
+
16
20
  def ==: (untyped other) -> bool
17
21
  end
18
22
  end
@@ -1,5 +1,7 @@
1
1
  module OpenFeature
2
2
  module SDK
3
+ RECOVERABLE_ERRORS: Array[singleton(Exception)]
4
+
3
5
  class EvaluationError < StandardError
4
6
  attr_reader error_code: String
5
7
 
@@ -0,0 +1,7 @@
1
+ module OpenFeature
2
+ module SDK
3
+ module FrozenCopy
4
+ def self.of: (untyped value, ?Hash[untyped, untyped]? seen) -> untyped
5
+ end
6
+ end
7
+ end
@@ -4,14 +4,21 @@ module OpenFeature
4
4
  class HookContext
5
5
  attr_reader flag_key: String
6
6
  attr_reader flag_value_type: Symbol
7
- attr_reader default_value: untyped
8
7
  attr_reader client_metadata: ClientMetadata?
9
8
  attr_reader provider_metadata: Provider::ProviderMetadata?
10
- attr_accessor evaluation_context: EvaluationContext?
9
+ attr_writer evaluation_context: EvaluationContext?
11
10
 
12
11
  def initialize: (flag_key: String, flag_value_type: Symbol, default_value: untyped, evaluation_context: EvaluationContext?, ?client_metadata: ClientMetadata?, ?provider_metadata: Provider::ProviderMetadata?) -> void
13
12
 
13
+ def default_value: () -> untyped
14
+
15
+ def evaluation_context: () -> EvaluationContext?
16
+
14
17
  def hook_data_for: (untyped hook) -> Hash[untyped, untyped]
18
+
19
+ private
20
+
21
+ def frozen_metadata: (untyped metadata) -> untyped
15
22
  end
16
23
  end
17
24
  end
@@ -6,7 +6,7 @@ module OpenFeature
6
6
 
7
7
  def run_short_circuit: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints, evaluation_details: EvaluationDetails) -> void
8
8
 
9
- def execute: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints) { (HookContext) -> EvaluationDetails } -> EvaluationDetails?
9
+ def execute: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints, default_value: untyped) { (HookContext) -> EvaluationDetails } -> EvaluationDetails?
10
10
 
11
11
  private
12
12
 
@@ -20,6 +20,10 @@ module OpenFeature
20
20
  def domain_scoped?: () -> bool
21
21
  end
22
22
 
23
+ interface _HookProvider
24
+ def hooks: () -> Array[Hooks::_Hook]
25
+ end
26
+
23
27
  interface _TrackableProvider
24
28
  def track: (String tracking_event_name, ?evaluation_context: EvaluationContext?, ?tracking_event_details: TrackingEventDetails?) -> void
25
29
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openfeature-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenFeature Authors
@@ -49,6 +49,7 @@ files:
49
49
  - lib/open_feature/sdk/evaluation_details.rb
50
50
  - lib/open_feature/sdk/evaluation_error.rb
51
51
  - lib/open_feature/sdk/event_dispatcher.rb
52
+ - lib/open_feature/sdk/frozen_copy.rb
52
53
  - lib/open_feature/sdk/hooks.rb
53
54
  - lib/open_feature/sdk/hooks/hints.rb
54
55
  - lib/open_feature/sdk/hooks/hook.rb
@@ -88,6 +89,7 @@ files:
88
89
  - sig/open_feature/sdk/evaluation_details.rbs
89
90
  - sig/open_feature/sdk/evaluation_error.rbs
90
91
  - sig/open_feature/sdk/event_dispatcher.rbs
92
+ - sig/open_feature/sdk/frozen_copy.rbs
91
93
  - sig/open_feature/sdk/hooks/hints.rbs
92
94
  - sig/open_feature/sdk/hooks/hook.rbs
93
95
  - sig/open_feature/sdk/hooks/hook_context.rbs