openfeature-sdk 0.8.0 → 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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -0
- data/Gemfile.lock +1 -1
- data/README.md +13 -6
- data/lib/open_feature/sdk/api.rb +1 -1
- data/lib/open_feature/sdk/client.rb +4 -3
- data/lib/open_feature/sdk/configuration.rb +34 -21
- data/lib/open_feature/sdk/evaluation_error.rb +4 -0
- data/lib/open_feature/sdk/event_dispatcher.rb +1 -1
- data/lib/open_feature/sdk/frozen_copy.rb +37 -0
- data/lib/open_feature/sdk/hooks/hints.rb +2 -2
- data/lib/open_feature/sdk/hooks/hook.rb +3 -3
- data/lib/open_feature/sdk/hooks/hook_context.rb +25 -13
- data/lib/open_feature/sdk/hooks/hook_executor.rb +4 -4
- data/lib/open_feature/sdk/provider/resolution_details.rb +3 -8
- data/lib/open_feature/sdk/version.rb +1 -1
- data/sig/open_feature/sdk/evaluation_error.rbs +2 -0
- data/sig/open_feature/sdk/frozen_copy.rbs +7 -0
- data/sig/open_feature/sdk/hooks/hook_context.rbs +6 -2
- data/sig/open_feature/sdk/provider.rbs +4 -0
- metadata +3 -3
- data/lib/open_feature/sdk/hooks/frozen_copy.rb +0 -14
- data/sig/open_feature/sdk/hooks/frozen_copy.rbs +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf021dbf998f01e1b0f6e69723e65e32b7f5d36e91381963988af89daabcbfd9
|
|
4
|
+
data.tar.gz: bbe22d9c478d5e2608d888d8a627dcdf3477b99f31b9b5114fce071fa18fb669
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 417abbc5e8c3e216e5bfd6299bad2f4f46396ed7e3e2a712c7e1a9c92639fa82cde9a904616854428ad54de10986e8537183b6a5efc959e3c7350943874e7a37
|
|
7
|
+
data.tar.gz: e715404890c82fd6b36b3d7c965a6c0e4eea06c80973ca25f3932aba943636ceb5addfe46ce06fdd59de4d5332e704e29dcb28f16ad7a690869b4da94a217524
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## [0.8.0](https://github.com/open-feature/ruby-sdk/compare/v0.7.1...v0.8.0) (2026-10-23)
|
|
4
11
|
|
|
5
12
|
|
data/Gemfile.lock
CHANGED
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.
|
|
16
|
-
<img alt="Specification" src="https://img.shields.io/static/v1?label=specification&message=v0.
|
|
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.8.
|
|
21
|
-
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.8.
|
|
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,10 +572,11 @@ class MyLoggingHook
|
|
|
566
572
|
end
|
|
567
573
|
```
|
|
568
574
|
|
|
569
|
-
Hooks can read
|
|
575
|
+
Hooks can read the hook context and hints but not change them:
|
|
570
576
|
|
|
571
|
-
- 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 frozen copy, so assigning it or
|
|
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.
|
|
572
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.
|
|
573
580
|
|
|
574
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!
|
|
575
582
|
|
data/lib/open_feature/sdk/api.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
61
|
+
nil # Spec 6.1.1.1: returns nothing
|
|
62
|
+
rescue *RECOVERABLE_ERRORS
|
|
62
63
|
nil
|
|
63
64
|
end
|
|
64
65
|
|
|
@@ -138,8 +139,8 @@ module OpenFeature
|
|
|
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
|
|
142
|
-
# Spec 1.4.10 and 1.4.11: report through the details, never raise or log
|
|
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),
|
|
@@ -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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
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
|
|
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
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "delegate"
|
|
4
|
-
require_relative "frozen_copy"
|
|
4
|
+
require_relative "../frozen_copy"
|
|
5
5
|
|
|
6
6
|
module OpenFeature
|
|
7
7
|
module SDK
|
|
@@ -14,7 +14,7 @@ module OpenFeature
|
|
|
14
14
|
assert_allowed_key(key)
|
|
15
15
|
assert_allowed_value(value)
|
|
16
16
|
end
|
|
17
|
-
super(
|
|
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.
|
|
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.
|
|
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.
|
|
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,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "frozen_copy"
|
|
3
|
+
require_relative "../frozen_copy"
|
|
4
4
|
|
|
5
5
|
module OpenFeature
|
|
6
6
|
module SDK
|
|
@@ -13,33 +13,36 @@ module OpenFeature
|
|
|
13
13
|
# - evaluation_context is mutable only in before hooks; HookExecutor freezes the hook context after them (4.1.4.1)
|
|
14
14
|
class HookContext
|
|
15
15
|
attr_reader :flag_key, :flag_value_type, :client_metadata, :provider_metadata
|
|
16
|
-
|
|
16
|
+
attr_writer :evaluation_context
|
|
17
17
|
|
|
18
18
|
def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
|
|
19
19
|
client_metadata: nil, provider_metadata: nil)
|
|
20
|
-
@flag_key =
|
|
20
|
+
@flag_key = FrozenCopy.of(flag_key)
|
|
21
21
|
@flag_value_type = flag_value_type.freeze
|
|
22
22
|
@source_default_value = default_value
|
|
23
23
|
@evaluation_context = evaluation_context
|
|
24
24
|
@client_metadata = client_metadata
|
|
25
|
-
@provider_metadata =
|
|
25
|
+
@provider_metadata = frozen_metadata(provider_metadata)
|
|
26
26
|
@hook_data = {}
|
|
27
27
|
@copies = {}
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
# Copied on first read: most hooks never read it, and the copy grows with the value
|
|
31
31
|
def default_value
|
|
32
|
-
@copies.fetch(:default_value) { @copies[:default_value] =
|
|
32
|
+
@copies.fetch(:default_value) { @copies[:default_value] = FrozenCopy.of(@source_default_value) }
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
def
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
# Returns a mutable hash scoped to the given hook instance.
|
|
@@ -48,6 +51,15 @@ module OpenFeature
|
|
|
48
51
|
def hook_data_for(hook)
|
|
49
52
|
@hook_data[hook.object_id] ||= {}
|
|
50
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
|
|
51
63
|
end
|
|
52
64
|
end
|
|
53
65
|
end
|
|
@@ -50,7 +50,7 @@ module OpenFeature
|
|
|
50
50
|
run_before_hooks(ordered_hooks, hook_context, hints)
|
|
51
51
|
evaluation_details = evaluate_block.call(hook_context)
|
|
52
52
|
|
|
53
|
-
# Spec 4.3.
|
|
53
|
+
# Spec 4.3.7: If evaluation resulted in an error (e.g. FLAG_NOT_FOUND,
|
|
54
54
|
# TYPE_MISMATCH), run error hooks instead of after hooks.
|
|
55
55
|
if evaluation_details.error_code
|
|
56
56
|
error = EvaluationError.new(evaluation_details.error_message, error_code: evaluation_details.error_code)
|
|
@@ -58,7 +58,7 @@ module OpenFeature
|
|
|
58
58
|
else
|
|
59
59
|
run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
|
|
60
60
|
end
|
|
61
|
-
rescue
|
|
61
|
+
rescue *RECOVERABLE_ERRORS => e
|
|
62
62
|
run_error_hooks(ordered_hooks, hook_context, e, hints)
|
|
63
63
|
|
|
64
64
|
evaluation_details = EvaluationDetails.new(
|
|
@@ -109,7 +109,7 @@ module OpenFeature
|
|
|
109
109
|
hooks.reverse_each do |hook|
|
|
110
110
|
next unless hook.respond_to?(:error)
|
|
111
111
|
hook.error(hook_context: hook_context, exception: exception, hints: hints)
|
|
112
|
-
rescue => e
|
|
112
|
+
rescue *RECOVERABLE_ERRORS => e
|
|
113
113
|
@logger&.error("Error hook #{hook.class.name} failed: #{e.message}")
|
|
114
114
|
end
|
|
115
115
|
end
|
|
@@ -121,7 +121,7 @@ module OpenFeature
|
|
|
121
121
|
hooks.reverse_each do |hook|
|
|
122
122
|
next unless hook.respond_to?(:finally)
|
|
123
123
|
hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
|
|
124
|
-
rescue => e
|
|
124
|
+
rescue *RECOVERABLE_ERRORS => e
|
|
125
125
|
@logger&.error("Finally hook #{hook.class.name} failed: #{e.message}")
|
|
126
126
|
end
|
|
127
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
|
-
|
|
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
|
|
@@ -6,15 +6,19 @@ module OpenFeature
|
|
|
6
6
|
attr_reader flag_value_type: Symbol
|
|
7
7
|
attr_reader client_metadata: ClientMetadata?
|
|
8
8
|
attr_reader provider_metadata: Provider::ProviderMetadata?
|
|
9
|
-
|
|
9
|
+
attr_writer evaluation_context: EvaluationContext?
|
|
10
10
|
|
|
11
11
|
def initialize: (flag_key: String, flag_value_type: Symbol, default_value: untyped, evaluation_context: EvaluationContext?, ?client_metadata: ClientMetadata?, ?provider_metadata: Provider::ProviderMetadata?) -> void
|
|
12
12
|
|
|
13
13
|
def default_value: () -> untyped
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def evaluation_context: () -> EvaluationContext?
|
|
16
16
|
|
|
17
17
|
def hook_data_for: (untyped hook) -> Hash[untyped, untyped]
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def frozen_metadata: (untyped metadata) -> untyped
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
@@ -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.8.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- OpenFeature Authors
|
|
@@ -49,8 +49,8 @@ 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
|
-
- lib/open_feature/sdk/hooks/frozen_copy.rb
|
|
54
54
|
- lib/open_feature/sdk/hooks/hints.rb
|
|
55
55
|
- lib/open_feature/sdk/hooks/hook.rb
|
|
56
56
|
- lib/open_feature/sdk/hooks/hook_context.rb
|
|
@@ -89,7 +89,7 @@ files:
|
|
|
89
89
|
- sig/open_feature/sdk/evaluation_details.rbs
|
|
90
90
|
- sig/open_feature/sdk/evaluation_error.rbs
|
|
91
91
|
- sig/open_feature/sdk/event_dispatcher.rbs
|
|
92
|
-
- sig/open_feature/sdk/
|
|
92
|
+
- sig/open_feature/sdk/frozen_copy.rbs
|
|
93
93
|
- sig/open_feature/sdk/hooks/hints.rbs
|
|
94
94
|
- sig/open_feature/sdk/hooks/hook.rbs
|
|
95
95
|
- sig/open_feature/sdk/hooks/hook_context.rbs
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module OpenFeature
|
|
4
|
-
module SDK
|
|
5
|
-
module Hooks
|
|
6
|
-
# A deep-frozen copy of a hook input, so hooks can't change it and the caller's object isn't frozen (spec 4.1.3, 4.2.2)
|
|
7
|
-
def self.frozen_copy(value)
|
|
8
|
-
Ractor.make_shareable(value, copy: true)
|
|
9
|
-
rescue
|
|
10
|
-
value.dup.freeze # shortcut: when something inside can't be copied (a Mutex, Queue or Proc), only the top level is frozen
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|