quonfig 2.0.0 → 2.2.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: c7947aba694daddefb1357930d7d1b3e63d13c3f68ad71b1e5d33eff15584aa6
4
- data.tar.gz: db349b68906829c24953f9504511200f7b837c8980ad9beb53a755586420f470
3
+ metadata.gz: 7fb6a1cbb69335648d8e68194ef6c177360f575248942a145b214fae2e3a0bf6
4
+ data.tar.gz: 7782d66fe639d2b690932d7d3c7ed2becb793c0a18252ddd7988424eaf29fec5
5
5
  SHA512:
6
- metadata.gz: 687f621eeff45b850bba0ab6845963abbdb8478ac18e32e1006d9f02de3b8a3ed850b75bf37ae2e907dd9512756f98fc79ae942221aaa7c17a0abd683bb9b920
7
- data.tar.gz: 96792d273e01d2748f78435a75c3d8b4be405ff65470f465b646595539cf786ab4c204cab167350e66b42be03907739e49aaa01ad5b8a2228ebd7736fd4bb8e8
6
+ metadata.gz: 3ff89f7539988b7a95527fcc93b8fd2b1662ed52c664c87e9855c7f9e926877d4240c04c8703e62d93f93e81c3e28ce5540c27682bfcc65e306942d3985084aa
7
+ data.tar.gz: 8ea638dc75cadf24d41bfc6030748cf03116c359e69a3f8c03a72dea26496f01fe64395502a3f6dafd437c929f07ab81121b84c66cdafeb1feff38f58ba6a875
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 - 2026-10-29
4
+
5
+ - **Changed (edge case): the `*_details` getters and `evaluate_details` report a value that cannot be parsed as `error_code: "PARSE_ERROR"` (qfg-wg1n).** A malformed ENV_VAR value (an int config whose env var is `"twelve"`, a duration env var `"PT5S garbage"`) and, on the typed getters, a malformed stored int, double or duration (`"twelve"`, `"abc"`, `"PT0.5H"`) were reported as `TYPE_MISMATCH`; they are now `PARSE_ERROR`, the code OpenFeature uses for this case, with the same `reason: "ERROR"`, nil value, variant and `flag_metadata` as before. A value of another type (`get_int_details` on a string config) is still `TYPE_MISMATCH`. The new constant is `Quonfig::EvaluationDetails::ERROR_PARSE_ERROR`. Only the error code on these failure paths changes: the plain getters, `get_or_raise` and the raised errors (`EnvVarParseError`, `TypeMismatchError`) are unchanged, and `evaluate_details` still returns a malformed stored value as stored with a success reason. Code that compared `error_code` to `TYPE_MISMATCH` to detect a malformed value should also accept `PARSE_ERROR`. quonfig-openfeature 2.3.0 already passes `PARSE_ERROR` straight through. Tests: `test/test_parse_error_details.rb`.
6
+
7
+ ## 2.1.0 - 2026-10-29
8
+
9
+ - **Added: `evaluate_details(key, context:)`, an untyped details read (qfg-177f).** It returns a `Quonfig::EvaluationDetails` whose `value` is the config's resolved value as stored, after ENV_VAR provision and decryption, with no type check or conversion: a duration comes back as its ISO 8601 text exactly as stored (`"PT90S"`, not milliseconds), a `string_list` as an Array, JSON as stored. The new `value_type` attribute names the stored type (`"duration"`, `"int"`, ...; `nil` when nothing resolved). Reason, variant, `flag_metadata`, error codes and telemetry are the same as the typed `*_details` getters, and it never raises. A malformed stored value (a duration `"30s"`) is returned as-is with a success reason; an unset or unparseable ENV_VAR is still `reason: "ERROR"`, as in the typed getters. Available on the client and on a `with_context` bound client. It matches sdk-go's `Client.EvaluateDetails` and is meant for OpenFeature providers and other callers that do their own typing. Existing getters are unchanged. Tests: `test/test_evaluate_details.rb`.
10
+ - **Fixed: `connection_state` no longer answers `:connected` before the SSE stream's first frame (qfg-fi1s).** On a network client with SSE on, the init fetch installs an envelope before the stream has delivered anything, and `connection_state` read that install as `:connected`. It now reports `:initializing` until the stream's first frame arrives (the connected edge), as the supervisor contract says ("after 200 headers, no frame" is initializing) and as sdk-go, sdk-java, sdk-net and sdk-python already do. Datadir clients and clients with `enable_sse: false` still read `:connected` once config is installed. Tests: `test/test_health_primitives.rb`, `test/test_sse_first_frame.rb` (Test 9a/9b now assert on the public getter).
11
+
3
12
  ## 2.0.0 - 2026-10-26
