quonfig 1.11.0 → 2.0.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: c7947aba694daddefb1357930d7d1b3e63d13c3f68ad71b1e5d33eff15584aa6
4
+ data.tar.gz: db349b68906829c24953f9504511200f7b837c8980ad9beb53a755586420f470
5
5
  SHA512:
6
- metadata.gz: 705580a318dd7d773b6b0b01236f0509b6d60f440e44e21d07228de7ce6915d71c9ffe9c9c60a6a4c50ed95ed39018727bbb2ed89389610d4df8c9058b196f48
7
- data.tar.gz: a9e69afa6be50db19b7508e9224203dda7c62ef522d1b591d2d7050ffdd69cbef217433791330fead5435c9ebe9fcbd364c245be5127ff9fbb1443e74be6a586
6
+ metadata.gz: 687f621eeff45b850bba0ab6845963abbdb8478ac18e32e1006d9f02de3b8a3ed850b75bf37ae2e907dd9512756f98fc79ae942221aaa7c17a0abd683bb9b920
7
+ data.tar.gz: 96792d273e01d2748f78435a75c3d8b4be405ff65470f465b646595539cf786ab4c204cab167350e66b42be03907739e49aaa01ad5b8a2228ebd7736fd4bb8e8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 - 2026-10-26
4
+
5
+ The typed-getter contract (qfg-zvyy), shared by every Quonfig SDK and pinned by
6
+ the integration-test-data matrix (`getter.contract.*`, tag `v2026.10.26`). A
7
+ typed read that cannot return a value of the getter's type no longer raises
8
+ past your `default:`; your default applies.
9
+
10
+ ### UPGRADE to 2.0
11
+
12
+ 1. **A failed read returns your `default:`.** In 1.x the typed getters raised
13
+ even when you passed a `default:`: `get_int('k', default: 0)` on a string
14
+ config raised `TypeMismatchError`, and any getter on an unset ENV_VAR or a
15
+ secret that cannot be decrypted raised `MissingEnvVarError` /
16
+ `DecryptionError`. In 2.0 the getter returns the default. This covers a
17
+ value of another type, a malformed stored or ENV_VAR value, an unset ENV_VAR
18
+ and a decryption failure. The untyped `get(key, default)` does the same for
19
+ the failures it can see. Code that rescued these errors around a getter
20
+ with a `default:` now gets the default instead.
21
+
22
+ 2. **No default: `on_no_default` decides.** `:raise` (the default) still raises
23
+ the error (`TypeMismatchError`, `EnvVarParseError`, `MissingEnvVarError`,
24
+ `DecryptionError`), as in 1.x. Under `on_no_default: :return_nil` the getter
25
+ now returns `nil` instead of raising.
26
+
27
+ 3. **No string coercion.** A stored string is never a bool or a number:
28
+ `get_bool` on a string `"false"` is a type mismatch, and `enabled?` on a
29
+ string `"true"` is `false` (1.x returned `true`). Store values with the
30
+ config type you read them with.
31
+
32
+ 4. **Numbers convert only when exact.** `get_float` on an int config now
33
+ returns the value (`30` -> `30.0`); 1.x raised `TypeMismatchError`.
34
+ `get_int` on a double config returns the value when it is whole and within
35
+ +/-(2^53-1) (`3.0` -> `3`); `3.9`, `1e300` and `2^53` are type mismatches,
36
+ never truncated.
37
+
38
+ 5. **Stricter types.** `get_json` reads json configs only, `get_string_list`
39
+ string_list configs only, and `get_string` string and log_level configs
40
+ only. In 1.x `get_json` returned any value and `get_string_list` accepted
41
+ any array of strings.
42
+
43
+ 6. **`enabled?` never raises.** It returns `false` for a missing key, a flag
44
+ with no matching rule, a value that is not the bool `true`, and any
45
+ resolution error (1.x raised `MissingEnvVarError` / `DecryptionError` and
46
+ errors from a malformed value).
47
+
48
+ 7. **One WARN per key.** A failed read that returns your default or `nil`, and
49
+ `enabled?` returning `false` for one of the reasons above, log one WARN per
50
+ key per client: `[quonfig] read of "<key>" returned no value: <reason>`. It
51
+ names the key and the types, never the value. A wrong decryption key in
52
+ production now degrades to your defaults with these WARNs. **If that must be
53
+ fatal**, read without a `default:` on a client with `on_no_default: :raise`
54
+ (the default), or use `get_or_raise`, which raises `MissingEnvVarError`,
55
+ `EnvVarParseError` and `DecryptionError` even when you pass a `default:`.
56
+
57
+ ### Changed
58
+
59
+ - **Major: typed getters (`get_string`, `get_int`, `get_float`, `get_bool`,
60
+ `get_string_list`, `get_duration`, `get_json`, and the same on
61
+ `BoundClient`) follow the contract above.** The 1.x identity bypass is gone:
62
+ a value that happened to be `equal?` to the default skipped the type check,
63
+ so `get_string('a_bool', default: true)` returned `true` with no error.
64
+ - **Major: `enabled?` is true only for the bool `true`.**
65
+ - **Major: `get(key, default)` returns the default on an unset ENV_VAR, a
66
+ decryption failure or a malformed value.** A malformed stored duration read
67
+ with no default now follows `on_no_default` (1.x returned `nil`).
68
+ - **`*_details`: a malformed value, stored or ENV_VAR, is `TYPE_MISMATCH`** (it
69
+ was `GENERAL` for an ENV_VAR value), as in sdk-go. `get_int_details` /
70
+ `get_float_details` follow the int <-> double rule.
71
+ - **`get_or_raise` raises `EnvVarParseError` on a malformed stored int or
72
+ double** (1.x raised a bare `ArgumentError`).
73
+ - **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.
74
+ - **`TypeMismatchError` names the config's value type** (`... expected Integer,
75
+ got a string value`) when the type is known.
76
+
77
+ ### Tests
78
+
79
+ - All workflows run integration-test-data `v2026.10.26`, which adds the
80
+ typed-getter matrix to `get.yaml`, `get_or_raise.yaml` and `enabled.yaml`.
81
+ The typed `get_or_raise` cases call the typed getter on a client with
82
+ `on_no_default: :raise` (Ruby's untyped `get_or_raise` cannot tell a
83
+ wrong-type value from a found one); that generator change is
84
+ quonfig/integration-test-data `ruby.ts`, see the PR for this release.
85
+ - `test/test_typed_getter_contract.rb` covers what the corpus cannot: the
86
+ once-per-key WARN, `enabled?` never raising, int <-> double edges (NaN,
87
+ infinity, 2^53), the Details error codes, and the untyped `get` /
88
+ `get_or_raise`.
89
+
90
+ ### Fixed
91
+
92
+ - **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`).
93
+
94
+ ### Docs
95
+
96
+ - **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.
97
+
3
98
  ## 1.11.0 - 2026-10-23
