Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,10 +572,11 @@ class MyLoggingHook
end
```

Hooks can read their inputs but not change them:
Hooks can read the hook context and hints but not change them:

- 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 writing to its `fields` raises `FrozenError`. Nested field values aren't frozen, because they belong to the caller.
- 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.
- The flag key, the default value, the hints, and the client and provider metadata are frozen copies. Your own objects aren't frozen.
- 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.

> 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!

Expand Down
7 changes: 4 additions & 3 deletions lib/open_feature/sdk/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ def track(tracking_event_name, evaluation_context: nil, tracking_event_details:
built_context = build_evaluation_context(evaluation_context)

provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
rescue StandardError, NotImplementedError
nil # Spec 6.1.1.1: returns nothing
rescue *RECOVERABLE_ERRORS
nil
end

Expand Down Expand Up @@ -138,8 +139,8 @@ def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, inv
evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
end
end
rescue StandardError, NotImplementedError => e
# Spec 1.4.10 and 1.4.11: report through the details, never raise or log. NotImplementedError is the abstract-method idiom.
rescue *RECOVERABLE_ERRORS => e
# Spec 1.4.10 and 1.4.11: report through the details, never raise or log
resolution = Provider::ResolutionDetails.new(
value: default_value,
error_code: EvaluationError.code_for(e),
Expand Down
55 changes: 34 additions & 21 deletions lib/open_feature/sdk/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def shutdown
@provider_state_registry.set_initial_state(prov, ProviderState::NOT_READY)
prov.send(:detach) if prov.is_a?(Provider::EventEmitter)
prov.shutdown if prov.respond_to?(:shutdown)
rescue => e
rescue *RECOVERABLE_ERRORS => e
@logger&.warn("Error shutting down provider #{prov&.class&.name || "unknown"}: #{e.message}")
end

Expand Down Expand Up @@ -165,7 +165,8 @@ def set_provider_internal(provider, domain:, wait_for_init:)
# First, so a provider registered with another API (1.8.4) leaves no state behind
provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
attempt = @init_attempts[provider] = InitAttempt.new(:running, nil, Thread::Queue.new)
@provider_state_registry.set_initial_state(provider)
# Spec 2.8.5.1: a provider without init is READY from registration
@provider_state_registry.set_initial_state(provider, has_init ? ProviderState::NOT_READY : ProviderState::READY)
end

# Publish last: clients read @providers without the lock, and must not see an untracked provider
Expand All @@ -184,30 +185,42 @@ def set_provider_internal(provider, domain:, wait_for_init:)
end
end

# Shutdown old provider outside mutex to avoid blocking other operations
if needs_shutdown
begin
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}")
begin
# Spec 2.8.5.1: a provider without init is READY from registration, so its READY handlers run now, before the
# old provider's shutdown; after it, a handler added during the shutdown would run twice
unless has_init
if needs_init
init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: wait_for_init)
raise incomplete_init_error(provider, "initialization did not complete") if wait_for_init && attempt.state != :succeeded
elsif !old_provider.equal?(provider)
# Each new binding runs its domain's READY handlers
dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt)
end
end
ensure
# Shutdown old provider outside mutex to avoid blocking other operations. Checked again, since a READY
# handler above may have bound it again (a rollback)
if needs_shutdown && !provider_bound?(old_provider)
begin
old_provider.shutdown if old_provider.respond_to?(:shutdown)
rescue *RECOVERABLE_ERRORS => e
@logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || "unknown"}: #{e.message}")
end
end
end
return unless has_init

# Initialize provider outside the mutex to avoid blocking other operations
if needs_init
# Spec 2.8.5.1: a provider without init is READY, and its handlers have run, when set_provider returns
if wait_for_init || !has_init
init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: wait_for_init)
if wait_for_init
init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: true)
# Replaced or shut down while init ran: fail like the waiters on this attempt did
raise incomplete_init_error(provider, "initialization did not complete") if wait_for_init && attempt.state != :succeeded
raise incomplete_init_error(provider, "initialization did not complete") if attempt.state != :succeeded
else
init_thread = Thread.new do
init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: false)
end
end
elsif !has_init
# Spec 2.8.5.1: each new binding of a provider without init runs its domain's READY handlers
dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) unless old_provider.equal?(provider)
elsif wait_for_init
# Already registered: wait for an init still in flight, then report its outcome.
# From inside that init (e.g. a handler of an event it emits) the wait could never end.
Expand All @@ -232,7 +245,7 @@ def init_provider(provider, context, domain:, attempt:, raise_on_error: false)
# An abandoned attempt (provider replaced or shut down meanwhile) reports no READY.
# Spec 2.8.2/2.8.3: a provider that emits its own lifecycle events gets no synthetic READY or ERROR
dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if finish_init_attempt(attempt, :succeeded) && !emits_lifecycle_events
rescue => e
rescue *RECOVERABLE_ERRORS => e
# Spec 1.7.5: PROVIDER_FATAL from init makes the provider FATAL
error_code = EvaluationError.code_for(e)
error = ProviderInitializationError.new(
Expand All @@ -250,7 +263,7 @@ def init_provider(provider, context, domain:, attempt:, raise_on_error: false)

raise error if raise_on_error
ensure
# Reached unsettled only when init raised something other than a StandardError
# Reached unsettled only when init raised something not in RECOVERABLE_ERRORS, such as an Interrupt
finish_init_attempt(attempt, :failed)
end

Expand Down Expand Up @@ -296,7 +309,7 @@ def dispatch_provider_event(provider, event_type, details = {}, token: nil)
until queue.empty?
begin
deliver_provider_event(provider, *queue.shift)
rescue => e
rescue *RECOVERABLE_ERRORS => e
# Its own emitter already returned, so log rather than fail the event that was dispatching
@logger&.error("Queued provider event failed: #{e.message}")
end
Expand Down Expand Up @@ -405,7 +418,7 @@ def run_handlers_for_provider(provider, event_type, event_details)

handlers.each do |handler|
handler.call(event_details)
rescue => e
rescue *RECOVERABLE_ERRORS => e
@logger&.error("Client event handler failed: #{e.message}")
end
end
Expand All @@ -430,7 +443,7 @@ def run_immediate_handler(event_type, handler, client)

begin
handler.call(event_details)
rescue => e
rescue *RECOVERABLE_ERRORS => e
@logger&.error("Immediate API event handler failed: #{e.message}")
end
end
Expand All @@ -447,7 +460,7 @@ def run_immediate_handler(event_type, handler, client)

begin
handler.call(event_details)
rescue => e
rescue *RECOVERABLE_ERRORS => e
@logger&.error("Immediate client event handler failed: #{e.message}")
end
end
Expand Down
4 changes: 4 additions & 0 deletions lib/open_feature/sdk/evaluation_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

module OpenFeature
module SDK
# What the SDK reports or logs, rather than raises, when provider, hook or handler code fails.
# NotImplementedError isn't a StandardError, but it's Ruby's abstract-method idiom; Interrupt and the rest still propagate.
RECOVERABLE_ERRORS = [StandardError, NotImplementedError].freeze

# Raised by a provider to signal an evaluation error with an OpenFeature
# error code (spec 2.2.7). Any exception responding to +error_code+ is
# honored the same way.
Expand Down
2 changes: 1 addition & 1 deletion lib/open_feature/sdk/event_dispatcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def trigger_event(event_type, event_details = {})
# Call handlers outside of mutex to avoid deadlocks
handlers_to_call.each do |handler|
handler.call(event_details)
rescue => e
rescue *RECOVERABLE_ERRORS => e
@logger&.warn "Event handler failed for #{event_type}: #{e.message}\n#{e.backtrace.join("\n")}"
end
end
Expand Down
37 changes: 37 additions & 0 deletions lib/open_feature/sdk/frozen_copy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

module OpenFeature
module SDK
# 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
# never frozen (spec 1.4.15.1, 4.1.3, 4.1.4.1, 4.2.2)
module FrozenCopy
def self.of(value, seen = nil)
return value if defined?(Ractor) && Ractor.shareable?(value)

case value
when String then value.frozen? ? value : value.dup.freeze
when Time then value.dup # Left unfrozen: utc and localtime convert in place, and on a copy they touch nothing else
when Hash, Array
seen ||= {}.compare_by_identity
return seen[value] if seen.key?(value)

# Registered before its items, so a cycle or a shared structure maps to this one copy
copy = seen[value] = value.dup
if copy.is_a?(Hash)
if copy.default_proc
copy.default_proc = nil # Behavior rather than data, and on a frozen copy one that stores would raise
elsif !copy.default.nil?
copy.default = of(copy.default, seen)
end
copy.transform_values! { |item| of(item, seen) }
else
copy.map! { |item| of(item, seen) }
end
copy.freeze
else
value # Anything else, like a user object or a record, is passed through as is
end
end
end
end
end
14 changes: 0 additions & 14 deletions lib/open_feature/sdk/hooks/frozen_copy.rb

This file was deleted.

4 changes: 2 additions & 2 deletions lib/open_feature/sdk/hooks/hints.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require "delegate"
require_relative "frozen_copy"
require_relative "../frozen_copy"

module OpenFeature
module SDK
Expand All @@ -14,7 +14,7 @@ def initialize(hash = {})
assert_allowed_key(key)
assert_allowed_value(value)
end
super(Hooks.frozen_copy(hash))
super(FrozenCopy.of(hash))
freeze
end

Expand Down
38 changes: 25 additions & 13 deletions lib/open_feature/sdk/hooks/hook_context.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

require_relative "frozen_copy"
require_relative "../frozen_copy"

module OpenFeature
module SDK
Expand All @@ -13,33 +13,36 @@ module Hooks
# - evaluation_context is mutable only in before hooks; HookExecutor freezes the hook context after them (4.1.4.1)
class HookContext
attr_reader :flag_key, :flag_value_type, :client_metadata, :provider_metadata
attr_accessor :evaluation_context
attr_writer :evaluation_context

def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
client_metadata: nil, provider_metadata: nil)
@flag_key = Hooks.frozen_copy(flag_key)
@flag_key = FrozenCopy.of(flag_key)
@flag_value_type = flag_value_type.freeze
@source_default_value = default_value
@evaluation_context = evaluation_context
@client_metadata = client_metadata
@provider_metadata = Hooks.frozen_copy(provider_metadata)
@provider_metadata = frozen_metadata(provider_metadata)
@hook_data = {}
@copies = {}
end

# Copied on first read: most hooks never read it, and the copy grows with the value
def default_value
@copies.fetch(:default_value) { @copies[:default_value] = Hooks.frozen_copy(@source_default_value) }
@copies.fetch(:default_value) { @copies[:default_value] = FrozenCopy.of(@source_default_value) }
end

# Swaps in a frozen copy of the evaluation context, so neither the caller's nor a before hook's object is frozen.
# Top-level strings are copied too, so a hook can't edit a targeting key in place.
def freeze
return self if frozen?
@evaluation_context &&= @evaluation_context.dup.tap do |copy|
copy.fields.transform_values! { |value| (value.is_a?(String) && !value.frozen?) ? value.dup.freeze : value }
end.freeze
super
# Live during the before stage; once frozen, a deep-frozen copy made on first read, so neither the caller's
# context nor one a before hook assigned is frozen
def evaluation_context
return @evaluation_context unless frozen?

@copies.fetch(:evaluation_context) do
@copies[:evaluation_context] = @evaluation_context&.dup&.tap do |copy|
seen = {}.compare_by_identity # One map for every field, so a structure two fields share is copied once
copy.fields.transform_values! { |value| FrozenCopy.of(value, seen) }
end&.freeze
end
end

# Returns a mutable hash scoped to the given hook instance.
Expand All @@ -48,6 +51,15 @@ def freeze
def hook_data_for(hook)
@hook_data[hook.object_id] ||= {}
end

private

# FrozenCopy passes Structs through, so ProviderMetadata's members are copied here (spec 4.2.2.3)
def frozen_metadata(metadata)
return metadata if !metadata.is_a?(Struct) || (defined?(Ractor) && Ractor.shareable?(metadata))

metadata.dup.tap { |copy| copy.each_pair { |member, value| copy[member] = FrozenCopy.of(value) } }.freeze
end
end
end
end
Expand Down
6 changes: 3 additions & 3 deletions lib/open_feature/sdk/hooks/hook_executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def execute(ordered_hooks:, hook_context:, hints:, default_value:, &evaluate_blo
else
run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
end
rescue StandardError, NotImplementedError => e
rescue *RECOVERABLE_ERRORS => e
run_error_hooks(ordered_hooks, hook_context, e, hints)

evaluation_details = EvaluationDetails.new(
Expand Down Expand Up @@ -109,7 +109,7 @@ def run_error_hooks(hooks, hook_context, exception, hints)
hooks.reverse_each do |hook|
next unless hook.respond_to?(:error)
hook.error(hook_context: hook_context, exception: exception, hints: hints)
rescue => e
rescue *RECOVERABLE_ERRORS => e
@logger&.error("Error hook #{hook.class.name} failed: #{e.message}")
end
end
Expand All @@ -121,7 +121,7 @@ def run_finally_hooks(hooks, hook_context, evaluation_details, hints)
hooks.reverse_each do |hook|
next unless hook.respond_to?(:finally)
hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
rescue => e
rescue *RECOVERABLE_ERRORS => e
@logger&.error("Finally hook #{hook.class.name} failed: #{e.message}")
end
end
Expand Down
11 changes: 3 additions & 8 deletions lib/open_feature/sdk/provider/resolution_details.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
# frozen_string_literal: true

require_relative "../frozen_copy"

module OpenFeature
module SDK
module Provider
EMPTY_FLAG_METADATA = {}.freeze

ResolutionDetails = Struct.new(:value, :reason, :variant, :error_code, :error_message, :flag_metadata, keyword_init: true) do
def flag_metadata
raw = self[:flag_metadata]
if raw.nil?
EMPTY_FLAG_METADATA
elsif raw.frozen?
raw
else
raw.dup.freeze
end
FrozenCopy.of(self[:flag_metadata] || EMPTY_FLAG_METADATA)
end
end
end
Expand Down
2 changes: 2 additions & 0 deletions sig/open_feature/sdk/evaluation_error.rbs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
module OpenFeature
module SDK
RECOVERABLE_ERRORS: Array[singleton(Exception)]

class EvaluationError < StandardError
attr_reader error_code: String

Expand Down
7 changes: 7 additions & 0 deletions sig/open_feature/sdk/frozen_copy.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module OpenFeature
module SDK
module FrozenCopy
def self.of: (untyped value, ?Hash[untyped, untyped]? seen) -> untyped
end
end
end
Loading
Loading