4
13
 
5
14
  The typed-getter contract (qfg-zvyy), shared by every Quonfig SDK and pinned by
data/README.md CHANGED
@@ -480,10 +480,28 @@ default only covers a missing key). For a typed read that raises, call the
480
480
  typed getter with no `default:` under `on_no_default: :raise`.
481
481
 
482
482
  The `*_details` getters (`get_int_details(key, context:)` and so on) never
483
- raise. They return `Quonfig::EvaluationDetails`; a wrong-type or malformed
484
- value is `reason: "ERROR"` with `error_code: "TYPE_MISMATCH"`.
483
+ raise. They return `Quonfig::EvaluationDetails`; a value of another type is
484
+ `reason: "ERROR"` with `error_code: "TYPE_MISMATCH"`, and a value that cannot
485
+ be parsed as its declared type (a malformed ENV_VAR value, or a malformed
486
+ stored int, double or duration) is `reason: "ERROR"` with
487
+ `error_code: "PARSE_ERROR"`.
485
488
  `get_duration_details` carries the duration in integer milliseconds.
486
489
 
490
+ To read a value without a type, use `evaluate_details(key, context:)`. It
491
+ returns the value as stored, with `value_type` naming its type, and the same
492
+ `reason`, `variant` and `flag_metadata` as the typed details getters. A
493
+ duration comes back as its ISO 8601 text:
494
+
495
+ ```ruby
496
+ details = client.evaluate_details('http.timeout', context: { user: { key: 'u1' } })
497
+ details.value # => "PT90S"
498
+ details.value_type # => "duration"
499
+ details.reason # => "STATIC"
500
+ ```
501
+
502
+ It does no type check and never raises; a missing key is `reason: "ERROR"`
503
+ with `error_code: "FLAG_NOT_FOUND"`.
504
+
487
505
  ## Dynamic log levels (SemanticLogger)
488
506
 
489
507
  Quonfig can drive per-class log levels at runtime. Set config keys like
@@ -607,8 +625,8 @@ Caveats:
607
625
  threads race the first request.
608
626
  - **`connection_state` never triggers the re-initialization** — a diagnostic
609
627
  must not open a socket. A child that has not used the client yet answers
610
- `:initializing`, which is exactly what it is; it flips to `:connected` on
611
- first use.
628
+ `:initializing`, which is exactly what it is; it flips to `:connected`
629
+ after its first use, once the stream delivers its first frame.
612
630
  - The child's telemetry aggregators start empty. The parent flushes the data
613
631
  it collected before the fork; the child reports only its own.
614
632
  - **Per-job forking pays per job.** A Resque-style worker that forks a child
@@ -757,7 +775,9 @@ reuse the parent's client object in a child process without it.
757
775
  envelope install (any source: datadir, initial HTTP fetch, SSE, or fallback
758
776
  polling). Returns `nil` before the first install. Preserved across `stop`.
759
777
  - `client.connection_state` — a `Symbol` describing the aggregate state:
760
- `:initializing`, `:connected`, `:disconnected`, or `:falling_back`.
778
+ `:initializing`, `:connected`, `:disconnected`, or `:falling_back`. With
779
+ SSE on, the client reads `:initializing` until the stream delivers its
780
+ first frame, even after the initial fetch has installed config.
761
781
  - `client.callback_errors_total(callback: 'on_config_update')` — how many
762
782
  times your `on_update` block raised. The SDK catches the error, logs it at
763
783
  ERROR with its stack, and keeps delivering updates; a raise is not a worker
@@ -74,6 +74,11 @@ module Quonfig
74
74
  @client.get_duration_details(key, context: @context)
75
75
  end
76
76
 
77
+ # See Quonfig::Client#evaluate_details.
78
+ def evaluate_details(key)
79
+ @client.evaluate_details(key, context: @context)
80
+ end
81
+
77
82
  def enabled?(feature_name)
78
83
  @client.enabled?(feature_name, @context)
79
84
  end
@@ -282,37 +282,54 @@ module Quonfig
282
282
  # carrying the OpenFeature-aligned resolution +reason+ ("STATIC",
283
283
  # "TARGETING_MATCH", "SPLIT", "DEFAULT", or "ERROR") plus an
284
284
  # +error_code+/+error_message+ on the error path. These methods never
285
- # raise — exceptions are caught and rendered as ERROR details.
285
+ # raise — exceptions are caught and rendered as ERROR details. A value of
286
+ # another type is error_code TYPE_MISMATCH; a value that cannot be parsed
287
+ # as its declared type (a malformed ENV_VAR value, a malformed stored
288
+ # int, double or duration) is PARSE_ERROR.
286
289
 