4
99
 
5
100
  - **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,18 @@ 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.
436
486
 
437
487
  ## Dynamic log levels (SemanticLogger)
438
488
 
@@ -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 ----------------------------------------------
@@ -293,9 +315,27 @@ module Quonfig
293
315
  evaluate_details(key, :duration, context)
294
316
  end
295
317
 
318
+ # True only when the flag resolves to the bool +true+. Never raises
319
+ # (qfg-zvyy): a missing key, a flag with no matching rule, a value that is
320
+ # not a bool (a stored string "true" included) or any resolution error
321
+ # returns false and logs one WARN per key.
296
322
  def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
297
- value = get(feature_name, false, jit_context)
298
- [true, 'true'].include?(value)
323
+ ensure_initialized_after_fork
324
+ begin
325
+ ctx = build_context(jit_context)
326
+ record_context_for_telemetry(ctx)
327
+ result = resolve_for_read(feature_name, ctx)
328
+ if result.nil?
329
+ warn_read_failure(feature_name, 'missing key or no matching rule; enabled? returns false')
330
+ return false
331
+ end
332
+
333
+ record_evaluation_for_telemetry(result)
334
+ typed_value(feature_name, result, :bool) == true
335
+ rescue StandardError => e
336
+ warn_read_failure(feature_name, "#{e.message}; enabled? returns false")
337
+ false
338
+ end
299
339
  end
