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 +4 -4
- data/CHANGELOG.md +100 -0
- data/README.md +83 -16
- data/lib/quonfig/bound_client.rb +5 -0
- data/lib/quonfig/client.rb +283 -122
- data/lib/quonfig/config_loader.rb +21 -6
- data/lib/quonfig/errors/type_mismatch_error.rb +5 -2
- data/lib/quonfig/evaluation_details.rb +11 -4
- data/lib/quonfig/resolver.rb +15 -3
- data/lib/quonfig/sse_config_client.rb +22 -9
- data/lib/quonfig/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 939842e745249a0ce6cee689801a6fe3a7cbdc56552ad88bdabaa0c36e79ef92
|
|
4
|
+
data.tar.gz: df5b171629c4b6c556f04b80a0035d5726c76929e78d11ba5382332d0e4b2293
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
398
|
-
`
|
|
399
|
-
|
|
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
|
|
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
|
|
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
|
|
430
|
-
missing key without a `default:` always raises
|
|
431
|
-
`Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to),
|
|
432
|
-
an unset or malformed ENV_VAR value, a malformed stored
|
|
433
|
-
decryption failure raises its error
|
|
434
|
-
|
|
435
|
-
|
|
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`
|
|
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
|
data/lib/quonfig/bound_client.rb
CHANGED
|
@@ -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
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
#
|
|
196
|
-
#
|
|
197
|
-
# -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
+
build_details(key, :bool, context)
|
|
267
289
|
end
|
|
268
290
|
|
|
269
291
|
def get_string_details(key, context: NO_DEFAULT_PROVIDED)
|
|
270
|
-
|
|
292
|
+
build_details(key, String, context)
|
|
271
293
|
end
|
|
272
294
|
|
|
273
295
|
def get_int_details(key, context: NO_DEFAULT_PROVIDED)
|
|
274
|
-
|
|
296
|
+
build_details(key, Integer, context)
|
|
275
297
|
end
|
|
276
298
|
|
|
277
299
|
def get_float_details(key, context: NO_DEFAULT_PROVIDED)
|
|
278
|
-
|
|
300
|
+
build_details(key, Float, context)
|
|
279
301
|
end
|
|
280
302
|
|
|
281
303
|
def get_string_list_details(key, context: NO_DEFAULT_PROVIDED)
|
|
282
|
-
|
|
304
|
+
build_details(key, :string_list, context)
|
|
283
305
|
end
|
|
284
306
|
|
|
285
307
|
def get_json_details(key, context: NO_DEFAULT_PROVIDED)
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
|
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+.
|
|
2143
|
-
#
|
|
2144
|
-
|
|
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
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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
|
-
|
|
2253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
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
|
|
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
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
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
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
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
|
-
|
|
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
|
|
498
|
-
#
|
|
499
|
-
#
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
134
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/quonfig/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-10-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|