287
290
  def get_bool_details(key, context: NO_DEFAULT_PROVIDED)
288
- evaluate_details(key, :bool, context)
291
+ build_details(key, :bool, context)
289
292
  end
290
293
 
291
294
  def get_string_details(key, context: NO_DEFAULT_PROVIDED)
292
- evaluate_details(key, String, context)
295
+ build_details(key, String, context)
293
296
  end
294
297
 
295
298
  def get_int_details(key, context: NO_DEFAULT_PROVIDED)
296
- evaluate_details(key, Integer, context)
299
+ build_details(key, Integer, context)
297
300
  end
298
301
 
299
302
  def get_float_details(key, context: NO_DEFAULT_PROVIDED)
300
- evaluate_details(key, Float, context)
303
+ build_details(key, Float, context)
301
304
  end
302
305
 
303
306
  def get_string_list_details(key, context: NO_DEFAULT_PROVIDED)
304
- evaluate_details(key, :string_list, context)
307
+ build_details(key, :string_list, context)
305
308
  end
306
309
 
307
310
  def get_json_details(key, context: NO_DEFAULT_PROVIDED)
308
- evaluate_details(key, :json, context)
311
+ build_details(key, :json, context)
309
312
  end
310
313
 
311
314
  # Integer milliseconds in +value+ (qfg-2agi.27). A config whose valueType
312
315
  # is not duration is ERROR / TYPE_MISMATCH; a malformed value (stored or
313
- # ENV_VAR) is ERROR with a nil value.
316
+ # ENV_VAR) is ERROR / PARSE_ERROR with a nil value.
314
317
  def get_duration_details(key, context: NO_DEFAULT_PROVIDED)
315
- evaluate_details(key, :duration, context)
318
+ build_details(key, :duration, context)
319
+ end
320
+
321
+ # Untyped details read (matches sdk-go Client.EvaluateDetails). +value+
322
+ # is the resolved value in its stored form, after ENV_VAR provision and
323
+ # decryption, with no type check, coercion or parse validation: a
324
+ # duration is its ISO 8601 text exactly as stored ("PT90S"), a
325
+ # string_list an Array, a json value as stored. +value_type+ is the
326
+ # stored value type ('string', 'int', 'double', 'bool', 'string_list',
327
+ # 'duration', 'json', ...; nil when nothing resolved). Reason, variant,
328
+ # error_code and telemetry are the same as the typed *_details getters,
329
+ # and like them it never raises. Meant for OpenFeature providers and
330
+ # other callers that do their own typing.
331
+ def evaluate_details(key, context: NO_DEFAULT_PROVIDED)
332
+ build_details(key, nil, context)
316
333
  end
317
334
 
318
335
  # True only when the flag resolves to the bool +true+. Never raises
@@ -650,7 +667,8 @@ module Quonfig
650
667
  # Aggregate connection state. Returns one of:
651
668
  #
652
669
  # - +:initializing+ — no envelope has been installed and SSE is not yet
653
- # connected.
670
+ # connected, or (SSE clients) the stream has not delivered its first
671
+ # frame yet, even if the init fetch already installed an envelope.
654
672
  # - +:connected+ — SSE is live, or the SDK is delivering configs from a
655
673
  # loaded envelope (datadir mode or post-initial-fetch with no SSE).
656
674
  # - +:disconnected+ — +stop+ was called, or SSE errored and no fallback
@@ -681,9 +699,14 @@ module Quonfig
681
699
  next :disconnected if sse_channel_expected? && !sse_worker_alive?
682
700
  next :connected if @sse_state == :connected
683
701
  next :disconnected if @sse_state == :error
702
+ # First-frame rule (qfg-fi1s): a client that is supposed to hold an
703
+ # SSE stream is not :connected until that stream delivers its first
704
+ # frame. The init fetch's install alone does not make it connected;
705
+ # "after 200 headers, no frame" is :initializing (contract Test 6).
706
+ next :initializing if sse_channel_expected? && !@sse_ever_connected
684
707
 
685
708
  # No SSE state change yet: state is driven by whether any envelope
686
- # has been installed (datadir / initial fetch).
709
+ # has been installed (datadir / SSE disabled / initial fetch).
687
710
  @last_successful_refresh.nil? ? :initializing : :connected
688
711
  end
689
712
  end
@@ -958,7 +981,7 @@ module Quonfig
958
981
  end
959
982
 
960
983
  # Lazy post-fork re-initialization. Called from every read entry point