300
340
 
301
341
  def defined?(key)
@@ -1857,19 +1897,25 @@ module Quonfig
1857
1897
  # last_successful_refresh (only an install or fetch does that).
1858
1898
  on_connected: sse_connected_callback
1859
1899
  )
1900
+ # The block returns the install result (:updated / :not_modified); the
1901
+ # SSE client clears its oversized gate only on :updated (qfg-6vno.20.8).
1902
+ # The connected edge comes from on_connected at the first frame (or,
1903
+ # while the oversized gate is set, at the first installed envelope), so
1904
+ # an envelope the reject-older guard drops never reports connected here.
1860
1905
  @sse_client.start do |envelope, _event, _source|
1861
1906
  next if @stopped
1862
1907
 
1863
1908
  begin
1864
- @config_loader.apply_envelope(envelope)
1909
+ result = @config_loader.apply_envelope(envelope)
1865
1910
  sync_evaluator_env_id!
1866
- handle_sse_state_change(:connected)
1911
+ handle_sse_state_change(:connected) if result == :updated
1867
1912
  record_refresh!
1868
1913
  rescue StandardError => e
1869
1914
  LOG.warn "[quonfig] Error applying SSE envelope: #{e.message}"
1870
1915
  next
1871
1916
  end
1872
1917
  notify_on_update_callback
1918
+ result
1873
1919
  end
1874
1920
  true
1875
1921
  rescue StandardError => e
@@ -2160,6 +2206,17 @@ module Quonfig
2160
2206
  variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
2161
2207
  flag_metadata: build_flag_metadata(nil, nil, nil, nil, nil)
2162
2208
  )
2209
+ rescue Quonfig::Errors::EnvVarParseError => e
2210
+ # 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).
2212
+ return Quonfig::EvaluationDetails.new(
2213
+ value: nil,
2214
+ reason: Quonfig::EvaluationDetails::REASON_ERROR,
2215
+ error_code: Quonfig::EvaluationDetails::ERROR_TYPE_MISMATCH,
2216
+ error_message: e.message,
2217
+ variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
2218
+ flag_metadata: build_flag_metadata(nil, nil, nil, nil, nil)
2219
+ )
2163
2220
  end
2164
2221
 
2165
2222
  if result.nil?
@@ -2175,15 +2232,12 @@ module Quonfig
2175
2232
 
2176
2233
  config_id = result.config&.dig('id') || result.config&.dig(:id)
2177
2234
  config_type = result.config&.dig('type') || result.config&.dig(:type)
2178
- raw_value = result.unwrapped_value
2179
2235
 
2180
2236
  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
