quonfig 1.11.0 → 2.1.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: 6d1016bb1930249b2a74a4f670e25cb2143ff0e664edd54062c585437a85527a
4
- data.tar.gz: 8e2e0844f5853cf5c1d27f45054e71dc6cda6f8f5aa398468c982a485659d1b8
3
+ metadata.gz: 939842e745249a0ce6cee689801a6fe3a7cbdc56552ad88bdabaa0c36e79ef92
4
+ data.tar.gz: df5b171629c4b6c556f04b80a0035d5726c76929e78d11ba5382332d0e4b2293
5
5
  SHA512:
6
- metadata.gz: 705580a318dd7d773b6b0b01236f0509b6d60f440e44e21d07228de7ce6915d71c9ffe9c9c60a6a4c50ed95ed39018727bbb2ed89389610d4df8c9058b196f48
7
- data.tar.gz: a9e69afa6be50db19b7508e9224203dda7c62ef522d1b591d2d7050ffdd69cbef217433791330fead5435c9ebe9fcbd364c245be5127ff9fbb1443e74be6a586
6
+ metadata.gz: 4c6822328fa6147d3816b404fe9543991d7095a5f663a2dc045f425113daba93f18da02e739c32dfce962858b00c190809acf4a62e56f1f049669bf813d8cdcd
7
+ data.tar.gz: 04a636c09573823cf0895c3cbbdcd2d9d79f22b875a877e55587244a2077bf1ae054007b729cc64bb8a2208e806fbdb962a7c55be38f2663c015b41d2685e09d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,105 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 - 2026-10-29
4
+
5
+ - **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`.
6
+ - **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).
7
+
8
+ ## 2.0.0 - 2026-10-26
9
+
10
+ The typed-getter contract (qfg-zvyy), shared by every Quonfig SDK and pinned by
11
+ the integration-test-data matrix (`getter.contract.*`, tag `v2026.10.26`). A
12
+ typed read that cannot return a value of the getter's type no longer raises
13
+ past your `default:`; your default applies.
14
+
15
+ ### UPGRADE to 2.0
16
+
17
+ 1. **A failed read returns your `default:`.** In 1.x the typed getters raised
18
+ even when you passed a `default:`: `get_int('k', default: 0)` on a string
19
+ config raised `TypeMismatchError`, and any getter on an unset ENV_VAR or a
20
+ secret that cannot be decrypted raised `MissingEnvVarError` /
21
+ `DecryptionError`. In 2.0 the getter returns the default. This covers a
22
+ value of another type, a malformed stored or ENV_VAR value, an unset ENV_VAR
23
+ and a decryption failure. The untyped `get(key, default)` does the same for
24
+ the failures it can see. Code that rescued these errors around a getter
25
+ with a `default:` now gets the default instead.
26
+
27
+ 2. **No default: `on_no_default` decides.** `:raise` (the default) still raises
28
+ the error (`TypeMismatchError`, `EnvVarParseError`, `MissingEnvVarError`,
29
+ `DecryptionError`), as in 1.x. Under `on_no_default: :return_nil` the getter
30
+ now returns `nil` instead of raising.
31
+
32
+ 3. **No string coercion.** A stored string is never a bool or a number:
33
+ `get_bool` on a string `"false"` is a type mismatch, and `enabled?` on a
34
+ string `"true"` is `false` (1.x returned `true`). Store values with the
35
+ config type you read them with.
36
+
37
+ 4. **Numbers convert only when exact.** `get_float` on an int config now
38
+ returns the value (`30` -> `30.0`); 1.x raised `TypeMismatchError`.
39
+ `get_int` on a double config returns the value when it is whole and within
40
+ +/-(2^53-1) (`3.0` -> `3`); `3.9`, `1e300` and `2^53` are type mismatches,
41
+ never truncated.
42
+
43
+ 5. **Stricter types.** `get_json` reads json configs only, `get_string_list`
44
+ string_list configs only, and `get_string` string and log_level configs
45
+ only. In 1.x `get_json` returned any value and `get_string_list` accepted
46
+ any array of strings.
47
+
48
+ 6. **`enabled?` never raises.** It returns `false` for a missing key, a flag
49
+ with no matching rule, a value that is not the bool `true`, and any
50
+ resolution error (1.x raised `MissingEnvVarError` / `DecryptionError` and
51
+ errors from a malformed value).
52
+
53
+ 7. **One WARN per key.** A failed read that returns your default or `nil`, and
54
+ `enabled?` returning `false` for one of the reasons above, log one WARN per
55
+ key per client: `[quonfig] read of "<key>" returned no value: <reason>`. It
56
+ names the key and the types, never the value. A wrong decryption key in
57
+ production now degrades to your defaults with these WARNs. **If that must be
58
+ fatal**, read without a `default:` on a client with `on_no_default: :raise`
59
+ (the default), or use `get_or_raise`, which raises `MissingEnvVarError`,
60
+ `EnvVarParseError` and `DecryptionError` even when you pass a `default:`.
61
+
62
+ ### Changed
63
+
64
+ - **Major: typed getters (`get_string`, `get_int`, `get_float`, `get_bool`,
65
+ `get_string_list`, `get_duration`, `get_json`, and the same on
66
+ `BoundClient`) follow the contract above.** The 1.x identity bypass is gone:
67
+ a value that happened to be `equal?` to the default skipped the type check,
68
+ so `get_string('a_bool', default: true)` returned `true` with no error.
69
+ - **Major: `enabled?` is true only for the bool `true`.**
70
+ - **Major: `get(key, default)` returns the default on an unset ENV_VAR, a
71
+ decryption failure or a malformed value.** A malformed stored duration read
72
+ with no default now follows `on_no_default` (1.x returned `nil`).
73
+ - **`*_details`: a malformed value, stored or ENV_VAR, is `TYPE_MISMATCH`** (it
74
+ was `GENERAL` for an ENV_VAR value), as in sdk-go. `get_int_details` /
75
+ `get_float_details` follow the int <-> double rule.
76
+ - **`get_or_raise` raises `EnvVarParseError` on a malformed stored int or
77
+ double** (1.x raised a bare `ArgumentError`).
78
+ - **A `decryptWith` key config that is missing, matches no rule or is empty now raises `DecryptionError`** (1.x raised a plain `Quonfig::Error`), so it counts as a decryption failure and the typed getters apply your default. `DecryptionError` is a `Quonfig::Error`, so `rescue Quonfig::Error` still catches it.
79
+ - **`TypeMismatchError` names the config's value type** (`... expected Integer,
80
+ got a string value`) when the type is known.
81
+
82
+ ### Tests
83
+
84
+ - All workflows run integration-test-data `v2026.10.26`, which adds the
85
+ typed-getter matrix to `get.yaml`, `get_or_raise.yaml` and `enabled.yaml`.
86
+ The typed `get_or_raise` cases call the typed getter on a client with
87
+ `on_no_default: :raise` (Ruby's untyped `get_or_raise` cannot tell a
88
+ wrong-type value from a found one); that generator change is
89
+ quonfig/integration-test-data `ruby.ts`, see the PR for this release.
90
+ - `test/test_typed_getter_contract.rb` covers what the corpus cannot: the
91
+ once-per-key WARN, `enabled?` never raising, int <-> double edges (NaN,
92
+ infinity, 2^53), the Details error codes, and the untyped `get` /
93
+ `get_or_raise`.
94
+
95
+ ### Fixed
96
+
97
+ - **A config payload refused as older no longer pins its ETag (qfg-j7ui).** When a `/api/v2/configs` 200 is dropped by the reject-older guard because its generation is lower than the one held, the SDK no longer remembers its ETag. Before, if the server later repaired that same commit to a higher generation, every poll got a 304 and the client stayed on the old config until the next commit. The same already held for a generation-0 payload (qfg-9dxb.9). A same-generation re-delivery still keeps its ETag. Pinned by supervisor contract Test 11 (integration-test-data `v2026.10.27.1`).
98
+
99
+ ### Docs
100
+
101
+ - **README note on forking servers during a delivery outage (qfg-6vno.24).** Under Startup failure: a forked worker rebuilds its own snapshot under the same `on_init_failure` mode, `:fail` raises until delivery heals, `:wait` blocks the first request, and frequent recyclers should use `:continue`. Links to the startup-failure docs page. No code change.
102
+
3
103
  ## 1.11.0 - 2026-10-23
4
104
 
5
105
  - **Changed (minor): SSE events and config bodies have size caps (qfg-6vno.20.5, cross-SDK rule qfg-6vno.8).** One SSE event (any single line, and the joined lines of a multi-line `data:`) is capped at 4 MiB (4,194,304 bytes), and a 200 body from `/api/v2/configs` at 16 MiB (16,777,216 bytes). Before, both were read with no limit (1.8.0 noted "there is still no size cap"), so a broken proxy or a stream with no newline could grow memory without bound.
data/README.md CHANGED
@@ -298,6 +298,24 @@ timeout runs out (false). Under `:wait`, point your readiness probe at
298
298
  `ready?`. `:raise` and `:return` are the deprecated spellings of `:fail` and
299
299
  `:continue`; they still work and log a one-time WARN.
300
300
 
301
+ ### Forking servers (Puma, Sidekiq)
302
+
303
+ A forked worker (Puma cluster or phased restart, Unicorn, Sidekiq, Resque)
304
+ never serves the parent's config: it rebuilds its own snapshot on first use and
305
+ applies the same `on_init_failure` mode as a fresh boot (see
306
+ [Rails integration](#rails-integration)). If delivery is unreachable when a
307
+ worker is recycled:
308
+
309
+ - `:fail` (default): the worker's first lookup raises
310
+ `Quonfig::Errors::InitializationError`, and later lookups keep raising until
311
+ delivery heals.
312
+ - `:wait`: the worker's first request blocks until delivery is back.
313
+ - `:continue`: the worker serves code defaults until it heals. Apps that
314
+ recycle workers often (frequent phased restarts, a fork per job) should use
315
+ this and keep their defaults safe.
316
+
317
+ Details: [Startup failure: forked workers](https://docs.quonfig.com/docs/explanations/architecture/startup-failure#forked-workers-and-worker-recycling).
318
+
301
319
  ## Constructor options
302
320
 
303
321
  ```ruby
