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 +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +63 -13
- data/lib/quonfig/client.rb +228 -106
- data/lib/quonfig/config_loader.rb +21 -6
- data/lib/quonfig/errors/type_mismatch_error.rb +5 -2
- 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: c7947aba694daddefb1357930d7d1b3e63d13c3f68ad71b1e5d33eff15584aa6
|
|
4
|
+
data.tar.gz: db349b68906829c24953f9504511200f7b837c8980ad9beb53a755586420f470
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
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,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
|
|
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.
|
|
436
486
|
|
|
437
487
|
## Dynamic log levels (SemanticLogger)
|
|
438
488
|
|
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 ----------------------------------------------
|
|
@@ -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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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
|
-
|
|
2253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
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
|
-
|
|
2399
|
+
number.to_i
|
|
2271
2400
|
end
|
|
2272
2401
|
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
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
|
|
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
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
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:
|
|
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-
|
|
11
|
+
date: 2026-10-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|