961
- # (+get+, +evaluate_details+, +defined?+, +keys+) — the flag read is a
984
+ # (+get+, +evaluate_details+ / the +*_details+ getters, +defined?+, +keys+) — the flag read is a
962
985
  # plain boolean, so the steady-state cost is one comparison per lookup.
963
986
  #
964
987
  # The first caller in the child does what +Client.new+ does: its own
@@ -2185,9 +2208,11 @@ module Quonfig
2185
2208
  end
2186
2209
 
2187
2210
  # Build a Quonfig::EvaluationDetails for +key+, evaluated against the
2188
- # caller's context, after coercing/checking +expected_type+. Never
2189
- # raises; all exceptions become ERROR details.
2190
- def evaluate_details(key, expected_type, context)
2211
+ # caller's context, after coercing/checking +expected_type+. A nil
2212
+ # +expected_type+ is the untyped #evaluate_details read: the stored value
2213
+ # with no type check, plus +value_type+. Never raises; all
2214
+ # exceptions become ERROR details.
2215
+ def build_details(key, expected_type, context)
2191
2216
  ensure_initialized_after_fork
2192
2217
  jit = context == NO_DEFAULT_PROVIDED ? nil : context
2193
2218
  ctx = build_context(jit)
@@ -2208,11 +2233,11 @@ module Quonfig
2208
2233
  )
2209
2234
  rescue Quonfig::Errors::EnvVarParseError => e
2210
2235
  # An ENV_VAR value that does not parse as the config's type is
2211
- # TYPE_MISMATCH, like a malformed stored value (as in sdk-go).
2236
+ # PARSE_ERROR, like a malformed stored value (qfg-wg1n).
2212
2237
  return Quonfig::EvaluationDetails.new(
2213
2238
  value: nil,
2214
2239
  reason: Quonfig::EvaluationDetails::REASON_ERROR,
2215
- error_code: Quonfig::EvaluationDetails::ERROR_TYPE_MISMATCH,
2240
+ error_code: Quonfig::EvaluationDetails::ERROR_PARSE_ERROR,
2216
2241
  error_message: e.message,
2217
2242
  variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
2218
2243
  flag_metadata: build_flag_metadata(nil, nil, nil, nil, nil)
@@ -2234,14 +2259,14 @@ module Quonfig
2234
2259
  config_type = result.config&.dig('type') || result.config&.dig(:type)
2235
2260
 
2236
2261
  begin
2237
- coerced = typed_value(key, result, expected_type)
2262
+ coerced = expected_type.nil? ? stored_value(result) : typed_value(key, result, expected_type)
2238
2263
  rescue Quonfig::Errors::TypeMismatchError, Quonfig::Errors::EnvVarParseError => e
2239
- # A wrong-type value and a malformed one (the getter cannot produce
2240
- # a value of its type) are both TYPE_MISMATCH, as in sdk-go.
2264
+ # A value of another type is TYPE_MISMATCH; a malformed one (a stored
2265
+ # int "twelve", a duration "30s") is PARSE_ERROR (qfg-wg1n).
2241
2266
  return Quonfig::EvaluationDetails.new(
2242
2267
  value: nil,
2243
2268
  reason: Quonfig::EvaluationDetails::REASON_ERROR,
2244
- error_code: Quonfig::EvaluationDetails::ERROR_TYPE_MISMATCH,
2269
+ error_code: details_error_code(e),
2245
2270
  error_message: e.message,
2246
2271
  variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
2247
2272
  flag_metadata: build_flag_metadata(config_id, config_type, nil, nil, nil)
@@ -2257,7 +2282,8 @@ module Quonfig
2257
2282
  value: coerced,
2258
2283
  reason: reason,
2259
2284
  variant: build_variant(reason, result.rule_index, result.weighted_value_index),
2260
- flag_metadata: flag_metadata
2285
+ flag_metadata: flag_metadata,
2286
+ value_type: expected_type.nil? ? stored_value_type(result) : nil
2261
2287
  )
2262
2288
  rescue StandardError => e
2263
2289
  Quonfig::EvaluationDetails.new(
@@ -2270,6 +2296,30 @@ module Quonfig
2270
2296
  )
2271
2297
  end
2272
2298
 
2299
+ def details_error_code(error)
2300
+ if error.is_a?(Quonfig::Errors::EnvVarParseError)
2301
+ Quonfig::EvaluationDetails::ERROR_PARSE_ERROR
2302
+ else
2303
+ Quonfig::EvaluationDetails::ERROR_TYPE_MISMATCH
2304
+ end
2305
+ end
2306
+
2307
+ # +result+'s resolved value in stored form (after ENV_VAR provision and
2308
+ # decryption) with no coercion or parse validation: a duration is its
2309
+ # ISO 8601 text as stored. Hashes and Arrays are copied so a caller
2310
+ # mutating the result cannot rewrite the cached config.
2311
+ def stored_value(result)
2312
+ copy_containers(result.raw_value)
2313
+ end
2314
+
2315
+ def copy_containers(value)
2316
+ case value
2317
+ when Hash then value.to_h { |k, v| [k, copy_containers(v)] }
2318
+ when Array then value.map { |v| copy_containers(v) }
2319
+ else value
2320
+ end
2321
+ end
2322
+
2273
2323
  # Build the variant string per the cross-SDK spec
2274
2324
  # (project/plans/openfeature-resolution-details.md §2).
2275
2325
  def build_variant(reason, rule_index, weighted_value_index)
@@ -15,10 +15,17 @@ module Quonfig
15
15
  #
16
16
  # +error_code+ (only when reason == "ERROR") is one of:
17
17
  # "FLAG_NOT_FOUND" — the key was unknown to the store
18
- # "TYPE_MISMATCH" — the resolved value didn't satisfy the requested type
18
+ # "TYPE_MISMATCH" — the resolved value is of another type than the getter's
19
+ # "PARSE_ERROR" — the value cannot be parsed as its declared type: a
20
+ # malformed ENV_VAR value, or a malformed stored int,
21
+ # double or duration (qfg-wg1n)
19
22
  # "PROVIDER_NOT_READY" — the client has no config yet (init continued
20
23
  # on code defaults, or a caught init failure)
21
24
  # "GENERAL" — any other failure
25
+ #
26
+ # +value_type+ is set only by Client#evaluate_details: the stored value
27
+ # type ("string", "int", "double", "bool", "string_list", "duration",
28
+ # "json", ...), or nil when nothing resolved. The typed getters leave it nil.
22
29
  class EvaluationDetails
23
30
  REASON_STATIC = 'STATIC'
24
31
  REASON_TARGETING_MATCH = 'TARGETING_MATCH'
@@ -28,19 +35,21 @@ module Quonfig
28
35
 
29
36
  ERROR_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'
30
37
  ERROR_TYPE_MISMATCH = 'TYPE_MISMATCH'
38
+ ERROR_PARSE_ERROR = 'PARSE_ERROR'
31
39
  ERROR_GENERAL = 'GENERAL'
32
40
  ERROR_PROVIDER_NOT_READY = 'PROVIDER_NOT_READY'
33
41
 
34
- attr_reader :value, :reason, :error_code, :error_message, :variant, :flag_metadata
42
+ attr_reader :value, :reason, :error_code, :error_message, :variant, :flag_metadata, :value_type
35
43
 
36
44
  def initialize(value:, reason:, error_code: nil, error_message: nil,
37
- variant: nil, flag_metadata: nil)
45
+ variant: nil, flag_metadata: nil, value_type: nil)
38
46
  @value = value