@@ -391,12 +409,40 @@ Each typed getter takes a config key plus two optional keyword arguments:
391
409
  for this call). On a `BoundClient` (see `with_context` above) the getters take
392
410
  only `default:`; the context is the bound one.
393
411
 
412
+ Upgrading from 1.x? See [UPGRADE to 2.0](CHANGELOG.md#upgrade-to-20).
413
+
394
414
  - **Missing key:** returns `default:` when you pass one. Without a default the
395
415
  `on_no_default` option decides: `:raise` (the default) raises
396
416
  `Quonfig::Errors::MissingDefaultError`, `:return_nil` returns `nil`.
397
- - **Wrong type:** a value that does not match the getter's type (for example
398
- `get_int` on a string config) raises `Quonfig::Errors::TypeMismatchError`,
399
- with or without a `default:`.
417
+ - **Failed read:** a read that cannot produce a value of the getter's type
418
+ returns `default:` when you pass one and logs one WARN per key (it names the
419
+ key, never the value). Without a default it follows `on_no_default`:
420
+ `:return_nil` returns `nil` (and logs the WARN), `:raise` raises the error:
421
+
422
+ | Situation | Error raised with no default under `:raise` |
423
+ |---|---|
424
+ | Value of another type (e.g. `get_bool` on a string config) | `Quonfig::Errors::TypeMismatchError` |
425
+ | Malformed stored or ENV_VAR value (an int `"twelve"`, a duration `"30s"`) | `Quonfig::Errors::EnvVarParseError` |
426
+ | ENV_VAR not set | `Quonfig::Errors::MissingEnvVarError` |
427
+ | Secret cannot be decrypted | `Quonfig::Errors::DecryptionError` |
428
+
429
+ Strings are never coerced: a stored `"false"` is a string, so `get_bool`
430
+ treats it as a type mismatch instead of returning `false`. The only conversions
431
+ are between numbers, and only when exact: `get_float` reads an int config
432
+ (`30` -> `30.0`), and `get_int` reads a double config holding a whole number
433
+ within +/-(2^53-1) (`3.0` -> `3`; `3.9` is a type mismatch, never truncated).
434
+ The untyped `get(key, default)` follows the same rule for the failures it can
435
+ see (malformed values, ENV_VAR and decryption errors).
436
+
437
+ `enabled?` never raises. It returns `true` only for a flag that resolves to the
438
+ bool `true`, and `false` (with one WARN per key) for a missing key, a flag with
439
+ no matching rule, a value that is not a bool (a stored string `"true"`
440
+ included) or any resolution error.
441
+
442
+ A wrong decryption key or an unset ENV_VAR therefore degrades to your defaults
443
+ with one WARN per key. If a broken config must stop your service, call the
444
+ getter without a `default:` on a client with `on_no_default: :raise` (the
445
+ default), or use `get_or_raise`.
400
446
 
401
447
  | Method | Returns |
402
448
  |-----------------------------------------------------|-------------------------------------------|
@@ -407,11 +453,10 @@ only `default:`; the context is the bound one.
407
453
  | `get_string_list(key, default:, context:)` | `Array<String>` |
408
454
  | `get_duration(key, default:, context:)` | `Integer` (milliseconds) |
409
455
  | `get_json(key, default:, context:)` | `Hash`, `Array`, or a scalar |
410
- | `enabled?(feature_name, context = nil)` | `true` or `false` (`false` when missing) |
456
+ | `enabled?(feature_name, context = nil)` | `true` or `false`; never raises |
411
457
 
412
458
  `get_duration` returns integer milliseconds (`PT1.5S` is `1500`). A duration
413
- value that is not valid ISO-8601 returns `default:` and logs one warning per
414
- key; with no default it follows `on_no_default`.
459
+ value that is not valid ISO-8601 is a malformed value (see above).
415
460
 
416
461
  Example:
417
462
 
@@ -426,13 +471,33 @@ client.get_json('homepage.layout', default: {})
426
471
  client.enabled?('beta-feature', user: { key: 'user-123' })
427
472
  ```
428
473
 
429
- `get_or_raise(key, default:, context:)` returns the value like `get`, but a
430
- missing key without a `default:` always raises
431
- `Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to), and
432
- an unset or malformed ENV_VAR value, a malformed stored duration, or a
433
- decryption failure raises its error. `get_duration_details(key, context:)`
434
- returns `Quonfig::EvaluationDetails` with the duration in integer milliseconds,
435
- like the other `*_details` getters.
474
+ `get_or_raise(key, default:, context:)` returns the value like `get` (untyped),
475
+ but a missing key without a `default:` always raises
476
+ `Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to),
477
+ and an unset or malformed ENV_VAR value, a malformed stored value, or a
478
+ decryption failure raises its error even when you pass a `default:` (the
479
+ default only covers a missing key). For a typed read that raises, call the
480
+ typed getter with no `default:` under `on_no_default: :raise`.
481
+
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"`.
485
+ `get_duration_details` carries the duration in integer milliseconds.
486
+
487
+ To read a value without a type, use `evaluate_details(key, context:)`. It
488
+ returns the value as stored, with `value_type` naming its type, and the same
489
+ `reason`, `variant` and `flag_metadata` as the typed details getters. A
490
+ duration comes back as its ISO 8601 text:
491
+
492
+ ```ruby
493
+ details = client.evaluate_details('http.timeout', context: { user: { key: 'u1' } })
494
+ details.value # => "PT90S"
495
+ details.value_type # => "duration"
496
+ details.reason # => "STATIC"
497
+ ```
498
+
499
+ It does no type check and never raises; a missing key is `reason: "ERROR"`
500
+ with `error_code: "FLAG_NOT_FOUND"`.
436
501
 
437
502
  ## Dynamic log levels (SemanticLogger)
438
503
 
@@ -557,8 +622,8 @@ Caveats:
557
622
  threads race the first request.
558
623
  - **`connection_state` never triggers the re-initialization** — a diagnostic
559
624
  must not open a socket. A child that has not used the client yet answers
560
- `:initializing`, which is exactly what it is; it flips to `:connected` on
561
- first use.
625
+ `:initializing`, which is exactly what it is; it flips to `:connected`
626
+ after its first use, once the stream delivers its first frame.
562
627
  - The child's telemetry aggregators start empty. The parent flushes the data
563
628
  it collected before the fork; the child reports only its own.
564
629
  - **Per-job forking pays per job.** A Resque-style worker that forks a child
@@ -707,7 +772,9 @@ reuse the parent's client object in a child process without it.
707
772
  envelope install (any source: datadir, initial HTTP fetch, SSE, or fallback
708
773
  polling). Returns `nil` before the first install. Preserved across `stop`.
709
774
  - `client.connection_state` — a `Symbol` describing the aggregate state:
710
- `:initializing`, `:connected`, `:disconnected`, or `:falling_back`.
775
+ `:initializing`, `:connected`, `:disconnected`, or `:falling_back`. With
776
+ SSE on, the client reads `:initializing` until the stream delivers its
777
+ first frame, even after the initial fetch has installed config.
711
778
  - `client.callback_errors_total(callback: 'on_config_update')` — how many
712
779
  times your `on_update` block raised. The SDK catches the error, logs it at
713
780
  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
@@ -21,6 +21,42 @@ module Quonfig
21
21
  class Client
22
22
  LOG = Quonfig::InternalLogger.new(self)
23
23
 
24
+ # Errors that make a read of a present key fail (qfg-zvyy). Each one is
25
+ # handled by #failed_read: the caller's default, nil, or the error.
26
+ READ_FAILURES = [
27
+ Quonfig::Errors::TypeMismatchError,
28
+ Quonfig::Errors::EnvVarParseError,
29
+ Quonfig::Errors::MissingEnvVarError,
30
+ Quonfig::Errors::DecryptionError
31
+ ].freeze
32
+
33
+ # Largest integer a double holds exactly: +/-(2^53-1), the bound the
34
+ # cross-SDK contract (and cli verify) uses for a double -> int read.
35
+ MAX_SAFE_INTEGER = (2**53) - 1
36
+
37
+ # Declared value type each typed getter reads. The only conversions are
38
+ # between numbers, and only when exact (see #typed_number).
39
+ ACCEPTED_TYPES = {
40
+ String => %w[string log_level],
41
+ Integer => %w[int double],
42
+ Float => %w[double int],
43
+ bool: %w[bool],
44
+ string_list: %w[string_list],
45
+ json: %w[json],
46
+ duration: %w[duration]
47
+ }.freeze
48
+
49
+ TYPE_NAMES = {
50
+ String => 'String',
51
+ Integer => 'Integer',
52
+ Float => 'Float',
53
+ bool: 'Boolean',
54
+ string_list: 'Array<String>',
55
+ json: 'JSON',
56
+ duration: 'ISO-8601 Duration'
57
+ }.freeze
58
+ private_constant :READ_FAILURES, :MAX_SAFE_INTEGER, :ACCEPTED_TYPES, :TYPE_NAMES
59
+
24
60
  # The +callback+ label values of quonfig_sdk_callback_errors_total
25
61
  # (supervisor contract). sdk-ruby has no public connection-state callback,
26
62
  # so +on_connection_state_change+ stays 0; it is listed so a reader of
@@ -81,9 +117,12 @@ module Quonfig
81
117
  @evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
82
118
  @resolver = Quonfig::Resolver.new(@store, @evaluator)
83
119
  @semantic_logger_filters = {}
84
- # Keys already warned about for a malformed duration value (qfg-2agi.10).
85
- @malformed_duration_warned = Set.new
86
- @malformed_duration_mutex = Mutex.new
120
+ # Keys already warned about for a failed read: a wrong-type value, a
121
+ # malformed value, an unset ENV_VAR or a decryption failure, plus
122
+ # enabled? returning false for a missing key (qfg-2agi.10, qfg-zvyy).
123
+ # One WARN per key per client.
124
+ @read_failure_warned = Set.new
125
+ @read_failure_mutex = Mutex.new
87
126
  @sse_client = nil
88
127
  @poll_supervisor = nil
89
128
  @stopped = false
@@ -151,23 +190,27 @@ module Quonfig
151
190
 
152
191
  # ---- Lookup --------------------------------------------------------
153
192
 
193
+ # The config's value, untyped.
194
+ #
195
+ # A missing key returns +default+, or applies +on_no_default+ (nil under
196
+ # :return_nil, MissingDefaultError under :raise). A read that fails (an
197
+ # unset ENV_VAR, a decryption failure, a malformed stored or ENV_VAR
198
+ # value) follows the typed-getter contract (qfg-zvyy): +default+ when one
199
+ # is given, otherwise nil under :return_nil or the error itself under
200
+ # :raise, and one WARN per key either way.
154
201
  def get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
155
202
  ensure_initialized_after_fork
156
203
  ctx = build_context(jit_context)
157
204
  record_context_for_telemetry(ctx)
158
- result =
159
- begin
160
- @resolver.get(key, ctx)
161
- rescue Quonfig::Errors::MissingDefaultError
162
- # The Resolver raises (matching Quonfig.get_or_raise semantics).
163
- # The Client's get applies the caller-provided default *or* the
164
- # configured on_no_default policy via handle_missing.
165
- nil
166
- end
167
- return handle_missing(key, default) if result.nil?
205
+ begin
206
+ result = resolve_for_read(key, ctx)
207
+ return handle_missing(key, default) if result.nil?
168
208
 
169
- record_evaluation_for_telemetry(result)
170
- result.unwrapped_value
209
+ record_evaluation_for_telemetry(result)
210
+ unwrap_for_read(result)
211
+ rescue *READ_FAILURES => e
212
+ failed_read(key, default, e)
213
+ end
171
214
  end
172
215
 
173
216
  def get_string(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
@@ -190,31 +233,13 @@ module Quonfig
190
233
  typed_get(key, :string_list, default: default, context: context)
191
234
  end
192
235
 
193
- # Integer milliseconds for a duration config (qfg-2agi.10).
194
- #
195
- # - A config whose valueType is not duration raises TypeMismatchError,
196
- # like get_int on a string config.
197
- # - Values follow the shared grammar (Quonfig::Duration); ms are rounded
198
- # half up on both the stored and the ENV_VAR path.
199
- # - A malformed value (stored or ENV_VAR) returns +default+ and logs one
200
- # warning per key. With no default it returns nil under
201
- # on_no_default: :return_nil and raises EnvVarParseError under :raise.
236
+ # Integer milliseconds for a duration config (qfg-2agi.10). Values follow
237
+ # the shared grammar (Quonfig::Duration); ms are rounded half up on both
238
+ # the stored and the ENV_VAR path. A config whose valueType is not
239
+ # duration, or a malformed duration, is a failed read under the
240
+ # typed-getter contract (see #typed_get).
202
241
  def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
203
- ensure_initialized_after_fork
204
- ctx = build_context(context)
205
- record_context_for_telemetry(ctx)
206
- result =
207
- begin
208
- @resolver.get(key, ctx)
209
- rescue Quonfig::Errors::MissingDefaultError
210
- nil
211
- rescue Quonfig::Errors::EnvVarParseError => e
212
- return malformed_duration(key, default, e)
213
- end
214
- return handle_missing(key, default) if result.nil?
215
-
216
- record_evaluation_for_telemetry(result)
217
- duration_millis(key, result, default)
242
+ typed_get(key, :duration, default: default, context: context)
218
243
  end
219
244
 
220
245
  def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
@@ -248,10 +273,7 @@ module Quonfig
248
273
  end
249
274
 
250
275
  record_evaluation_for_telemetry(result)
251
- value = result.unwrapped_value
252
- raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, value)
253
-
254
- value
276
+ unwrap_for_read(result)
255
277
  end
256
278
 
257
279
  # ---- Details getters ----------------------------------------------
@@ -263,39 +285,71 @@ module Quonfig
263
285
  # raise — exceptions are caught and rendered as ERROR details.
264
286
 
265
287
  def get_bool_details(key, context: NO_DEFAULT_PROVIDED)
266
- evaluate_details(key, :bool, context)
288
+ build_details(key, :bool, context)
267
289
  end
268
290
 
269
291
  def get_string_details(key, context: NO_DEFAULT_PROVIDED)
270
- evaluate_details(key, String, context)
292
+ build_details(key, String, context)
271
293
  end
272
294
 
273
295
  def get_int_details(key, context: NO_DEFAULT_PROVIDED)
274
- evaluate_details(key, Integer, context)
296
+ build_details(key, Integer, context)
275
297
  end
276
298
 
277
299
  def get_float_details(key, context: NO_DEFAULT_PROVIDED)
278
- evaluate_details(key, Float, context)
300
+ build_details(key, Float, context)
279
301
  end
280
302
 
281
303
  def get_string_list_details(key, context: NO_DEFAULT_PROVIDED)
282
- evaluate_details(key, :string_list, context)
304
+ build_details(key, :string_list, context)
283
305
  end
284
306
 
285
307
  def get_json_details(key, context: NO_DEFAULT_PROVIDED)
286
- evaluate_details(key, :json, context)
308
+ build_details(key, :json, context)
287
309
  end
288
310
 
289
311
  # Integer milliseconds in +value+ (qfg-2agi.27). A config whose valueType
290
312
  # is not duration is ERROR / TYPE_MISMATCH; a malformed value (stored or
291
313
  # ENV_VAR) is ERROR with a nil value.
292
314
  def get_duration_details(key, context: NO_DEFAULT_PROVIDED)
293
- evaluate_details(key, :duration, context)
294
- end
295
-
315
+ build_details(key, :duration, context)
316
+ end
317
+
318
+ # Untyped details read (matches sdk-go Client.EvaluateDetails). +value+
319
+ # is the resolved value in its stored form, after ENV_VAR provision and
320
+ # decryption, with no type check, coercion or parse validation: a
321
+ # duration is its ISO 8601 text exactly as stored ("PT90S"), a
322
+ # string_list an Array, a json value as stored. +value_type+ is the
323
+ # stored value type ('string', 'int', 'double', 'bool', 'string_list',
324
+ # 'duration', 'json', ...; nil when nothing resolved). Reason, variant,
325
+ # error_code and telemetry are the same as the typed *_details getters,
326
+ # and like them it never raises. Meant for OpenFeature providers and
327
+ # other callers that do their own typing.
328
+ def evaluate_details(key, context: NO_DEFAULT_PROVIDED)
329
+ build_details(key, nil, context)
330
+ end
331
+
332
+ # True only when the flag resolves to the bool +true+. Never raises
333
+ # (qfg-zvyy): a missing key, a flag with no matching rule, a value that is
334
+ # not a bool (a stored string "true" included) or any resolution error
335
+ # returns false and logs one WARN per key.
296
336
  def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
297
- value = get(feature_name, false, jit_context)
298
- [true, 'true'].include?(value)
337
+ ensure_initialized_after_fork
338
+ begin
339
+ ctx = build_context(jit_context)
340
+ record_context_for_telemetry(ctx)
341
+ result = resolve_for_read(feature_name, ctx)
342
+ if result.nil?
343
+ warn_read_failure(feature_name, 'missing key or no matching rule; enabled? returns false')
344
+ return false
345
+ end
346
+
347
+ record_evaluation_for_telemetry(result)
348
+ typed_value(feature_name, result, :bool) == true
349
+ rescue StandardError => e
350
+ warn_read_failure(feature_name, "#{e.message}; enabled? returns false")
351
+ false
352
+ end
299
353
  end
300
354
 
301
355
  def defined?(key)
@@ -610,7 +664,8 @@ module Quonfig
610
664
  # Aggregate connection state. Returns one of:
611
665
  #
612
666
  # - +:initializing+ — no envelope has been installed and SSE is not yet
613
- # connected.
667
+ # connected, or (SSE clients) the stream has not delivered its first
668
+ # frame yet, even if the init fetch already installed an envelope.
614
669
  # - +:connected+ — SSE is live, or the SDK is delivering configs from a
615
670
  # loaded envelope (datadir mode or post-initial-fetch with no SSE).
616
671
  # - +:disconnected+ — +stop+ was called, or SSE errored and no fallback
@@ -641,9 +696,14 @@ module Quonfig
641
696
  next :disconnected if sse_channel_expected? && !sse_worker_alive?
642
697
  next :connected if @sse_state == :connected
643
698
  next :disconnected if @sse_state == :error
699
+ # First-frame rule (qfg-fi1s): a client that is supposed to hold an
700
+ # SSE stream is not :connected until that stream delivers its first
701
+ # frame. The init fetch's install alone does not make it connected;
702
+ # "after 200 headers, no frame" is :initializing (contract Test 6).
703
+ next :initializing if sse_channel_expected? && !@sse_ever_connected
644
704
 
645
705
  # No SSE state change yet: state is driven by whether any envelope
646
- # has been installed (datadir / initial fetch).
706
+ # has been installed (datadir / SSE disabled / initial fetch).
647
707
  @last_successful_refresh.nil? ? :initializing : :connected
648
708
  end
649
709
  end
@@ -918,7 +978,7 @@ module Quonfig
918
978
  end
919
979
 
920
980
  # Lazy post-fork re-initialization. Called from every read entry point
921
- # (+get+, +evaluate_details+, +defined?+, +keys+) — the flag read is a
981
+ # (+get+, +evaluate_details+ / the +*_details+ getters, +defined?+, +keys+) — the flag read is a
922
982
  # plain boolean, so the steady-state cost is one comparison per lookup.
923
983
  #
924
984
  # The first caller in the child does what +Client.new+ does: its own
@@ -1857,19 +1917,25 @@ module Quonfig
1857
1917
  # last_successful_refresh (only an install or fetch does that).
1858
1918
  on_connected: sse_connected_callback
1859
1919
  )
1920
+ # The block returns the install result (:updated / :not_modified); the
1921
+ # SSE client clears its oversized gate only on :updated (qfg-6vno.20.8).
1922
+ # The connected edge comes from on_connected at the first frame (or,
1923
+ # while the oversized gate is set, at the first installed envelope), so
1924
+ # an envelope the reject-older guard drops never reports connected here.
1860
1925
  @sse_client.start do |envelope, _event, _source|
1861
1926
  next if @stopped
1862
1927
 
1863
1928
  begin
1864
- @config_loader.apply_envelope(envelope)
1929
+ result = @config_loader.apply_envelope(envelope)
1865
1930
  sync_evaluator_env_id!
1866
- handle_sse_state_change(:connected)
1931
+ handle_sse_state_change(:connected) if result == :updated
1867
1932
  record_refresh!
1868
1933
  rescue StandardError => e
1869
1934
  LOG.warn "[quonfig] Error applying SSE envelope: #{e.message}"
1870
1935
  next
1871
1936
  end
1872
1937
  notify_on_update_callback
1938
+ result
1873
1939
  end
1874
1940
  true
1875
1941
  rescue StandardError => e
@@ -2139,9 +2205,11 @@ module Quonfig
2139
2205
  end
2140
2206
 
2141
2207
  # Build a Quonfig::EvaluationDetails for +key+, evaluated against the
2142
- # caller's context, after coercing/checking +expected_type+. Never
2143
- # raises; all exceptions become ERROR details.
2144
- def evaluate_details(key, expected_type, context)
2208
+ # caller's context, after coercing/checking +expected_type+. A nil
2209
+ # +expected_type+ is the untyped #evaluate_details read: the stored value
2210
+ # with no type check, plus +value_type+. Never raises; all
2211
+ # exceptions become ERROR details.
2212
+ def build_details(key, expected_type, context)
2145
2213
  ensure_initialized_after_fork
2146
2214
  jit = context == NO_DEFAULT_PROVIDED ? nil : context
2147
2215
  ctx = build_context(jit)
@@ -2160,6 +2228,17 @@ module Quonfig
2160
2228
  variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
2161
2229
  flag_metadata: build_flag_metadata(nil, nil, nil, nil, nil)
2162
2230
  )
2231
+ rescue Quonfig::Errors::EnvVarParseError => e
2232
+ # An ENV_VAR value that does not parse as the config's type is
2233
+ # TYPE_MISMATCH, like a malformed stored value (as in sdk-go).
2234
+ return Quonfig::EvaluationDetails.new(
2235
+ value: nil,
2236
+ reason: Quonfig::EvaluationDetails::REASON_ERROR,
2237
+ error_code: Quonfig::EvaluationDetails::ERROR_TYPE_MISMATCH,
2238
+ error_message: e.message,
2239
+ variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
2240
+ flag_metadata: build_flag_metadata(nil, nil, nil, nil, nil)
2241
+ )
2163
2242
  end
2164
2243
 
2165
2244
  if result.nil?
@@ -2175,15 +2254,12 @@ module Quonfig
2175
2254
 
2176
2255
  config_id = result.config&.dig('id') || result.config&.dig(:id)
2177
2256
  config_type = result.config&.dig('type') || result.config&.dig(:type)
2178
- raw_value = result.unwrapped_value
2179
2257
 
2180
2258
  begin
2181
- if expected_type == :duration
2182
- raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', raw_value) unless duration_result?(result)
2183
- raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, raw_value)
2184
- end
2185
- coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
2186
- rescue Quonfig::Errors::TypeMismatchError => e
2259
+ coerced = expected_type.nil? ? stored_value(result) : typed_value(key, result, expected_type)
2260
+ rescue Quonfig::Errors::TypeMismatchError, Quonfig::Errors::EnvVarParseError => e
2261
+ # A wrong-type value and a malformed one (the getter cannot produce
2262
+ # a value of its type) are both TYPE_MISMATCH, as in sdk-go.
2187
2263
  return Quonfig::EvaluationDetails.new(
2188
2264
  value: nil,
2189
2265
  reason: Quonfig::EvaluationDetails::REASON_ERROR,
@@ -2203,7 +2279,8 @@ module Quonfig
2203
2279
  value: coerced,
2204
2280
  reason: reason,
2205
2281
  variant: build_variant(reason, result.rule_index, result.weighted_value_index),
2206
- flag_metadata: flag_metadata
2282
+ flag_metadata: flag_metadata,
2283
+ value_type: expected_type.nil? ? stored_value_type(result) : nil
2207
2284
  )
2208
2285
  rescue StandardError => e
2209
2286
  Quonfig::EvaluationDetails.new(
@@ -2216,6 +2293,22 @@ module Quonfig
2216
2293
  )
2217
2294
  end
2218
2295
 
2296
+ # +result+'s resolved value in stored form (after ENV_VAR provision and
2297
+ # decryption) with no coercion or parse validation: a duration is its
2298
+ # ISO 8601 text as stored. Hashes and Arrays are copied so a caller
2299
+ # mutating the result cannot rewrite the cached config.
2300
+ def stored_value(result)
2301
+ copy_containers(result.raw_value)
2302
+ end
2303
+
2304
+ def copy_containers(value)
2305
+ case value
2306
+ when Hash then value.to_h { |k, v| [k, copy_containers(v)] }
2307
+ when Array then value.map { |v| copy_containers(v) }
2308
+ else value
2309
+ end
2310
+ end
2311
+
2219
2312
  # Build the variant string per the cross-SDK spec
2220
2313
  # (project/plans/openfeature-resolution-details.md §2).
2221
2314
  def build_variant(reason, rule_index, weighted_value_index)
@@ -2248,32 +2341,122 @@ module Quonfig
2248
2341
  md
2249
2342
  end
2250
2343
 
2344
+ # A typed read under the typed-getter contract (qfg-zvyy, shared with
2345
+ # every Quonfig SDK and pinned by integration-test-data getter.contract.*).
2346
+ #
2347
+ # A missing key returns +default+ or applies +on_no_default+. A read that
2348
+ # fails (a value of another type, a malformed stored or ENV_VAR value, an
2349
+ # unset ENV_VAR, a decryption failure) returns +default+ when one is
2350
+ # given; with no default it returns nil under on_no_default: :return_nil
2351
+ # and raises the error (TypeMismatchError, EnvVarParseError,
2352
+ # MissingEnvVarError, DecryptionError) under :raise. Either way it logs
2353
+ # one WARN per key per client.
2251
2354
  def typed_get(key, expected_type, default:, context:)
2252
- jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
2253
- value = get(key, default, jit)
2355
+ ensure_initialized_after_fork
2356
+ ctx = build_context(context)
2357
+ record_context_for_telemetry(ctx)
2358
+ begin
2359
+ result = resolve_for_read(key, ctx)
2360
+ return handle_missing(key, default) if result.nil?
2361
+
2362
+ record_evaluation_for_telemetry(result)
2363
+ typed_value(key, result, expected_type)
2364
+ rescue *READ_FAILURES => e
2365
+ failed_read(key, default, e)
2366
+ end
2367
+ end
2368
+
2369
+ # The resolved Evaluation for +key+, or nil when the key is missing or no
2370
+ # rule matched. Resolution errors (MissingEnvVarError, EnvVarParseError,
2371
+ # DecryptionError) propagate.
2372
+ def resolve_for_read(key, ctx)
2373
+ @resolver.get(key, ctx)
2374
+ rescue Quonfig::Errors::MissingDefaultError
2375
+ nil
2376
+ end
2377
+
2378
+ # +result+'s value with no type check. A stored value that does not parse
2379
+ # as its declared type (an int "twelve", a duration "30s") raises
2380
+ # EnvVarParseError, as a malformed ENV_VAR value does.
2381
+ def unwrap_for_read(result)
2382
+ value =
2383
+ begin
2384
+ result.unwrapped_value
2385
+ rescue ArgumentError, TypeError, FloatDomainError
2386
+ raise malformed_stored_value_error(result)
2387
+ end
2388
+ raise malformed_stored_value_error(result) if malformed_stored_duration?(result, value)
2389
+
2390
+ value
2391
+ end
2392
+
2393
+ # +result+'s value as +expected_type+, or raise TypeMismatchError (a value
2394
+ # of another type) / EnvVarParseError (a malformed value). Strings are
2395
+ # never coerced: a stored "false" is not a bool and "3" is not an int.
2396
+ def typed_value(key, result, expected_type)
2397
+ stored = stored_value_type(result)
2398
+ accepted = ACCEPTED_TYPES.fetch(expected_type)
2399
+ raise Quonfig::Errors::TypeMismatchError.new(key, TYPE_NAMES.fetch(expected_type), nil, actual_type: stored) unless stored.nil? || accepted.include?(stored)
2254
2400
 
2255
- # Missing path: resolver returned the caller's default (or nil under
2256
- # on_no_default=:return_nil) — skip type coercion.
2257
- return value if default != NO_DEFAULT_PROVIDED && value.equal?(default)
2401
+ value = unwrap_for_read(result)
2258
2402
  return nil if value.nil?
2259
2403
 
2260
- coerce_and_check(key, value, expected_type)
2404
+ if [Integer, Float].include?(expected_type)
2405
+ typed_number(key, result, value, expected_type)
2406
+ elsif expected_type == :bool
2407
+ typed_bool(key, result)
2408
+ else
2409
+ check_class(key, value, expected_type)
2410
+ end
2261
2411
  end
2262
2412
 
2263
- def duration_millis(key, result, default)
2264
- raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', result.unwrapped_value) unless duration_result?(result)
2413
+ # The Value's declared type ('int', 'string', ...); a resolved ENV_VAR or
2414
+ # decrypted value carries the type it was coerced to. Nil when unknown.
2415
+ def stored_value_type(result)
2416
+ type = result.type
2417
+ if type.nil? || type == 'provided'
2418
+ config = result.config || {}
2419
+ type = config['valueType'] || config[:valueType] || config['value_type'] || config[:value_type]
2420
+ end
2421
+ type&.to_s
2422
+ end
2265
2423
 
2266
- value = result.unwrapped_value
2267
- millis = value.is_a?(Integer) ? value : Quonfig::Duration.parse_millis(value)
2268
- return millis unless millis.nil?
2424
+ # Double getter: any int or double (30 -> 30.0). Int getter: an int, or a
2425
+ # double that is whole and within +/-(2^53-1) (3.0 -> 3). Never truncates:
2426
+ # 3.9, 1e300 and NaN are type mismatches.
2427
+ def typed_number(key, result, value, expected_type)
2428
+ # A stored int whose raw value is a Float would unwrap truncated.
2429
+ raw = result.raw_value
2430
+ number = raw.is_a?(Numeric) ? raw : value
2431
+ mismatch = -> { Quonfig::Errors::TypeMismatchError.new(key, TYPE_NAMES.fetch(expected_type), number) }
2432
+ raise mismatch.call unless number.is_a?(Integer) || number.is_a?(Float)
2269
2433
 
2270
- malformed_duration(key, default, malformed_stored_duration_error(result))
2434
+ return number.to_f if expected_type == Float
2435
+ return number if number.is_a?(Integer)
2436
+ raise mismatch.call unless number.finite? && number == number.floor && number.abs <= MAX_SAFE_INTEGER
2437
+
2438
+ number.to_i
2271
2439
  end
2272
2440
 
2273
- def duration_result?(result)
2274
- config = result.config || {}
2275
- value_type = config['valueType'] || config[:valueType] || config['value_type'] || config[:value_type]
2276
- value_type == 'duration' || result.type == 'duration'
2441
+ # A bool Value unwraps with !!raw, so check the raw value: only a real
2442
+ # true/false is a bool.
2443
+ def typed_bool(key, result)
2444
+ raw = result.raw_value
2445
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', raw) unless [true, false].include?(raw)
2446
+
2447
+ raw
2448
+ end
2449
+
2450
+ def check_class(key, value, expected_type)
2451
+ ok =
2452
+ if expected_type == String then value.is_a?(String)
2453
+ elsif expected_type == :string_list then value.is_a?(Array) && value.all?(String)
2454
+ elsif expected_type == :duration then value.is_a?(Integer)
2455
+ else true # :json: any JSON value
2456
+ end
2457
+ raise Quonfig::Errors::TypeMismatchError.new(key, TYPE_NAMES.fetch(expected_type), value) unless ok
2458
+
2459
+ value
2277
2460
  end
2278
2461
 
2279
2462
  # A stored duration string outside the shared grammar unwraps to nil.
@@ -2281,49 +2464,27 @@ module Quonfig
2281
2464
  unwrapped.nil? && result.type == 'duration' && !result.raw_value.nil?
2282
2465
  end
2283
2466
 
2284
- def malformed_stored_duration_error(result)
2467
+ def malformed_stored_value_error(result)
2285
2468
  Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, nil, stored: true)
2286
2469
  end
2287
2470
 
2288
- def malformed_duration(key, default, error)
2289
- if @malformed_duration_mutex.synchronize { @malformed_duration_warned.add?(key.to_s) }
2290
- LOG.warn "[quonfig] #{key} is not a valid ISO-8601 duration; " \
2291
- "#{default == NO_DEFAULT_PROVIDED ? 'no default given' : 'returning the default'}"
2292
- end
2293
- return default unless default == NO_DEFAULT_PROVIDED
2294
- raise error if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
2471
+ # The failed-read rule (qfg-zvyy): +default+ when one is given, else nil
2472
+ # under on_no_default: :return_nil, each with one WARN per key; else
2473
+ # +error+ under :raise (the raise is the signal, so it is not logged).
2474
+ def failed_read(key, default, error)
2475
+ no_default = default == NO_DEFAULT_PROVIDED
2476
+ raise error if no_default && @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
2295
2477
 
2296
- nil
2478
+ warn_read_failure(key, "#{error.message}; #{no_default ? 'returning nil' : 'returning the default'}")
2479
+ no_default ? nil : default
2297
2480
  end
2298
2481
 
2299
- def coerce_and_check(key, value, expected_type)
2300
- case expected_type
2301
- when :bool
2302
- raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', value) unless [true, false].include?(value)
2303
-
2304
- value
2305
- when :string_list
2306
- arr = value.is_a?(Array) ? value : nil
2307
- raise Quonfig::Errors::TypeMismatchError.new(key, 'Array<String>', value) unless arr&.all?(String)
2482
+ # Logs once per config key per client. The reason names the key and the
2483
+ # types involved, never the value (it can be a decrypted secret).
2484
+ def warn_read_failure(key, reason)
2485
+ return unless @read_failure_mutex.synchronize { @read_failure_warned.add?(key.to_s) }
2308
2486
 
2309
- arr
2310
- when :duration
2311
- return value if value.is_a?(Integer)
2312
-
2313
- millis = Quonfig::Duration.parse_millis(value)
2314
- raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value) if millis.nil?
2315
-
2316
- millis
2317
- when :json
2318
- # JSON values are returned as-is (Hash, Array, or scalar from the wire).
2319
- value
2320
- when Class
2321
- raise Quonfig::Errors::TypeMismatchError.new(key, expected_type.to_s, value) unless value.is_a?(expected_type)
2322
-
2323
- value
2324
- else
2325
- value
2326
- end
2487
+ LOG.warn "[quonfig] read of #{key.to_s.inspect} returned no value: #{reason}"
2327
2488
  end
2328
2489
  end
2329
2490
 
@@ -300,7 +300,8 @@ module Quonfig
300
300
  # when a new event arrives. SSE is a single-leg live stream, so it carries no
301
301
  # config_api_urls index and does not change #resolved_from — but it IS
302
302
  # guarded by the same reject-older rule (a late SSE snapshot must not regress
303
- # an established client).
303
+ # an established client). Returns :updated when the envelope installed and
304
+ # :not_modified when the guard dropped it.
304
305
  def apply_envelope(envelope)
305
306
  install_envelope(envelope, source: :sse, source_index: nil)
306
307
  end
@@ -494,11 +495,12 @@ module Quonfig
494
495
  envelope = parse_envelope(response.body)
495
496
  result = install_envelope(envelope, source: source, source_index: index)
496
497
  # Write this leg's ETag back AFTER the response (per-leg, race-free).
497
- # Exception (qfg-9dxb.9): an ignored gen<=0 payload keeps the previous
498
- # ETag. Its ETag is the git sha, and the server may later repair the
499
- # generation for the SAME sha; remembering it would 304 that repair.
500
- ignored_unversioned = result == :not_modified && extract_generation(envelope.meta || {}) <= 0
501
- set_etag_for(index, new_etag) unless ignored_unversioned
498
+ # Exception (qfg-9dxb.9, qfg-j7ui; supervisor contract Test 11): an
499
+ # ignored gen<=0 payload, or one dropped as STRICTLY older, keeps the
500
+ # previous ETag. Its ETag is the git sha, and the server may later
501
+ # repair the generation for the SAME sha; remembering it would 304 that
502
+ # repair until the next commit. A same-generation 200 stores its ETag.
503
+ set_etag_for(index, new_etag) unless result == :not_modified && refused_by_generation?(envelope)
502
504
  end_oversized_episode(index) if result == :updated
503
505
  # install_envelope returns :not_modified when the reject-older guard drops
504
506
  # an equal/older payload — surface that so the caller doesn't double-count.
@@ -706,9 +708,22 @@ module Quonfig
706
708
  # SSE / datadir installs pass source_index nil and are ignored by the
707
709
  # aggregator, so they are never counted.
708
710
  @failover_aggregator&.record_resolved_from(source_index)
711
+ :updated
709
712
  end
710
713
  end
711
714
 
715
+ # Whether the reject-older guard refused this envelope as unversioned
716
+ # (gen <= 0) or strictly older than the held generation, rather than as a
717
+ # same-generation re-delivery. The held generation only rises, so a
718
+ # payload older at decision time is still older here.
719
+ def refused_by_generation?(envelope)
720
+ incoming_gen = extract_generation(envelope.meta || {})
721
+ return true if incoming_gen <= 0
722
+
723
+ held = @held_generation
724
+ !held.nil? && incoming_gen < held
725
+ end
726
+
712
727
  # The install decision for a network payload (see #install_envelope).
713
728
  def should_install?(incoming_gen)
714
729
  return true if @held_generation.nil?
@@ -7,8 +7,11 @@ module Quonfig
7
7
  # class only. The value itself is never interpolated: it can be a
8
8
  # decrypted secret, and exception messages end up in logs and error
9
9
  # trackers (qfg-goi1.2.11).
10
- def initialize(key, expected, actual_value)
11
- super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}")
10
+ # +actual_type+, when given, is the config's declared value type
11
+ # ('string', 'duration', ...) and is named instead of the Ruby class.
12
+ def initialize(key, expected, actual_value, actual_type: nil)
13
+ got = actual_type ? "a #{actual_type} value" : actual_value.class
14
+ super("Quonfig value for key '#{key}' expected #{expected}, got #{got}")
12
15
  end
13
16
  end
14
17
  end
@@ -19,6 +19,10 @@ module Quonfig
19
19
  # "PROVIDER_NOT_READY" — the client has no config yet (init continued
20
20
  # on code defaults, or a caught init failure)
21
21
  # "GENERAL" — any other failure
22
+ #
23
+ # +value_type+ is set only by Client#evaluate_details: the stored value
24
+ # type ("string", "int", "double", "bool", "string_list", "duration",
25
+ # "json", ...), or nil when nothing resolved. The typed getters leave it nil.
22
26
  class EvaluationDetails
23
27
  REASON_STATIC = 'STATIC'
24
28
  REASON_TARGETING_MATCH = 'TARGETING_MATCH'
@@ -31,16 +35,17 @@ module Quonfig
31
35
  ERROR_GENERAL = 'GENERAL'
32
36
  ERROR_PROVIDER_NOT_READY = 'PROVIDER_NOT_READY'
33
37
 
34
- attr_reader :value, :reason, :error_code, :error_message, :variant, :flag_metadata
38
+ attr_reader :value, :reason, :error_code, :error_message, :variant, :flag_metadata, :value_type
35
39
 
36
40
  def initialize(value:, reason:, error_code: nil, error_message: nil,
37
- variant: nil, flag_metadata: nil)
41
+ variant: nil, flag_metadata: nil, value_type: nil)
38
42
  @value = value
39
43
  @reason = reason
40
44
  @error_code = error_code
41
45
  @error_message = error_message
42
46
  @variant = variant
43
47
  @flag_metadata = flag_metadata
48
+ @value_type = value_type
44
49
  end
45
50
 
46
51
  def ==(other)
@@ -50,12 +55,13 @@ module Quonfig
50
55
  other.error_code == @error_code &&
51
56
  other.error_message == @error_message &&
52
57
  other.variant == @variant &&
53
- other.flag_metadata == @flag_metadata
58
+ other.flag_metadata == @flag_metadata &&
59
+ other.value_type == @value_type
54
60
  end
55
61
  alias eql? ==
56
62
 
57
63
  def hash
58
- [@value, @reason, @error_code, @error_message, @variant, @flag_metadata].hash
64
+ [@value, @reason, @error_code, @error_message, @variant, @flag_metadata, @value_type].hash
59
65
  end
60
66
 
61
67
  def inspect
@@ -64,6 +70,7 @@ module Quonfig
64
70
  parts << "error_message=#{@error_message.inspect}" if @error_message
65
71
  parts << "variant=#{@variant.inspect}" if @variant
66
72
  parts << "flag_metadata=#{@flag_metadata.inspect}" if @flag_metadata
73
+ parts << "value_type=#{@value_type.inspect}" if @value_type
67
74
  "#<Quonfig::EvaluationDetails #{parts.join(' ')}>"
68
75
  end
69
76
  end
@@ -257,15 +257,27 @@ module Quonfig
257
257
  )
258
258
  end
259
259
 
260
+ # A missing, unmatched or empty key is a decryption failure too, so the
261
+ # typed getters apply the caller's default (qfg-zvyy). DecryptionError is
262
+ # a Quonfig::Error, so rescuing Quonfig::Error still catches it.
260
263
  key_cfg = @store.get(decrypt_with)
261
- raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
264
+ if key_cfg.nil?
265
+ raise Quonfig::Errors::DecryptionError.new(config_key(config),
266
+ %(decryption key config "#{decrypt_with}" not found))
267
+ end
262
268
 
263
269
  key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
264
- raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
270
+ if key_match.nil?
271
+ raise Quonfig::Errors::DecryptionError.new(config_key(config),
272
+ %(decryption key config "#{decrypt_with}" did not match))
273
+ end
265
274
 
266
275
  resolved_key = resolve_value_on_path(key_match.value, key_cfg, context, key_path)
267
276
  secret_key = vget(resolved_key, :value, 'value').to_s
268
- raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
277
+ if secret_key.empty?
278
+ raise Quonfig::Errors::DecryptionError.new(config_key(config),
279
+ %(decryption key from "#{decrypt_with}" is empty))
280
+ end
269
281
 
270
282
  ciphertext = vget(value, :value, 'value').to_s
271
283
  begin
@@ -130,8 +130,10 @@ module Quonfig
130
130
  # ended on an event over MAX_EVENT_BYTES. While set, a keepalive is not
131
131
  # a frame, so a connection that only re-delivers the oversized snapshot
132
132
  # never reports connected and the fallback poller keeps config fresh.
133
- # The first envelope clears it (and ends the WARN episode). Touched only
134
- # by the worker thread.
133
+ # The first INSTALLED envelope clears it (and ends the WARN episode):
134
+ # the on_envelope block returns :updated for an install, and an
135
+ # envelope the reject-older guard drops neither clears the gate nor
136
+ # counts as a frame (qfg-6vno.20.8). Touched only by the worker thread.
135
137
  @oversized = false
136
138
  end
137
139
 
@@ -263,16 +265,22 @@ module Quonfig
263
265
  # First-frame rule (qfg-goi1.10.43): this connection counts as
264
266
  # connected only at its first valid frame. Raised once, from the
265
267
  # parser, before the frame's envelope is installed.
268
+ #
269
+ # While the oversized gate is set the parser reports only
270
+ # envelopes as frames, and even those are deferred until the
271
+ # on_envelope block says the envelope installed (:updated): an
272
+ # envelope the reject-older guard drops is not an install, so it
273
+ # neither ends the episode nor makes the connection connected.
266
274
  frame_seen = false
267
- on_frame = lambda do
268
- # Gated, the parser reports only envelopes as frames; one ends
269
- # the oversized episode.
270
- @oversized = false
275
+ mark_frame = lambda do
271
276
  next if frame_seen
272
277
 
273
278
  frame_seen = true
274
279
  invoke_on_connected
275
280
  end
281
+ on_frame = lambda do
282
+ mark_frame.call unless @oversized
283
+ end
276
284
  begin
277
285
  ended_url = stream_once(on_frame) do |event|
278
286
  # Persist the most recent id so the next reconnect resumes
@@ -284,7 +292,11 @@ module Quonfig
284
292
  # qfg-m3lk: callback exceptions are isolated. A buggy
285
293
  # listener must not look like a transport error and trigger
286
294
  # a reconnect.
287
- invoke_on_envelope_safely(on_envelope, event)
295
+ result = invoke_on_envelope_safely(on_envelope, event)
296
+ if @oversized && result == :updated
297
+ @oversized = false
298
+ mark_frame.call
299
+ end
288
300
  end
289
301
  # A clean EOF before any frame (200 headers then close) is a
290
302
  # failed attempt, exactly like a connect error: report it so
@@ -474,7 +486,7 @@ module Quonfig
474
486
 
475
487
  # Size caps R4: one WARN per oversized episode (redacted source + cap in
476
488
  # bytes), repeats at DEBUG. The episode lasts until an envelope gets
477
- # through (@oversized cleared in run_loop's on_frame).
489
+ # through (@oversized cleared in run_loop at the first installed envelope).
478
490
  def handle_oversized_event(error)
479
491
  where = Quonfig::ConfigLoader.redact_userinfo("#{current_url}/api/v2/sse/config")
480
492
  msg = "SSE event from #{where} exceeds the maximum size of #{MAX_EVENT_BYTES} bytes; " \
@@ -497,7 +509,8 @@ module Quonfig
497
509
  # qfg-m3lk: rescue StandardError (NOT Exception) so SystemExit /
498
510
  # Interrupt / SignalException still escape — Ctrl-C inside a customer
499
511
  # callback must still kill the process. StandardError is the right
500
- # boundary for "the caller's listener has a bug".
512
+ # boundary for "the caller's listener has a bug". Returns the block's
513
+ # value (:updated for an installed envelope), or nil when it raised.
501
514
  def invoke_on_envelope_safely(on_envelope, event)
502
515
  on_envelope.call(event.envelope, event, :sse)
503
516
  rescue StandardError => e
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.11.0'
4
+ VERSION = '2.1.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: 1.11.0
4
+ version: 2.1.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-23 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