2237
+ coerced = typed_value(key, result, expected_type)
2238
+ 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.
2187
2241
  return Quonfig::EvaluationDetails.new(
2188
2242
  value: nil,
2189
2243
  reason: Quonfig::EvaluationDetails::REASON_ERROR,
@@ -2248,32 +2302,122 @@ module Quonfig
2248
2302
  md
2249
2303
  end
2250
2304
 
2305
+ # A typed read under the typed-getter contract (qfg-zvyy, shared with
2306
+ # every Quonfig SDK and pinned by integration-test-data getter.contract.*).
2307
+ #
2308
+ # A missing key returns +default+ or applies +on_no_default+. A read that
2309
+ # fails (a value of another type, a malformed stored or ENV_VAR value, an
2310
+ # unset ENV_VAR, a decryption failure) returns +default+ when one is
2311
+ # given; with no default it returns nil under on_no_default: :return_nil
2312
+ # and raises the error (TypeMismatchError, EnvVarParseError,
2313
+ # MissingEnvVarError, DecryptionError) under :raise. Either way it logs
2314
+ # one WARN per key per client.
2251
2315
  def typed_get(key, expected_type, default:, context:)
2252
- jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
2253
- value = get(key, default, jit)
2316
+ ensure_initialized_after_fork
2317
+ ctx = build_context(context)
2318
+ record_context_for_telemetry(ctx)
2319
+ begin
2320
+ result = resolve_for_read(key, ctx)
2321
+ return handle_missing(key, default) if result.nil?
2322
+
2323
+ record_evaluation_for_telemetry(result)
2324
+ typed_value(key, result, expected_type)
2325
+ rescue *READ_FAILURES => e
2326
+ failed_read(key, default, e)
2327
+ end
2328
+ end
2329
+
2330
+ # The resolved Evaluation for +key+, or nil when the key is missing or no
2331
+ # rule matched. Resolution errors (MissingEnvVarError, EnvVarParseError,
2332
+ # DecryptionError) propagate.
2333
+ def resolve_for_read(key, ctx)
2334
+ @resolver.get(key, ctx)
2335
+ rescue Quonfig::Errors::MissingDefaultError
2336
+ nil
2337
+ end
2338
+
2339
+ # +result+'s value with no type check. A stored value that does not parse
2340
+ # as its declared type (an int "twelve", a duration "30s") raises
2341
+ # EnvVarParseError, as a malformed ENV_VAR value does.
2342
+ def unwrap_for_read(result)
2343
+ value =
2344
+ begin
2345
+ result.unwrapped_value
2346
+ rescue ArgumentError, TypeError, FloatDomainError
2347
+ raise malformed_stored_value_error(result)
2348
+ end
2349
+ raise malformed_stored_value_error(result) if malformed_stored_duration?(result, value)
2350
+
2351
+ value
2352
+ end
2353
+
2354
+ # +result+'s value as +expected_type+, or raise TypeMismatchError (a value
2355
+ # of another type) / EnvVarParseError (a malformed value). Strings are
2356
+ # never coerced: a stored "false" is not a bool and "3" is not an int.
2357
+ def typed_value(key, result, expected_type)
2358
+ stored = stored_value_type(result)
2359
+ accepted = ACCEPTED_TYPES.fetch(expected_type)
2360
+ raise Quonfig::Errors::TypeMismatchError.new(key, TYPE_NAMES.fetch(expected_type), nil, actual_type: stored) unless stored.nil? || accepted.include?(stored)
2254
2361
 
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)
2362
+ value = unwrap_for_read(result)
2258
2363
  return nil if value.nil?
2259
2364
 
2260
- coerce_and_check(key, value, expected_type)
2365
+ if [Integer, Float].include?(expected_type)
2366
+ typed_number(key, result, value, expected_type)
2367
+ elsif expected_type == :bool
2368
+ typed_bool(key, result)
2369
+ else
2370
+ check_class(key, value, expected_type)
2371
+ end
2372
+ end
2373
+
2374
+ # The Value's declared type ('int', 'string', ...); a resolved ENV_VAR or
2375
+ # decrypted value carries the type it was coerced to. Nil when unknown.
2376
+ def stored_value_type(result)
2377
+ type = result.type
2378
+ if type.nil? || type == 'provided'
2379
+ config = result.config || {}
2380
+ type = config['valueType'] || config[:valueType] || config['value_type'] || config[:value_type]
2381
+ end
2382
+ type&.to_s
2261
2383
  end
2262
2384
 