39
47
  @reason = reason
40
48
  @error_code = error_code
41
49
  @error_message = error_message
42
50
  @variant = variant
43
51
  @flag_metadata = flag_metadata
52
+ @value_type = value_type
44
53
  end
45
54
 
46
55
  def ==(other)
@@ -50,12 +59,13 @@ module Quonfig
50
59
  other.error_code == @error_code &&
51
60
  other.error_message == @error_message &&
52
61
  other.variant == @variant &&
53
- other.flag_metadata == @flag_metadata
62
+ other.flag_metadata == @flag_metadata &&
63
+ other.value_type == @value_type
54
64
  end
55
65
  alias eql? ==
56
66
 
57
67
  def hash
58
- [@value, @reason, @error_code, @error_message, @variant, @flag_metadata].hash
68
+ [@value, @reason, @error_code, @error_message, @variant, @flag_metadata, @value_type].hash
59
69
  end
60
70
 
61
71
  def inspect
@@ -64,6 +74,7 @@ module Quonfig
64
74
  parts << "error_message=#{@error_message.inspect}" if @error_message
65
75
  parts << "variant=#{@variant.inspect}" if @variant
66
76
  parts << "flag_metadata=#{@flag_metadata.inspect}" if @flag_metadata
77
+ parts << "value_type=#{@value_type.inspect}" if @value_type
67
78
  "#<Quonfig::EvaluationDetails #{parts.join(' ')}>"
68
79
  end
69
80
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '2.0.0'
4
+ VERSION = '2.2.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-27 00:00:00.000000000 Z
11
+ date: 2026-10-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport