openfeature-sdk 0.6.6 → 0.7.0

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: 500c09c5333475a75aeff77cdab714c1360f4c14a84c28f9f16fe3b0dc42cb8f
4
- data.tar.gz: 4d760e25308fad1ea762e8547260f87dea3474a8861eadd25993cfd2dde55b04
3
+ metadata.gz: 1b8b6e8a5fd01205278fa44c809eb3c1ef578b56e5542aa94793a090e605458d
4
+ data.tar.gz: f85c7d0e8b804fb651e02cc3c59d14c21458f9fdb419d58df6d8fbb714a35b9b
5
5
  SHA512:
6
- metadata.gz: 4bfda5295966ab245426ecaa0d2dbb440b7f273833193906b9fd91fd6ffeb41e5b49e652a9d05dbf6679c08cfc434c4733d34384dfc1d64be995194f3938cdf1
7
- data.tar.gz: 2359651d7407cdd30e41e15e4d764efc47565e025b2046adbc4942da0c25d62396bd5e52991626d728b675b123aa52bcce733f8ebc71703112ebe77d6704e96e
6
+ metadata.gz: a7fef199498fb02863ed74cf76a6fbff8acf65df23e2e5d4baad90b6ed3da589b6ca98da1540443a7367456a527819eb2380979aa4f1a1ef8780c33ccf425d32
7
+ data.tar.gz: 71db2db0d85592861ebd5f117d7599e9d54374deb9a6eae5060716044abddb27a4bd10db7ca07314540a36adeb7c366afa1a8c21fb4f19824e05e15fd59a1a27
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.6.6"
2
+ ".": "0.7.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/open-feature/ruby-sdk/compare/v0.6.7...v0.7.0) (2026-10-23)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * resolve a client's provider by domain on every call ([#352](https://github.com/open-feature/ruby-sdk/issues/352))
9
+
10
+ ### Features
11
+
12
+ * let providers own their lifecycle events ([#353](https://github.com/open-feature/ruby-sdk/issues/353)) ([15faf98](https://github.com/open-feature/ruby-sdk/commit/15faf98d1c900118da362f6b8d76ccdec1b3b99e))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * catch NotImplementedError in track and the hook lifecycle ([#349](https://github.com/open-feature/ruby-sdk/issues/349)) ([62759f4](https://github.com/open-feature/ruby-sdk/commit/62759f443fa8cb2f7a1b67720397240063623da1))
18
+ * deliver provider events in order, outside handler locks ([#351](https://github.com/open-feature/ruby-sdk/issues/351)) ([16bf89c](https://github.com/open-feature/ruby-sdk/commit/16bf89cf72541c22de9565f0dcdbfcf9dfe097ad))
19
+ * release init waiters on replacement and same-thread re-entry ([#348](https://github.com/open-feature/ruby-sdk/issues/348)) ([9e82b15](https://github.com/open-feature/ruby-sdk/commit/9e82b15e9730f0c53f2186aeb39cc53275d5f929))
20
+ * resolve a client's provider by domain on every call ([#352](https://github.com/open-feature/ruby-sdk/issues/352)) ([fa0076f](https://github.com/open-feature/ruby-sdk/commit/fa0076fb8a159a180af408ee0a335e9f8d43afc9))
21
+
22
+ ## [0.6.7](https://github.com/open-feature/ruby-sdk/compare/v0.6.6...v0.6.7) (2026-10-23)
23
+
24
+
25
+ ### Features
26
+
27
+ * pass the bound domain to provider init and support domain-scoped providers ([#345](https://github.com/open-feature/ruby-sdk/issues/345)) ([f3cf6b7](https://github.com/open-feature/ruby-sdk/commit/f3cf6b76c06297aa4c0a2a95ea1055f816269e3b))
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * return defaults instead of raising from client calls ([#346](https://github.com/open-feature/ruby-sdk/issues/346)) ([481f21c](https://github.com/open-feature/ruby-sdk/commit/481f21cfe70365de92c6ece3eb92c40117a7e1c5))
33
+
3
34
  ## [0.6.6](https://github.com/open-feature/ruby-sdk/compare/v0.6.5...v0.6.6) (2026-10-22)
4
35
 
5
36
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openfeature-sdk (0.6.6)
4
+ openfeature-sdk (0.7.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -17,8 +17,8 @@
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.6.6">
21
- <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.6&color=blue&style=for-the-badge" />
20
+ <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.7.0">
21
+ <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.7.0&color=blue&style=for-the-badge" />
22
22
  </a>
23
23
 
24
24
  <!-- x-release-please-end -->
@@ -287,6 +287,7 @@ Log output uses a structured key=value format:
287
287
 
288
288
  Clients can be assigned to a domain. A domain is a logical identifier which can be used to associate clients with a particular provider.
289
289
  If a domain has no associated provider, the default provider is used.
290
+ Clients look up their domain's provider on every call, so a client created before `set_provider` follows the new binding.
290
291
 
291
292
  ```ruby
292
293
  OpenFeature::SDK.configure do |config|
@@ -300,28 +301,35 @@ legacy_flag_client = OpenFeature::SDK.build_client(domain: "legacy_flags")
300
301
  ### Eventing
301
302
 
302
303
  Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions.
303
- Initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) are dispatched for every provider.
304
- Some providers support additional events, such as `PROVIDER_CONFIGURATION_CHANGED`.
304
+ Providers emit initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) from `init`.
305
+ A provider without `init` is `READY` as soon as it's registered, and its `PROVIDER_READY` handlers run before `set_provider` returns.
306
+ For providers that define `init` but not `emits_lifecycle_events?` returning `true`, the SDK emits `PROVIDER_READY` after `init` returns and `PROVIDER_ERROR` if it raises.
307
+ That path is deprecated (spec Appendix E), and the SDK logs a deprecation warning once per provider class to its configured logger.
308
+ Handlers for events a provider emits from `init` run while `init` is still in progress, so they can't call `set_provider_and_wait` for that same provider.
309
+ Some providers emit additional events: `PROVIDER_CONFIGURATION_CHANGED`, `PROVIDER_STALE`, `PROVIDER_RECONCILING` and `PROVIDER_CONTEXT_CHANGED`.
310
+ Handlers receive a hash with the `provider_name` and any details the provider sent, such as `message` or `error_code`.
305
311
 
306
312
  Please refer to the documentation of the provider you're using to see what events are supported.
307
313
 
308
314
  ```ruby
309
315
  # Register event handlers at the API (global) level
310
316
  ready_handler = ->(event_details) do
311
- puts "Provider #{event_details[:provider].metadata.name} is ready!"
317
+ puts "Provider #{event_details[:provider_name]} is ready!"
312
318
  end
313
319
 
314
320
  OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler)
315
321
 
316
- # The SDK automatically emits lifecycle events. Providers can emit additional spontaneous events
317
- # using the EventEmitter mixin to signal internal state changes like configuration updates.
322
+ # Providers emit events using the EventEmitter mixin: lifecycle events from init, and spontaneous
323
+ # events to signal internal state changes like configuration updates.
318
324
  class MyEventAwareProvider
319
325
  include OpenFeature::SDK::Provider::EventEmitter
320
326
 
327
+ def emits_lifecycle_events? = true
328
+
321
329
  def init(evaluation_context)
322
330
  # Start background process to monitor for configuration changes
323
- # Note: SDK automatically emits PROVIDER_READY when init completes successfully
324
331
  start_background_process
332
+ emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
325
333
  end
326
334
 
327
335
  def start_background_process
@@ -351,6 +359,9 @@ This should only be called when your application is in the process of shutting d
351
359
  OpenFeature::SDK.shutdown
352
360
  ```
353
361
 
362
+ The SDK also shuts down a replaced provider once no domain uses it.
363
+ Events from shut-down providers are ignored.
364
+
354
365
  Individual providers can implement a `shutdown` method to perform cleanup:
355
366
 
356
367
  ```ruby
@@ -442,11 +453,29 @@ You’ll then need to write the provider by implementing the `Provider` duck.
442
453
 
443
454
  ```ruby
444
455
  class MyProvider
445
- def init
446
- # Perform any initialization steps with flag management system here
456
+ include OpenFeature::SDK::Provider::EventEmitter
457
+
458
+ # Tells the SDK that init emits PROVIDER_READY/PROVIDER_ERROR itself.
459
+ def emits_lifecycle_events? = true
460
+
461
+ # Optional: a provider without init is READY as soon as it's registered.
462
+ # Both arguments are optional; declare only the ones you need.
463
+ # `domain` is the domain the provider was bound to, or nil for the default provider.
464
+ def init(evaluation_context = nil, domain: nil)
465
+ # Perform any initialization steps with flag management system here, then emit PROVIDER_READY.
447
466
  # Return value is ignored
448
467
  # **Note** The OpenFeature spec defines a lifecycle method called `initialize` to be called when a new provider is set.
449
468
  # To avoid conflicting with the Ruby `initialize` method, this method should be named `init` when creating a provider.
469
+ emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
470
+ rescue => e
471
+ emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: e.message)
472
+ raise
473
+ end
474
+
475
+ # Optional: return true if this provider keeps state for a single domain.
476
+ # The SDK then raises ArgumentError if the same instance is bound to a second domain.
477
+ def domain_scoped?
478
+ false
450
479
  end
451
480
 
452
481
  def shutdown
@@ -48,11 +48,7 @@ module OpenFeature
48
48
  end
49
49
 
50
50
  def build_client(domain: nil, evaluation_context: nil)
51
- active_provider = provider(domain:).nil? ? Provider::NoOpProvider.new : provider(domain:)
52
-
53
- Client.new(provider: active_provider, domain:, evaluation_context:)
54
- rescue
55
- Client.new(provider: Provider::NoOpProvider.new, evaluation_context:)
51
+ Client.new(domain:, evaluation_context:)
56
52
  end
57
53
 
58
54
  def provider_metadata(domain: nil)
@@ -17,11 +17,11 @@ module OpenFeature
17
17
  SUFFIXES = %i[value details].freeze
18
18
  EMPTY_HINTS = Hooks::Hints.new.freeze
19
19
 
20
- attr_reader :metadata, :evaluation_context
20
+ attr_reader :metadata
21
21
 
22
- attr_accessor :hooks
22
+ attr_accessor :hooks, :evaluation_context
23
23
 
24
- def initialize(provider:, domain: nil, evaluation_context: nil)
24
+ def initialize(provider: nil, domain: nil, evaluation_context: nil)
25
25
  @provider = provider
26
26
  @metadata = ClientMetadata.new(domain:)
27
27
  @evaluation_context = evaluation_context
@@ -33,7 +33,7 @@ module OpenFeature
33
33
  end
34
34
 
35
35
  def provider_status
36
- OpenFeature::SDK.configuration.provider_state(@provider)
36
+ OpenFeature::SDK.configuration.provider_state(resolve_provider)
37
37
  end
38
38
 
39
39
  def add_handler(event_type, handler = nil, &block)
@@ -51,17 +51,21 @@ module OpenFeature
51
51
  # Records a tracking event. If the provider does not implement
52
52
  # tracking, this is a no-op (spec 6.1.4).
53
53
  def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
54
- return unless @provider.respond_to?(:track)
54
+ provider = resolve_provider
55
+ return unless provider.respond_to?(:track)
55
56
 
56
57
  built_context = build_evaluation_context(evaluation_context)
57
58
 
58
- @provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
59
+ provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
60
+ rescue StandardError, NotImplementedError
61
+ nil
59
62
  end
60
63
 
61
64
  RESULT_TYPE.each do |result_type|
62
65
  SUFFIXES.each do |suffix|
63
66
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
64
67
  def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context: nil, hooks: [], hook_hints: nil)
68
+ validate_default_value_type(:#{result_type}, default_value)
65
69
  evaluation_details = fetch_details(type: :#{result_type}, flag_key:, default_value:, evaluation_context:, invocation_hooks: hooks, hook_hints: hook_hints)
66
70
  #{"evaluation_details.value" if suffix == :value}
67
71
  end
@@ -72,18 +76,18 @@ module OpenFeature
72
76
  private
73
77
 
74
78
  def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
75
- validate_default_value_type(type, default_value)
76
-
79
+ provider = resolve_provider
77
80
  built_context = build_evaluation_context(evaluation_context)
78
81
 
79
82
  # Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
80
- provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
83
+ provider_hooks = provider.respond_to?(:hooks) ? Array(provider.hooks) : []
81
84
  ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
82
85
 
83
- # Short-circuit NOT_READY/FATAL providers (required by evaluation_v2.feature)
86
+ # Short-circuit NOT_READY/FATAL providers (required by evaluation_v2.feature). A provider resolved from the
87
+ # configuration is tracked while bound, so one that isn't was replaced mid-call and reads NOT_READY.
84
88
  short_circuit_code = nil
85
- if OpenFeature::SDK.configuration.provider_tracked?(@provider)
86
- short_circuit_code = short_circuit_error_code(provider_status)
89
+ if @provider ? OpenFeature::SDK.configuration.provider_tracked?(provider) : !provider.equal?(@fallback_provider)
90
+ short_circuit_code = short_circuit_error_code(OpenFeature::SDK.configuration.provider_state(provider))
87
91
  end
88
92
 
89
93
  # Fast path: skip hook ceremony when no hooks are registered
@@ -97,17 +101,7 @@ module OpenFeature
97
101
  return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
98
102
  end
99
103
 
100
- begin
101
- return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
102
- rescue => e
103
- resolution = Provider::ResolutionDetails.new(
104
- value: default_value,
105
- error_code: EvaluationError.code_for(e),
106
- reason: Provider::Reason::ERROR,
107
- error_message: e.message
108
- )
109
- return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
110
- end
104
+ return evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
111
105
  end
112
106
 
113
107
  hook_context = Hooks::HookContext.new(
@@ -116,7 +110,7 @@ module OpenFeature
116
110
  default_value: default_value,
117
111
  evaluation_context: built_context,
118
112
  client_metadata: @metadata,
119
- provider_metadata: @provider.respond_to?(:metadata) ? @provider.metadata : nil
113
+ provider_metadata: provider.respond_to?(:metadata) ? provider.metadata : nil
120
114
  )
121
115
 
122
116
  hints = if hook_hints.is_a?(Hooks::Hints)
@@ -140,22 +134,31 @@ module OpenFeature
140
134
  evaluation_details
141
135
  else
142
136
  executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
143
- evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
137
+ evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
144
138
  end
145
139
  end
140
+ rescue StandardError, NotImplementedError => e
141
+ # Spec 1.4.10 and 1.4.11: report through the details, never raise or log. NotImplementedError is the abstract-method idiom.
142
+ resolution = Provider::ResolutionDetails.new(
143
+ value: default_value,
144
+ error_code: EvaluationError.code_for(e),
145
+ reason: Provider::Reason::ERROR,
146
+ error_message: e.message
147
+ )
148
+ EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
146
149
  end
147
150
 
148
- def evaluate_flag(type:, flag_key:, default_value:, evaluation_context:)
149
- resolution_details = @provider.send(
151
+ def evaluate_flag(provider:, type:, flag_key:, default_value:, evaluation_context:)
152
+ resolution_details = provider.send(
150
153
  :"fetch_#{type}_value",
151
154
  flag_key: flag_key,
152
155
  default_value: default_value,
153
156
  evaluation_context: evaluation_context
154
157
  )
155
158
 
156
- if TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
159
+ if resolution_details.error_code || TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
157
160
  resolution_details.value = default_value
158
- resolution_details.error_code = Provider::ErrorCode::TYPE_MISMATCH
161
+ resolution_details.error_code ||= Provider::ErrorCode::TYPE_MISMATCH
159
162
  resolution_details.reason = Provider::Reason::ERROR
160
163
  resolution_details.variant = nil
161
164
  end
@@ -163,6 +166,12 @@ module OpenFeature
163
166
  EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
164
167
  end
165
168
 
169
+ # Callers resolve once per operation, so a rebinding mid-evaluation can't mix two providers.
170
+ # The fallback NoOp is never registered: untracked, so not short-circuited, and its status reads NOT_READY.
171
+ def resolve_provider
172
+ @provider || OpenFeature::SDK.configuration.provider(domain: @metadata.domain) || (@fallback_provider ||= Provider::NoOpProvider.new)
173
+ end
174
+
166
175
  def short_circuit_error_code(state)
167
176
  case state
168
177
  when ProviderState::NOT_READY then Provider::ErrorCode::PROVIDER_NOT_READY
@@ -20,15 +20,22 @@ module OpenFeature
20
20
  attr_accessor :evaluation_context, :hooks, :transaction_context_propagator
21
21
  attr_reader :logger
22
22
 
23
+ # One per registration; +done+ is closed once init finishes, so waiters can pop on it
24
+ InitAttempt = Struct.new(:state, :error, :done, :thread)
25
+ private_constant :InitAttempt
26
+
23
27
  def initialize
24
28
  @hooks = []
25
29
  @providers = {}
30
+ @init_attempts = {}.compare_by_identity
31
+ @legacy_warned = Set.new.compare_by_identity
26
32
  @provider_mutex = Mutex.new
27
33
  @logger = nil
28
34
  @event_dispatcher = EventDispatcher.new(@logger)
29
35
  @provider_state_registry = ProviderStateRegistry.new
30
36
  @client_handlers = {}
31
37
  @client_handlers_mutex = Mutex.new
38
+ @dispatch_key = :"openfeature_dispatch_#{object_id}"
32
39
  end
33
40
 
34
41
  def add_hooks(*new_hooks)
@@ -55,6 +62,8 @@ module OpenFeature
55
62
 
56
63
  # @api private
57
64
  def add_client_handler(client, event_type, handler)
65
+ @event_dispatcher.validate_handler!(event_type, handler)
66
+
58
67
  @client_handlers_mutex.synchronize do
59
68
  @client_handlers[client] ||= Hash.new { |h, k| h[k] = [] }
60
69
  @client_handlers[client][event_type] << handler
@@ -118,6 +127,9 @@ module OpenFeature
118
127
  @provider_state_registry.clear
119
128
  @provider_mutex.synchronize do
120
129
  @providers.clear
130
+ @init_attempts.each_value { |attempt| abandon_init_attempt(attempt) }
131
+ @init_attempts.clear
132
+ @legacy_warned.clear
121
133
  end
122
134
  @hooks.clear
123
135
  @evaluation_context = nil
@@ -129,30 +141,42 @@ module OpenFeature
129
141
  context_for_init = @evaluation_context
130
142
 
131
143
  old_provider = nil
144
+ attempt = nil
132
145
  needs_init = false
146
+ init_thread = nil
133
147
  needs_shutdown = false
134
148
 
149
+ # Provider code runs before taking the lock: it may call back into the SDK
150
+ scoped = domain_scoped?(provider)
151
+ has_init = provider.respond_to?(:init)
152
+ legacy = has_init && !emits_lifecycle_events?(provider)
153
+ warn_legacy_provider(provider) if legacy
154
+
135
155
  @provider_mutex.synchronize do
136
- old_provider = @providers[domain]
156
+ validate_domain_scoped_binding!(provider, domain, scoped)
137
157
 
138
- new_providers = @providers.dup
139
- new_providers[domain] = provider
140
- @providers = new_providers
158
+ old_provider = @providers[domain]
141
159
 
142
- # Spec 1.1.2.2: Only initialize if the provider is not already active
143
- # (i.e., not already bound to another domain)
144
- already_active = @providers.any? { |d, p| d != domain && p.equal?(provider) && @provider_state_registry.tracked?(p) }
145
- needs_init = !already_active
160
+ # Spec 1.1.2.2: initialize once per registration; a failed init is retried on the next set
161
+ attempt = @init_attempts[provider]
162
+ needs_init = attempt.nil? || attempt.state == :failed
146
163
 
147
164
  if needs_init
165
+ attempt = @init_attempts[provider] = InitAttempt.new(:running, nil, Thread::Queue.new)
148
166
  @provider_state_registry.set_initial_state(provider)
149
167
  provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
150
168
  end
151
169
 
170
+ # Publish last: clients read @providers without the lock, and must not see an untracked provider
171
+ new_providers = @providers.dup
172
+ new_providers[domain] = provider
173
+ @providers = new_providers
174
+
152
175
  # Spec 1.1.2.3: Only shutdown old provider if it's no longer bound to any domain
153
176
  if old_provider && !old_provider.equal?(provider)
154
177
  needs_shutdown = !provider_bound?(old_provider)
155
178
  if needs_shutdown
179
+ abandon_init_attempt(@init_attempts.delete(old_provider))
156
180
  @provider_state_registry.remove_provider(old_provider)
157
181
  old_provider.send(:detach) if old_provider.is_a?(Provider::EventEmitter)
158
182
  end
@@ -170,67 +194,139 @@ module OpenFeature
170
194
 
171
195
  # Initialize provider outside the mutex to avoid blocking other operations
172
196
  if needs_init
173
- if wait_for_init
174
- init_provider(provider, context_for_init, raise_on_error: true)
197
+ # Spec 2.8.5.1: a provider without init is READY, and its handlers have run, when set_provider returns
198
+ if wait_for_init || !has_init
199
+ init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: wait_for_init)
200
+ # Replaced or shut down while init ran: fail like the waiters on this attempt did
201
+ raise incomplete_init_error(provider, "initialization did not complete") if wait_for_init && attempt.state != :succeeded
175
202
  else
176
- Thread.new do
177
- init_provider(provider, context_for_init, raise_on_error: false)
203
+ init_thread = Thread.new do
204
+ init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: false)
178
205
  end
179
206
  end
207
+ elsif !has_init
208
+ # Spec 2.8.5.1: each new binding of a provider without init runs its domain's READY handlers
209
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) unless old_provider.equal?(provider)
180
210
  elsif wait_for_init
181
- # Provider already active; no init needed but still dispatch READY
182
- dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
211
+ # Already registered: wait for an init still in flight, then report its outcome.
212
+ # From inside that init (e.g. a handler of an event it emits) the wait could never end.
213
+ raise incomplete_init_error(provider, "is still initializing on this thread") if running_on_this_thread?(attempt)
214
+
215
+ attempt.done.pop
216
+ raise attempt.error || incomplete_init_error(provider, "initialization did not complete") unless attempt.state == :succeeded
217
+
218
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if legacy
183
219
  end
220
+ ensure
221
+ # Fail an attempt no init took over (e.g. a Timeout hit first), or it would stay running forever
222
+ finish_init_attempt(attempt, :failed) if needs_init && !init_thread
184
223
  end
185
224
 
186
- def init_provider(provider, context, raise_on_error: false)
187
- if provider.respond_to?(:init)
188
- init_method = provider.method(:init)
189
- if init_method.parameters.empty?
190
- provider.init
191
- else
192
- provider.init(context)
193
- end
194
- end
225
+ def init_provider(provider, context, domain:, attempt:, raise_on_error: false)
226
+ emits_lifecycle_events = emits_lifecycle_events?(provider)
227
+ attempt.thread = Thread.current
228
+ call_init(provider, context, domain)
195
229
 
196
- dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
230
+ # Settle the attempt before handlers run, so a handler can register this provider again.
231
+ # An abandoned attempt (provider replaced or shut down meanwhile) reports no READY.
232
+ # Spec 2.8.2/2.8.3: a provider that emits its own lifecycle events gets no synthetic READY or ERROR
233
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if finish_init_attempt(attempt, :succeeded) && !emits_lifecycle_events
197
234
  rescue => e
198
235
  # Spec 1.7.5: PROVIDER_FATAL from init makes the provider FATAL
199
236
  error_code = EvaluationError.code_for(e)
200
-
201
- dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
237
+ error = ProviderInitializationError.new(
238
+ "Provider #{provider.class.name} initialization failed: #{e.message}",
239
+ provider:,
202
240
  error_code: error_code,
203
- message: e.message)
204
-
205
- if raise_on_error
206
- raise ProviderInitializationError.new(
207
- "Provider #{provider.class.name} initialization failed: #{e.message}",
208
- provider:,
209
- error_code: error_code,
210
- original_error: e
211
- )
241
+ original_error: e
242
+ )
243
+ finish_init_attempt(attempt, :failed, error)
244
+
245
+ unless emits_lifecycle_events
246
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
247
+ {error_code: error_code, message: e.message}, token: attempt)
212
248
  end
249
+
250
+ raise error if raise_on_error
251
+ ensure
252
+ # Reached unsettled only when init raised something other than a StandardError
253
+ finish_init_attempt(attempt, :failed)
254
+ end
255
+
256
+ # Caller holds @provider_mutex: the provider was unbound or the API reset before its init settled
257
+ def abandon_init_attempt(attempt)
258
+ return unless attempt
259
+
260
+ attempt.state = :failed if attempt.state == :running
261
+ attempt.done.close
213
262
  end
214
263
 
215
- def dispatch_provider_event(provider, event_type, details = {})
216
- # Under the replacement lock, so a late event can't re-track a replaced or shut-down provider
264
+ def incomplete_init_error(provider, reason)
265
+ ProviderInitializationError.new("Provider #{provider.class.name} #{reason}", provider:, error_code: Provider::ErrorCode::GENERAL)
266
+ end
267
+
268
+ def finish_init_attempt(attempt, state, error = nil)
217
269
  @provider_mutex.synchronize do
218
- return unless provider_bound?(provider)
270
+ return false unless attempt.state == :running
219
271
 
220
- @provider_state_registry.update_state_from_event(provider, event_type, details)
272
+ attempt.state = state
273
+ attempt.error = error
221
274
  end
275
+ attempt.done.close
276
+ true
277
+ end
222
278
 
223
- provider_name = extract_provider_name(provider)
279
+ # An event for a provider already dispatching on this call stack is queued behind the current one
280
+ def dispatch_provider_event(provider, event_type, details = {}, token: nil)
281
+ # Lifecycle events (the SDK's own, or the provider's from its init thread) record status when sent, so
282
+ # set_provider_and_wait returns with it even from the provider's own handler (1.1.2.4); others record it when delivered (5.3.5)
283
+ token ||= running_init_attempt_on_this_thread(provider)
284
+ return if token && !accept_provider_event(provider, event_type, details, token)
285
+
286
+ queues = (Thread.current[@dispatch_key] ||= {}.compare_by_identity)
287
+ if (queue = queues[provider])
288
+ queue << [event_type, details.dup, token]
289
+ return
290
+ end
224
291
 
225
- event_details = {
226
- provider_name: provider_name
227
- }.merge(details)
292
+ queue = queues[provider] = [[event_type, details, token]]
293
+ begin
294
+ deliver_provider_event(provider, *queue.shift)
295
+ until queue.empty?
296
+ begin
297
+ deliver_provider_event(provider, *queue.shift)
298
+ rescue => e
299
+ # Its own emitter already returned, so log rather than fail the event that was dispatching
300
+ @logger&.error("Queued provider event failed: #{e.message}")
301
+ end
302
+ end
303
+ ensure
304
+ queues.delete(provider)
305
+ Thread.current[@dispatch_key] = nil if queues.empty?
306
+ end
307
+ end
228
308
 
309
+ def deliver_provider_event(provider, event_type, details, token)
310
+ return unless accept_provider_event(provider, event_type, details, token, record_status: token.nil?)
311
+
312
+ event_details = {provider_name: extract_provider_name(provider)}.merge(details)
229
313
  run_handlers_for_provider(provider, event_type, event_details)
230
314
  end
231
315
 
232
316
  private
233
317
 
318
+ # Under the replacement lock, so a late event can't re-track a replaced or shut-down provider
319
+ def accept_provider_event(provider, event_type, details, token, record_status: true)
320
+ @provider_mutex.synchronize do
321
+ return false unless provider_bound?(provider)
322
+ # An SDK READY/ERROR from an earlier registration of this provider is stale
323
+ return false unless token.nil? || @init_attempts[provider].equal?(token)
324
+
325
+ @provider_state_registry.update_state_from_event(provider, event_type, details) if record_status
326
+ true
327
+ end
328
+ end
329
+
234
330
  def provider_bound?(provider)
235
331
  @providers.each_value.any? { |p| p.equal?(provider) }
236
332
  end
@@ -239,26 +335,78 @@ module OpenFeature
239
335
  provider.respond_to?(:metadata) ? provider.metadata.name : provider.class.name
240
336
  end
241
337
 
338
+ def domain_scoped?(provider)
339
+ provider.respond_to?(:domain_scoped?) && provider.domain_scoped?
340
+ end
341
+
342
+ # Spec 2.8.5.1: without init there's nothing to report, so the marker only counts alongside init
343
+ def emits_lifecycle_events?(provider)
344
+ provider.respond_to?(:init) && provider.respond_to?(:emits_lifecycle_events?) && provider.emits_lifecycle_events?
345
+ end
346
+
347
+ def running_init_attempt_on_this_thread(provider)
348
+ attempt = @provider_mutex.synchronize { @init_attempts[provider] }
349
+ attempt if attempt && running_on_this_thread?(attempt)
350
+ end
351
+
352
+ def running_on_this_thread?(attempt)
353
+ attempt.state == :running && attempt.thread.equal?(Thread.current)
354
+ end
355
+
356
+ def warn_legacy_provider(provider)
357
+ return if @logger.nil? || !@provider_mutex.synchronize { @legacy_warned.add?(provider.class) }
358
+ @logger.warn("Provider #{extract_provider_name(provider)} relies on SDK-emitted lifecycle events, which are deprecated; " \
359
+ "emit PROVIDER_READY/PROVIDER_ERROR from init and define emits_lifecycle_events? returning true (spec Appendix E)")
360
+ end
361
+
362
+ def validate_domain_scoped_binding!(provider, domain, scoped)
363
+ return unless scoped && provider_bound?(provider) && !@providers[domain].equal?(provider)
364
+
365
+ raise ArgumentError, "Cannot bind domain-scoped provider to more than one domain"
366
+ end
367
+
368
+ def call_init(provider, context, domain)
369
+ return unless provider.respond_to?(:init)
370
+
371
+ parameters = provider.method(:init).parameters
372
+ takes_domain = init_accepts_domain?(parameters)
373
+ if !takes_domain && domain_scoped?(provider)
374
+ @logger&.warn("Provider #{extract_provider_name(provider)} is domain-scoped but its init doesn't accept domain:")
375
+ end
376
+
377
+ args = init_accepts_evaluation_context?(parameters) ? [context] : []
378
+ provider.init(*args, **(takes_domain ? {domain:} : {}))
379
+ end
380
+
381
+ # Anonymous `**` (from `...`) and delegators' bare `*` can't be trusted to accept `domain:`
382
+ def init_accepts_domain?(parameters)
383
+ parameters.any? do |kind, name|
384
+ ((kind == :key || kind == :keyreq) && name == :domain) || (kind == :keyrest && name && name != :**)
385
+ end
386
+ end
387
+
388
+ def init_accepts_evaluation_context?(parameters)
389
+ parameters.any? { |kind, _| kind == :req || kind == :opt || kind == :rest }
390
+ end
391
+
242
392
  def run_handlers_for_provider(provider, event_type, event_details)
243
393
  # Run global handlers (API-level, no domain filtering)
244
394
  @event_dispatcher.trigger_event(event_type, event_details)
245
395
 
246
- # Run client handlers (domain-scoped)
247
- @client_handlers_mutex.synchronize do
248
- @client_handlers.each do |client, handlers_by_event|
396
+ # Run client handlers (domain-scoped) outside the lock, so they can add or remove handlers
397
+ handlers = @client_handlers_mutex.synchronize do
398
+ @client_handlers.flat_map do |client, handlers_by_event|
249
399
  # Check if this client should receive events from this provider
250
400
  client_provider = provider(domain: client.metadata.domain)
251
- next unless client_provider&.equal?(provider)
252
-
253
- # Trigger handlers for this client
254
- handlers = handlers_by_event[event_type]
255
- handlers.each do |handler|
256
- handler.call(event_details)
257
- rescue => e
258
- @logger&.error("Client event handler failed: #{e.message}")
259
- end
401
+ client_provider&.equal?(provider) ? handlers_by_event[event_type] : []
260
402
  end
261
403
  end
404
+
405
+ handlers.each do |handler|
406
+ handler.call(event_details)
407
+ rescue => e
408
+ @logger&.error("Client event handler failed: #{e.message}")
409
+ end
262
410
  end
263
411
 
264
412
  def run_immediate_handler(event_type, handler, client)
@@ -270,8 +418,8 @@ module OpenFeature
270
418
  }
271
419
 
272
420
  if client.nil?
273
- # API-level handler: check all providers
274
- @providers.each do |domain, provider|
421
+ # API-level handler: check each bound provider once, however many domains it's bound to
422
+ @providers.values.uniq(&:object_id).each do |provider|
275
423
  next unless provider
276
424
 
277
425
  provider_state = @provider_state_registry.get_state(provider)
@@ -16,14 +16,18 @@ module OpenFeature
16
16
  end
17
17
 
18
18
  def add_handler(event_type, handler)
19
- raise ArgumentError, "Invalid event type: #{event_type}" unless valid_event?(event_type)
20
- raise ArgumentError, "Handler must respond to call" unless handler.respond_to?(:call)
19
+ validate_handler!(event_type, handler)
21
20
 
22
21
  @mutex.synchronize do
23
22
  @handlers[event_type] << handler
24
23
  end
25
24
  end
26
25
 
26
+ def validate_handler!(event_type, handler)
27
+ raise ArgumentError, "Invalid event type: #{event_type}" unless valid_event?(event_type)
28
+ raise ArgumentError, "Handler must respond to call" unless handler.respond_to?(:call)
29
+ end
30
+
27
31
  def remove_handler(event_type, handler)
28
32
  return unless valid_event?(event_type)
29
33
 
@@ -54,7 +54,7 @@ module OpenFeature
54
54
  else
55
55
  run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
56
56
  end
57
- rescue => e
57
+ rescue StandardError, NotImplementedError => e
58
58
  run_error_hooks(ordered_hooks, hook_context, e, hints)
59
59
 
60
60
  evaluation_details = EvaluationDetails.new(
@@ -19,10 +19,6 @@ module OpenFeature
19
19
  @flags = flags
20
20
  end
21
21
 
22
- def init(evaluation_context = nil)
23
- # Intentional no-op, used for testing
24
- end
25
-
26
22
  def shutdown
27
23
  # Intentional no-op, used for testing
28
24
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- VERSION = "0.6.6"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -7,10 +7,10 @@ module OpenFeature
7
7
  EMPTY_HINTS: Hooks::Hints
8
8
 
9
9
  attr_reader metadata: ClientMetadata
10
- attr_reader evaluation_context: EvaluationContext?
10
+ attr_accessor evaluation_context: EvaluationContext?
11
11
  attr_accessor hooks: Array[untyped]
12
12
 
13
- def initialize: (provider: untyped, ?domain: String?, ?evaluation_context: EvaluationContext?) -> void
13
+ def initialize: (?provider: untyped, ?domain: String?, ?evaluation_context: EvaluationContext?) -> void
14
14
 
15
15
  def add_hooks: (*Array[untyped] new_hooks) -> void
16
16
 
@@ -40,7 +40,8 @@ module OpenFeature
40
40
  private
41
41
 
42
42
  def fetch_details: (type: Symbol, flag_key: String, default_value: untyped, ?evaluation_context: EvaluationContext?, ?invocation_hooks: Array[untyped], ?hook_hints: Hooks::Hints?) -> EvaluationDetails?
43
- def evaluate_flag: (type: Symbol, flag_key: String, default_value: untyped, evaluation_context: EvaluationContext?) -> EvaluationDetails
43
+ def evaluate_flag: (provider: untyped, type: Symbol, flag_key: String, default_value: untyped, evaluation_context: EvaluationContext?) -> EvaluationDetails
44
+ def resolve_provider: () -> untyped
44
45
  def short_circuit_error_code: (String state) -> String?
45
46
  def build_evaluation_context: (EvaluationContext? invocation_context) -> EvaluationContext?
46
47
  def validate_default_value_type: (Symbol type, untyped default_value) -> void
@@ -32,9 +32,23 @@ module OpenFeature
32
32
 
33
33
  def reset: () -> void
34
34
  def set_provider_internal: (untyped provider, domain: String?, wait_for_init: bool) -> void
35
- def init_provider: (untyped provider, EvaluationContext? context, ?raise_on_error: bool) -> void
36
- def dispatch_provider_event: (untyped provider, String event_type, ?Hash[Symbol, untyped] details) -> void
35
+ def init_provider: (untyped provider, EvaluationContext? context, domain: String?, attempt: untyped, ?raise_on_error: bool) -> void
36
+ def finish_init_attempt: (untyped attempt, Symbol state, ?ProviderInitializationError? error) -> bool
37
+ def abandon_init_attempt: (untyped attempt) -> void
38
+ def incomplete_init_error: (untyped provider, String reason) -> ProviderInitializationError
39
+ def dispatch_provider_event: (untyped provider, String event_type, ?Hash[Symbol, untyped] details, ?token: untyped) -> void
40
+ def deliver_provider_event: (untyped provider, String event_type, Hash[Symbol, untyped]? details, untyped token) -> void
41
+ def accept_provider_event: (untyped provider, String event_type, Hash[Symbol, untyped]? details, untyped token, ?record_status: bool) -> bool
37
42
  def provider_bound?: (untyped provider) -> bool
43
+ def domain_scoped?: (untyped provider) -> bool
44
+ def emits_lifecycle_events?: (untyped provider) -> bool
45
+ def warn_legacy_provider: (untyped provider) -> void
46
+ def running_init_attempt_on_this_thread: (untyped provider) -> untyped
47
+ def running_on_this_thread?: (untyped attempt) -> bool
48
+ def validate_domain_scoped_binding!: (untyped provider, String? domain, bool scoped) -> void
49
+ def call_init: (untyped provider, EvaluationContext? context, String? domain) -> void
50
+ def init_accepts_domain?: (Array[[Symbol, Symbol?]] parameters) -> bool
51
+ def init_accepts_evaluation_context?: (Array[[Symbol, Symbol?]] parameters) -> bool
38
52
  def extract_provider_name: (untyped provider) -> String
39
53
  def run_handlers_for_provider: (untyped provider, String event_type, Hash[Symbol, untyped] event_details) -> void
40
54
  def run_immediate_handler: (String event_type, _ToProc handler, Client? client) -> void
@@ -6,6 +6,7 @@ module OpenFeature
6
6
  def initialize: (?untyped? logger) -> void
7
7
 
8
8
  def add_handler: (String event_type, _ToProc handler) -> void
9
+ def validate_handler!: (String event_type, untyped handler) -> void
9
10
  def remove_handler: (String event_type, _ToProc handler) -> void
10
11
  def remove_all_handlers: (String event_type) -> void
11
12
  def trigger_event: (String event_type, ?Hash[Symbol, untyped] event_details) -> void
@@ -20,7 +20,6 @@ module OpenFeature
20
20
 
21
21
  def initialize: (?Hash[String, untyped] flags) -> void
22
22
 
23
- def init: (?EvaluationContext? evaluation_context) -> void
24
23
  def shutdown: () -> void
25
24
 
26
25
  def add_flag: (flag_key: String, value: untyped) -> void
@@ -11,11 +11,15 @@ module OpenFeature
11
11
  end
12
12
 
13
13
  interface _LifecycleProvider
14
- def init: (?EvaluationContext? evaluation_context) -> void
14
+ def init: (?EvaluationContext? evaluation_context, ?domain: String?) -> void
15
15
  def shutdown: () -> void
16
16
  def metadata: () -> ProviderMetadata
17
17
  end
18
18
 
19
+ interface _DomainScopedProvider
20
+ def domain_scoped?: () -> bool
21
+ end
22
+
19
23
  interface _TrackableProvider
20
24
  def track: (String tracking_event_name, ?evaluation_context: EvaluationContext?, ?tracking_event_details: TrackingEventDetails?) -> void
21
25
  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.6.6
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenFeature Authors