2263
- def duration_millis(key, result, default)
2264
- raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', result.unwrapped_value) unless duration_result?(result)
2385
+ # Double getter: any int or double (30 -> 30.0). Int getter: an int, or a
2386
+ # double that is whole and within +/-(2^53-1) (3.0 -> 3). Never truncates:
2387
+ # 3.9, 1e300 and NaN are type mismatches.
2388
+ def typed_number(key, result, value, expected_type)
2389
+ # A stored int whose raw value is a Float would unwrap truncated.
2390
+ raw = result.raw_value
2391
+ number = raw.is_a?(Numeric) ? raw : value
2392
+ mismatch = -> { Quonfig::Errors::TypeMismatchError.new(key, TYPE_NAMES.fetch(expected_type), number) }
2393
+ raise mismatch.call unless number.is_a?(Integer) || number.is_a?(Float)
2265
2394
 
2266
- value = result.unwrapped_value
2267
- millis = value.is_a?(Integer) ? value : Quonfig::Duration.parse_millis(value)
2268
- return millis unless millis.nil?
2395
+ return number.to_f if expected_type == Float
2396
+ return number if number.is_a?(Integer)
2397
+ raise mismatch.call unless number.finite? && number == number.floor && number.abs <= MAX_SAFE_INTEGER
2269
2398
 
2270
- malformed_duration(key, default, malformed_stored_duration_error(result))
2399
+ number.to_i
2271
2400
  end
2272
2401
 
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'
2402
+ # A bool Value unwraps with !!raw, so check the raw value: only a real
2403
+ # true/false is a bool.
2404
+ def typed_bool(key, result)
2405
+ raw = result.raw_value
2406
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', raw) unless [true, false].include?(raw)
2407
+
2408
+ raw
2409
+ end
2410
+
2411
+ def check_class(key, value, expected_type)
2412
+ ok =
2413
+ if expected_type == String then value.is_a?(String)
2414
+ elsif expected_type == :string_list then value.is_a?(Array) && value.all?(String)
2415
+ elsif expected_type == :duration then value.is_a?(Integer)
2416
+ else true # :json: any JSON value
2417
+ end
2418
+ raise Quonfig::Errors::TypeMismatchError.new(key, TYPE_NAMES.fetch(expected_type), value) unless ok
2419
+
2420
+ value
2277
2421
  end
2278
2422
 
2279
2423
  # A stored duration string outside the shared grammar unwraps to nil.
@@ -2281,49 +2425,27 @@ module Quonfig
2281
2425
  unwrapped.nil? && result.type == 'duration' && !result.raw_value.nil?
2282
2426
  end
2283
2427
 
2284
- def malformed_stored_duration_error(result)
2428
+ def malformed_stored_value_error(result)
2285
2429
  Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, nil, stored: true)
2286
2430
  end
2287
2431
 
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
2432
+ # The failed-read rule (qfg-zvyy): +default+ when one is given, else nil
2433
+ # under on_no_default: :return_nil, each with one WARN per key; else
2434
+ # +error+ under :raise (the raise is the signal, so it is not logged).
2435
+ def failed_read(key, default, error)
2436
+ no_default = default == NO_DEFAULT_PROVIDED
2437
+ raise error if no_default && @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
2295
2438
 
2296
- nil
2439
+ warn_read_failure(key, "#{error.message}; #{no_default ? 'returning nil' : 'returning the default'}")
2440
+ no_default ? nil : default
2297
2441
  end
2298
2442
 
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)
2308
-
2309
- arr
2310
- when :duration
2311
- return value if value.is_a?(Integer)
2443
+ # Logs once per config key per client. The reason names the key and the
2444
+ # types involved, never the value (it can be a decrypted secret).
2445
+ def warn_read_failure(key, reason)
2446
+ return unless @read_failure_mutex.synchronize { @read_failure_warned.add?(key.to_s) }
2312
2447
 
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
2448
+ LOG.warn "[quonfig] read of #{key.to_s.inspect} returned no value: #{reason}"
2327
2449
  end
2328
2450
  end
2329
2451
 
@@ -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
@@ -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.0.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.0.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-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport