openfeature-sdk 0.6.6 → 0.6.7

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: 93f85fdb9eb64b634a3267d0987415f3e4b9033a9d864e80478dfde83074fa7d
4
+ data.tar.gz: 14e94971fdae29a56d31db9d8ee44716b805b6783e0d381ac04e6348168ccf67
5
5
  SHA512:
6
- metadata.gz: 4bfda5295966ab245426ecaa0d2dbb440b7f273833193906b9fd91fd6ffeb41e5b49e652a9d05dbf6679c08cfc434c4733d34384dfc1d64be995194f3938cdf1
7
- data.tar.gz: 2359651d7407cdd30e41e15e4d764efc47565e025b2046adbc4942da0c25d62396bd5e52991626d728b675b123aa52bcce733f8ebc71703112ebe77d6704e96e
6
+ metadata.gz: c1e726bae4617d91ed82f676270bb51b7355e8694fac68a76af5f8ea046ed60c33d4c31b0a716827ad61edffe2849c3eb444c614975ac8a09707dcf620286c03
7
+ data.tar.gz: 47d740d86cac2c493058c0f35aa6c2489d89f0502e8df3eb9ba850585777934478fe2dcf500b7eac9b71c96cfb2c857bc6026e9d1214e7876bc385788fcf6f18
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.6.6"
2
+ ".": "0.6.7"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.7](https://github.com/open-feature/ruby-sdk/compare/v0.6.6...v0.6.7) (2026-10-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * 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))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * 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))
14
+
3
15
  ## [0.6.6](https://github.com/open-feature/ruby-sdk/compare/v0.6.5...v0.6.6) (2026-10-22)
4
16
 
5
17
 
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.6.7)
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.6.7">
21
+ <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.7&color=blue&style=for-the-badge" />
22
22
  </a>
23
23
 
24
24
  <!-- x-release-please-end -->
@@ -301,14 +301,15 @@ legacy_flag_client = OpenFeature::SDK.build_client(domain: "legacy_flags")
301
301
 
302
302
  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
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
+ Some providers emit additional events: `PROVIDER_CONFIGURATION_CHANGED`, `PROVIDER_STALE`, `PROVIDER_RECONCILING` and `PROVIDER_CONTEXT_CHANGED`.
305
+ Handlers receive a hash with the `provider_name` and any details the provider sent, such as `message` or `error_code`.
305
306
 
306
307
  Please refer to the documentation of the provider you're using to see what events are supported.
307
308
 
308
309
  ```ruby
309
310
  # Register event handlers at the API (global) level
310
311
  ready_handler = ->(event_details) do
311
- puts "Provider #{event_details[:provider].metadata.name} is ready!"
312
+ puts "Provider #{event_details[:provider_name]} is ready!"
312
313
  end
313
314
 
314
315
  OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler)
@@ -351,6 +352,9 @@ This should only be called when your application is in the process of shutting d
351
352
  OpenFeature::SDK.shutdown
352
353
  ```
353
354
 
355
+ The SDK also shuts down a replaced provider once no domain uses it.
356
+ Events from shut-down providers are ignored.
357
+
354
358
  Individual providers can implement a `shutdown` method to perform cleanup:
355
359
 
356
360
  ```ruby
@@ -442,13 +446,21 @@ You’ll then need to write the provider by implementing the `Provider` duck.
442
446
 
443
447
  ```ruby
444
448
  class MyProvider
445
- def init
449
+ # Both arguments are optional; declare only the ones you need.
450
+ # `domain` is the domain the provider was bound to, or nil for the default provider.
451
+ def init(evaluation_context = nil, domain: nil)
446
452
  # Perform any initialization steps with flag management system here
447
453
  # Return value is ignored
448
454
  # **Note** The OpenFeature spec defines a lifecycle method called `initialize` to be called when a new provider is set.
449
455
  # To avoid conflicting with the Ruby `initialize` method, this method should be named `init` when creating a provider.
450
456
  end
451
457
 
458
+ # Optional: return true if this provider keeps state for a single domain.
459
+ # The SDK then raises ArgumentError if the same instance is bound to a second domain.
460
+ def domain_scoped?
461
+ false
462
+ end
463
+
452
464
  def shutdown
453
465
  # Perform any shutdown/reclamation steps with flag management system here
454
466
  # Return value is ignored
@@ -56,12 +56,15 @@ module OpenFeature
56
56
  built_context = build_evaluation_context(evaluation_context)
57
57
 
58
58
  @provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
59
+ rescue
60
+ nil
59
61
  end
60
62
 
61
63
  RESULT_TYPE.each do |result_type|
62
64
  SUFFIXES.each do |suffix|
63
65
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
64
66
  def fetch_#{result_type}_#{suffix}(flag_key:, default_value:, evaluation_context: nil, hooks: [], hook_hints: nil)
67
+ validate_default_value_type(:#{result_type}, default_value)
65
68
  evaluation_details = fetch_details(type: :#{result_type}, flag_key:, default_value:, evaluation_context:, invocation_hooks: hooks, hook_hints: hook_hints)
66
69
  #{"evaluation_details.value" if suffix == :value}
67
70
  end
@@ -72,8 +75,6 @@ module OpenFeature
72
75
  private
73
76
 
74
77
  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
-
77
78
  built_context = build_evaluation_context(evaluation_context)
78
79
 
79
80
  # Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
@@ -97,17 +98,7 @@ module OpenFeature
97
98
  return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
98
99
  end
99
100
 
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
101
+ return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
111
102
  end
112
103
 
113
104
  hook_context = Hooks::HookContext.new(
@@ -143,6 +134,15 @@ module OpenFeature
143
134
  evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
144
135
  end
145
136
  end
137
+ rescue StandardError, NotImplementedError => e
138
+ # Spec 1.4.10 and 1.4.11: report through the details, never raise or log. NotImplementedError is the abstract-method idiom.
139
+ resolution = Provider::ResolutionDetails.new(
140
+ value: default_value,
141
+ error_code: EvaluationError.code_for(e),
142
+ reason: Provider::Reason::ERROR,
143
+ error_message: e.message
144
+ )
145
+ EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
146
146
  end
147
147
 
148
148
  def evaluate_flag(type:, flag_key:, default_value:, evaluation_context:)
@@ -153,9 +153,9 @@ module OpenFeature
153
153
  evaluation_context: evaluation_context
154
154
  )
155
155
 
156
- if TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
156
+ if resolution_details.error_code || TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
157
157
  resolution_details.value = default_value
158
- resolution_details.error_code = Provider::ErrorCode::TYPE_MISMATCH
158
+ resolution_details.error_code ||= Provider::ErrorCode::TYPE_MISMATCH
159
159
  resolution_details.reason = Provider::Reason::ERROR
160
160
  resolution_details.variant = nil
161
161
  end
@@ -20,9 +20,14 @@ 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)
25
+ private_constant :InitAttempt
26
+
23
27
  def initialize
24
28
  @hooks = []
25
29
  @providers = {}
30
+ @init_attempts = {}.compare_by_identity
26
31
  @provider_mutex = Mutex.new
27
32
  @logger = nil
28
33
  @event_dispatcher = EventDispatcher.new(@logger)
@@ -118,6 +123,9 @@ module OpenFeature
118
123
  @provider_state_registry.clear
119
124
  @provider_mutex.synchronize do
120
125
  @providers.clear
126
+ # Release anyone waiting on an init that shutdown cut short
127
+ @init_attempts.each_value { |attempt| attempt.done.close }
128
+ @init_attempts.clear
121
129
  end
122
130
  @hooks.clear
123
131
  @evaluation_context = nil
@@ -129,22 +137,29 @@ module OpenFeature
129
137
  context_for_init = @evaluation_context
130
138
 
131
139
  old_provider = nil
140
+ attempt = nil
132
141
  needs_init = false
142
+ init_thread = nil
133
143
  needs_shutdown = false
134
144
 
145
+ # Provider code runs before taking the lock: it may call back into the SDK
146
+ scoped = domain_scoped?(provider)
147
+
135
148
  @provider_mutex.synchronize do
149
+ validate_domain_scoped_binding!(provider, domain, scoped)
150
+
136
151
  old_provider = @providers[domain]
137
152
 
138
153
  new_providers = @providers.dup
139
154
  new_providers[domain] = provider
140
155
  @providers = new_providers
141
156
 
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
157
+ # Spec 1.1.2.2: initialize once per registration; a failed init is retried on the next set
158
+ attempt = @init_attempts[provider]
159
+ needs_init = attempt.nil? || attempt.state == :failed
146
160
 
147
161
  if needs_init
162
+ attempt = @init_attempts[provider] = InitAttempt.new(:running, nil, Thread::Queue.new)
148
163
  @provider_state_registry.set_initial_state(provider)
149
164
  provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
150
165
  end
@@ -153,6 +168,7 @@ module OpenFeature
153
168
  if old_provider && !old_provider.equal?(provider)
154
169
  needs_shutdown = !provider_bound?(old_provider)
155
170
  if needs_shutdown
171
+ @init_attempts.delete(old_provider)
156
172
  @provider_state_registry.remove_provider(old_provider)
157
173
  old_provider.send(:detach) if old_provider.is_a?(Provider::EventEmitter)
158
174
  end
@@ -171,45 +187,65 @@ module OpenFeature
171
187
  # Initialize provider outside the mutex to avoid blocking other operations
172
188
  if needs_init
173
189
  if wait_for_init
174
- init_provider(provider, context_for_init, raise_on_error: true)
190
+ init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: true)
175
191
  else
176
- Thread.new do
177
- init_provider(provider, context_for_init, raise_on_error: false)
192
+ init_thread = Thread.new do
193
+ init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: false)
178
194
  end
179
195
  end
180
196
  elsif wait_for_init
181
- # Provider already active; no init needed but still dispatch READY
197
+ # Already registered: wait for an init still in flight, then report its outcome
198
+ attempt.done.pop
199
+ unless attempt.state == :succeeded
200
+ raise attempt.error || ProviderInitializationError.new(
201
+ "Provider #{provider.class.name} initialization did not complete",
202
+ provider:,
203
+ error_code: Provider::ErrorCode::GENERAL
204
+ )
205
+ end
206
+
182
207
  dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
183
208
  end
209
+ ensure
210
+ # Fail an attempt no init took over (e.g. a Timeout hit first), or it would stay running forever
211
+ finish_init_attempt(attempt, :failed) if needs_init && !init_thread
184
212
  end
185
213
 
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
214
+ def init_provider(provider, context, domain:, attempt:, raise_on_error: false)
215
+ call_init(provider, context, domain)
195
216
 
217
+ # Settle the attempt before handlers run, so a handler can register this provider again
218
+ finish_init_attempt(attempt, :succeeded)
196
219
  dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
197
220
  rescue => e
198
221
  # Spec 1.7.5: PROVIDER_FATAL from init makes the provider FATAL
199
222
  error_code = EvaluationError.code_for(e)
223
+ error = ProviderInitializationError.new(
224
+ "Provider #{provider.class.name} initialization failed: #{e.message}",
225
+ provider:,
226
+ error_code: error_code,
227
+ original_error: e
228
+ )
229
+ finish_init_attempt(attempt, :failed, error)
200
230
 
201
231
  dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
202
232
  error_code: error_code,
203
233
  message: e.message)
204
234
 
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
- )
235
+ raise error if raise_on_error
236
+ ensure
237
+ # Reached unsettled only when init raised something other than a StandardError
238
+ finish_init_attempt(attempt, :failed)
239
+ end
240
+
241
+ def finish_init_attempt(attempt, state, error = nil)
242
+ @provider_mutex.synchronize do
243
+ return unless attempt.state == :running
244
+
245
+ attempt.state = state
246
+ attempt.error = error
212
247
  end
248
+ attempt.done.close
213
249
  end
214
250
 
215
251
  def dispatch_provider_event(provider, event_type, details = {})
@@ -239,6 +275,40 @@ module OpenFeature
239
275
  provider.respond_to?(:metadata) ? provider.metadata.name : provider.class.name
240
276
  end
241
277
 
278
+ def domain_scoped?(provider)
279
+ provider.respond_to?(:domain_scoped?) && provider.domain_scoped?
280
+ end
281
+
282
+ def validate_domain_scoped_binding!(provider, domain, scoped)
283
+ return unless scoped && provider_bound?(provider) && !@providers[domain].equal?(provider)
284
+
285
+ raise ArgumentError, "Cannot bind domain-scoped provider to more than one domain"
286
+ end
287
+
288
+ def call_init(provider, context, domain)
289
+ return unless provider.respond_to?(:init)
290
+
291
+ parameters = provider.method(:init).parameters
292
+ takes_domain = init_accepts_domain?(parameters)
293
+ if !takes_domain && domain_scoped?(provider)
294
+ @logger&.warn("Provider #{extract_provider_name(provider)} is domain-scoped but its init doesn't accept domain:")
295
+ end
296
+
297
+ args = init_accepts_evaluation_context?(parameters) ? [context] : []
298
+ provider.init(*args, **(takes_domain ? {domain:} : {}))
299
+ end
300
+
301
+ # Anonymous `**` (from `...`) and delegators' bare `*` can't be trusted to accept `domain:`
302
+ def init_accepts_domain?(parameters)
303
+ parameters.any? do |kind, name|
304
+ ((kind == :key || kind == :keyreq) && name == :domain) || (kind == :keyrest && name && name != :**)
305
+ end
306
+ end
307
+
308
+ def init_accepts_evaluation_context?(parameters)
309
+ parameters.any? { |kind, _| kind == :req || kind == :opt || kind == :rest }
310
+ end
311
+
242
312
  def run_handlers_for_provider(provider, event_type, event_details)
243
313
  # Run global handlers (API-level, no domain filtering)
244
314
  @event_dispatcher.trigger_event(event_type, event_details)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- VERSION = "0.6.6"
5
+ VERSION = "0.6.7"
6
6
  end
7
7
  end
@@ -32,9 +32,15 @@ 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
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) -> void
36
37
  def dispatch_provider_event: (untyped provider, String event_type, ?Hash[Symbol, untyped] details) -> void
37
38
  def provider_bound?: (untyped provider) -> bool
39
+ def domain_scoped?: (untyped provider) -> bool
40
+ def validate_domain_scoped_binding!: (untyped provider, String? domain, bool scoped) -> void
41
+ def call_init: (untyped provider, EvaluationContext? context, String? domain) -> void
42
+ def init_accepts_domain?: (Array[[Symbol, Symbol?]] parameters) -> bool
43
+ def init_accepts_evaluation_context?: (Array[[Symbol, Symbol?]] parameters) -> bool
38
44
  def extract_provider_name: (untyped provider) -> String
39
45
  def run_handlers_for_provider: (untyped provider, String event_type, Hash[Symbol, untyped] event_details) -> void
40
46
  def run_immediate_handler: (String event_type, _ToProc handler, Client? client) -> 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.6.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenFeature Authors