quonfig 1.10.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 +108 -0
- data/README.md +67 -13
- data/lib/quonfig/client.rb +264 -107
- data/lib/quonfig/config_loader.rb +52 -7
- data/lib/quonfig/dev_context.rb +8 -1
- data/lib/quonfig/errors/type_mismatch_error.rb +5 -2
- data/lib/quonfig/http_connection.rb +117 -2
- data/lib/quonfig/resolver.rb +15 -3
- data/lib/quonfig/sse_config_client.rb +78 -6
- 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,113 @@
|
|
|
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
|
+
|
|
98
|
+
## 1.11.0 - 2026-10-23
|
|
99
|
+
|
|
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.
|
|
101
|
+
- The SDK stops reading at the cap. It never reads the whole event or body and then measures it.
|
|
102
|
+
- An oversized SSE event is not a frame. The connection counts as a failed attempt: the reconnect delay grows, the client reports not connected, and the fallback poller keeps config fresh over HTTP. Until an envelope arrives on the stream, a later connection that only sends keepalives does not report connected.
|
|
103
|
+
- An oversized config body is a failed leg, handled like a 5xx. Nothing is installed, its ETag is not stored, and `last_successful_refresh` does not advance.
|
|
104
|
+
- A non-2xx response from `/api/v2/configs` has its body read to at most 1 KiB, then the SDK stops reading. Before, a large error body (a proxy error page, or a 401 carrying many MiB) was read up to the 16 MiB cap, and a 401/403/404/410 with a body over 16 MiB was logged as an oversized 200 and missed by auth-class detection. The status now comes first, so that response counts as the auth status it is. The bounded config fetch runs on Net::HTTP directly so it can see the status before it reads the body; the URL, headers, proxy and timeouts are the same as before.
|
|
105
|
+
- Each oversized episode logs one WARN that names the source and the cap in bytes. Repeats log at DEBUG. The caps are constants, not options. Contract: integration-test-data `v2026.10.22.2`, supervisor contract "Rule — size caps" and Test 10 cases 1-5, in `test/test_size_caps.rb`.
|
|
106
|
+
- **Added (minor): `Client#callback_errors_total(callback: nil)` (qfg-6vno.6.1, supervisor contract Test 5).** It counts each call of your `on_update` block that raised a `StandardError` (`quonfig_sdk_callback_errors_total{callback="on_config_update"}`). Pass `callback: 'on_config_update'` or `'on_connection_state_change'` for one label; with no argument it returns the sum. sdk-ruby has no connection-state callback, so `on_connection_state_change` is always 0. An unknown name raises `ArgumentError`. A callback raise is still caught and does not count in `worker_restart_total`; `connection_state` and `last_successful_refresh` do not change. The ERROR line (`[quonfig] onConfigUpdate callback raised: ...`) now includes the first 10 lines of the stack trace. Tests: `test/test_supervisor_callback_throw.rb`, `test/test_on_update_callback_recovery.rb`.
|
|
107
|
+
- **Fix (patch): `Client.new` no longer raises when `Dir.home` fails (qfg-7xlj).** With HOME unset and no passwd entry (some containers), `Dir.home` raises `ArgumentError`, which escaped client construction while dev context was enabled (the default). Dev context is now skipped instead, as in sdk-go. Test: `test/test_dev_context.rb`.
|
|
108
|
+
- **Tests: all workflows run integration-test-data `v2026.10.22.2`.** It adds the regex v1 `enabled` cases (`test/integration/test_enabled.rb` regenerated), the size-caps rule and Test 10, and changes chaos scenario 10 exp[0] to `callback_errors_total`. The chaos probe answers `sdkMetric('quonfig_sdk_callback_errors_total', callback=...)` from the SDK counter, and the scenario 10 `CHAOS_KNOWN_SKIPS` entry is gone, so that row is evaluated instead of SKIPPED.
|
|
109
|
+
- **Tests: the chaos harness reads every asserted value from the SDK, and both chaos suites run integration-test-data `v2026.10.21.1` (qfg-6vno.6, qfg-6vno.7).** The default-suite probe now reports `Client#connection_state` as-is (it used to invent `reconnecting`), the per-layer `worker_restart_total` counters and the SSE dial count straight from the SDK, and no longer counts a callback log line as a restart. A chaos step the rig cannot apply fails the scenario instead of printing a no-op. `scenarios-http-proxy/08` and the caught-`fail` init test are collected and reported SKIPPED with their reason, and scenario 00 (init-failure modes) runs. A new CI step (`scripts/check-itd-refs.sh`) fails when the workflows pin different integration-test-data refs. No SDK change.
|
|
110
|
+
|
|
3
111
|
## 1.10.0 - 2026-10-21
|
|
4
112
|
|
|
5
113
|
- **Fix (patch): the init deadline reason follows sdk-go (qfg-9p4i.17.14, design decision 10).** A new init attempt now starts whenever time remains before `init_timeout_ms`, however little. Before, a retry was skipped with under 250ms left and the boot was decided on the previous attempt, so an attempt that fails fast followed by a delivery that blackholes until the deadline raised `reason: :unavailable` where sdk-go raises `InitializationTimeoutError` (`reason: :timeout`). An attempt still in flight at the deadline decides the reason (`:timeout` only if none of its legs answered); a deadline that lands in the backoff is decided by the last completed attempt (`:timeout` only if every leg timed out). Test: `test/test_init_failure.rb`.
|
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
|
|
|
@@ -708,6 +758,10 @@ reuse the parent's client object in a child process without it.
|
|
|
708
758
|
polling). Returns `nil` before the first install. Preserved across `stop`.
|
|
709
759
|
- `client.connection_state` — a `Symbol` describing the aggregate state:
|
|
710
760
|
`:initializing`, `:connected`, `:disconnected`, or `:falling_back`.
|
|
761
|
+
- `client.callback_errors_total(callback: 'on_config_update')` — how many
|
|
762
|
+
times your `on_update` block raised. The SDK catches the error, logs it at
|
|
763
|
+
ERROR with its stack, and keeps delivering updates; a raise is not a worker
|
|
764
|
+
restart.
|
|
711
765
|
|
|
712
766
|
> Do not wire `last_successful_refresh` or `connection_state` directly into a Kubernetes liveness probe. These signals are diagnostic, not pass/fail. A liveness probe based on SDK freshness will amplify transient network blips into restart cascades.
|
|
713
767
|
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -21,6 +21,48 @@ 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
|
+
|
|
60
|
+
# The +callback+ label values of quonfig_sdk_callback_errors_total
|
|
61
|
+
# (supervisor contract). sdk-ruby has no public connection-state callback,
|
|
62
|
+
# so +on_connection_state_change+ stays 0; it is listed so a reader of
|
|
63
|
+
# either name gets the contract's answer.
|
|
64
|
+
CALLBACK_NAMES = %w[on_config_update on_connection_state_change].freeze
|
|
65
|
+
|
|
24
66
|
# Init fetch loop cadence (qfg-9p4i.10; init-failure design B.3, C).
|
|
25
67
|
INIT_PROGRESS_EVERY_S = 5
|
|
26
68
|
INIT_WAIT_PROGRESS_EVERY_S = 30
|
|
@@ -75,9 +117,12 @@ module Quonfig
|
|
|
75
117
|
@evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
|
|
76
118
|
@resolver = Quonfig::Resolver.new(@store, @evaluator)
|
|
77
119
|
@semantic_logger_filters = {}
|
|
78
|
-
# Keys already warned about for a
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
81
126
|
@sse_client = nil
|
|
82
127
|
@poll_supervisor = nil
|
|
83
128
|
@stopped = false
|
|
@@ -97,6 +142,9 @@ module Quonfig
|
|
|
97
142
|
@sse_state = :idle
|
|
98
143
|
@sse_ever_connected = false
|
|
99
144
|
@fallback_engage_timer = nil
|
|
145
|
+
# quonfig_sdk_callback_errors_total{callback} (supervisor contract Test 5,
|
|
146
|
+
# qfg-6vno.6.1). Guarded by @state_mutex. See #callback_errors_total.
|
|
147
|
+
@callback_errors = CALLBACK_NAMES.to_h { |name| [name, 0] }
|
|
100
148
|
# Auth-failed regime (rule R2-R4, qfg-goi1.10.26). Set by the poller's
|
|
101
149
|
# fetch cycle on entry, cleared by the first accepted probe. While set:
|
|
102
150
|
# no SSE, no polling, one probe per [150s, 300s]. Read by
|
|
@@ -142,23 +190,27 @@ module Quonfig
|
|
|
142
190
|
|
|
143
191
|
# ---- Lookup --------------------------------------------------------
|
|
144
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.
|
|
145
201
|
def get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
|
|
146
202
|
ensure_initialized_after_fork
|
|
147
203
|
ctx = build_context(jit_context)
|
|
148
204
|
record_context_for_telemetry(ctx)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
rescue Quonfig::Errors::MissingDefaultError
|
|
153
|
-
# The Resolver raises (matching Quonfig.get_or_raise semantics).
|
|
154
|
-
# The Client's get applies the caller-provided default *or* the
|
|
155
|
-
# configured on_no_default policy via handle_missing.
|
|
156
|
-
nil
|
|
157
|
-
end
|
|
158
|
-
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?
|
|
159
208
|
|
|
160
|
-
|
|
161
|
-
|
|
209
|
+
record_evaluation_for_telemetry(result)
|
|
210
|
+
unwrap_for_read(result)
|
|
211
|
+
rescue *READ_FAILURES => e
|
|
212
|
+
failed_read(key, default, e)
|
|
213
|
+
end
|
|
162
214
|
end
|
|
163
215
|
|
|
164
216
|
def get_string(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
@@ -181,31 +233,13 @@ module Quonfig
|
|
|
181
233
|
typed_get(key, :string_list, default: default, context: context)
|
|
182
234
|
end
|
|
183
235
|
|
|
184
|
-
# Integer milliseconds for a duration config (qfg-2agi.10).
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
# -
|
|
189
|
-
# half up on both the stored and the ENV_VAR path.
|
|
190
|
-
# - A malformed value (stored or ENV_VAR) returns +default+ and logs one
|
|
191
|
-
# warning per key. With no default it returns nil under
|
|
192
|
-
# 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).
|
|
193
241
|
def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
194
|
-
|
|
195
|
-
ctx = build_context(context)
|
|
196
|
-
record_context_for_telemetry(ctx)
|
|
197
|
-
result =
|
|
198
|
-
begin
|
|
199
|
-
@resolver.get(key, ctx)
|
|
200
|
-
rescue Quonfig::Errors::MissingDefaultError
|
|
201
|
-
nil
|
|
202
|
-
rescue Quonfig::Errors::EnvVarParseError => e
|
|
203
|
-
return malformed_duration(key, default, e)
|
|
204
|
-
end
|
|
205
|
-
return handle_missing(key, default) if result.nil?
|
|
206
|
-
|
|
207
|
-
record_evaluation_for_telemetry(result)
|
|
208
|
-
duration_millis(key, result, default)
|
|
242
|
+
typed_get(key, :duration, default: default, context: context)
|
|
209
243
|
end
|
|
210
244
|
|
|
211
245
|
def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
|
|
@@ -239,10 +273,7 @@ module Quonfig
|
|
|
239
273
|
end
|
|
240
274
|
|
|
241
275
|
record_evaluation_for_telemetry(result)
|
|
242
|
-
|
|
243
|
-
raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, value)
|
|
244
|
-
|
|
245
|
-
value
|
|
276
|
+
unwrap_for_read(result)
|
|
246
277
|
end
|
|
247
278
|
|
|
248
279
|
# ---- Details getters ----------------------------------------------
|
|
@@ -284,9 +315,27 @@ module Quonfig
|
|
|
284
315
|
evaluate_details(key, :duration, context)
|
|
285
316
|
end
|
|
286
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.
|
|
287
322
|
def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
290
339
|
end
|
|
291
340
|
|
|
292
341
|
def defined?(key)
|
|
@@ -561,6 +610,29 @@ module Quonfig
|
|
|
561
610
|
end
|
|
562
611
|
end
|
|
563
612
|
|
|
613
|
+
# quonfig_sdk_callback_errors_total counter (supervisor contract Test 5,
|
|
614
|
+
# qfg-6vno.6.1). Counts each call of your +on_update+ block that raised a
|
|
615
|
+
# StandardError the SDK caught and logged at ERROR. Such a raise is not a
|
|
616
|
+
# worker restart: #worker_restart_total does not change, the stream keeps
|
|
617
|
+
# running, and the next update still reaches your block.
|
|
618
|
+
#
|
|
619
|
+
# Pass +callback:+ ('on_config_update' or 'on_connection_state_change',
|
|
620
|
+
# String or Symbol) for one label; the default returns the sum. sdk-ruby
|
|
621
|
+
# has no connection-state callback, so 'on_connection_state_change' is
|
|
622
|
+
# always 0. An unknown name raises ArgumentError. Starts at 0 when the
|
|
623
|
+
# client is built.
|
|
624
|
+
def callback_errors_total(callback: nil)
|
|
625
|
+
@state_mutex.synchronize do
|
|
626
|
+
next @callback_errors.values.sum if callback.nil?
|
|
627
|
+
|
|
628
|
+
name = callback.to_s
|
|
629
|
+
raise ArgumentError, "unknown callback #{callback.inspect}; expected one of #{CALLBACK_NAMES.join(', ')}" unless
|
|
630
|
+
@callback_errors.key?(name)
|
|
631
|
+
|
|
632
|
+
@callback_errors[name]
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
564
636
|
# Wall-clock time of the last installed envelope (any source: datadir,
|
|
565
637
|
# initial HTTP fetch, SSE, or polling fallback). +nil+ before the first
|
|
566
638
|
# install. Preserved after +stop+.
|
|
@@ -1825,19 +1897,25 @@ module Quonfig
|
|
|
1825
1897
|
# last_successful_refresh (only an install or fetch does that).
|
|
1826
1898
|
on_connected: sse_connected_callback
|
|
1827
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.
|
|
1828
1905
|
@sse_client.start do |envelope, _event, _source|
|
|
1829
1906
|
next if @stopped
|
|
1830
1907
|
|
|
1831
1908
|
begin
|
|
1832
|
-
@config_loader.apply_envelope(envelope)
|
|
1909
|
+
result = @config_loader.apply_envelope(envelope)
|
|
1833
1910
|
sync_evaluator_env_id!
|
|
1834
|
-
handle_sse_state_change(:connected)
|
|
1911
|
+
handle_sse_state_change(:connected) if result == :updated
|
|
1835
1912
|
record_refresh!
|
|
1836
1913
|
rescue StandardError => e
|
|
1837
1914
|
LOG.warn "[quonfig] Error applying SSE envelope: #{e.message}"
|
|
1838
1915
|
next
|
|
1839
1916
|
end
|
|
1840
1917
|
notify_on_update_callback
|
|
1918
|
+
result
|
|
1841
1919
|
end
|
|
1842
1920
|
true
|
|
1843
1921
|
rescue StandardError => e
|
|
@@ -2007,7 +2085,10 @@ module Quonfig
|
|
|
2007
2085
|
begin
|
|
2008
2086
|
cb.call
|
|
2009
2087
|
rescue StandardError => e
|
|
2010
|
-
|
|
2088
|
+
# Counted, not a worker restart (supervisor contract Test 5).
|
|
2089
|
+
@state_mutex.synchronize { @callback_errors['on_config_update'] += 1 }
|
|
2090
|
+
bt = (e.backtrace || []).first(10).join("\n ")
|
|
2091
|
+
LOG.error "[quonfig] onConfigUpdate callback raised: #{e.class}: #{e.message}\n #{bt}"
|
|
2011
2092
|
end
|
|
2012
2093
|
end
|
|
2013
2094
|
|
|
@@ -2125,6 +2206,17 @@ module Quonfig
|
|
|
2125
2206
|
variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
|
|
2126
2207
|
flag_metadata: build_flag_metadata(nil, nil, nil, nil, nil)
|
|
2127
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
|
+
)
|
|
2128
2220
|
end
|
|
2129
2221
|
|
|
2130
2222
|
if result.nil?
|
|
@@ -2140,15 +2232,12 @@ module Quonfig
|
|
|
2140
2232
|
|
|
2141
2233
|
config_id = result.config&.dig('id') || result.config&.dig(:id)
|
|
2142
2234
|
config_type = result.config&.dig('type') || result.config&.dig(:type)
|
|
2143
|
-
raw_value = result.unwrapped_value
|
|
2144
2235
|
|
|
2145
2236
|
begin
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
|
|
2151
|
-
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.
|
|
2152
2241
|
return Quonfig::EvaluationDetails.new(
|
|
2153
2242
|
value: nil,
|
|
2154
2243
|
reason: Quonfig::EvaluationDetails::REASON_ERROR,
|
|
@@ -2213,32 +2302,122 @@ module Quonfig
|
|
|
2213
2302
|
md
|
|
2214
2303
|
end
|
|
2215
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.
|
|
2216
2315
|
def typed_get(key, expected_type, default:, context:)
|
|
2217
|
-
|
|
2218
|
-
|
|
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)
|
|
2219
2361
|
|
|
2220
|
-
|
|
2221
|
-
# on_no_default=:return_nil) — skip type coercion.
|
|
2222
|
-
return value if default != NO_DEFAULT_PROVIDED && value.equal?(default)
|
|
2362
|
+
value = unwrap_for_read(result)
|
|
2223
2363
|
return nil if value.nil?
|
|
2224
2364
|
|
|
2225
|
-
|
|
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
|
|
2226
2372
|
end
|
|
2227
2373
|
|
|
2228
|
-
|
|
2229
|
-
|
|
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
|
|
2383
|
+
end
|
|
2230
2384
|
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
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)
|
|
2234
2394
|
|
|
2235
|
-
|
|
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
|
|
2398
|
+
|
|
2399
|
+
number.to_i
|
|
2236
2400
|
end
|
|
2237
2401
|
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
|
2242
2421
|
end
|
|
2243
2422
|
|
|
2244
2423
|
# A stored duration string outside the shared grammar unwraps to nil.
|
|
@@ -2246,49 +2425,27 @@ module Quonfig
|
|
|
2246
2425
|
unwrapped.nil? && result.type == 'duration' && !result.raw_value.nil?
|
|
2247
2426
|
end
|
|
2248
2427
|
|
|
2249
|
-
def
|
|
2428
|
+
def malformed_stored_value_error(result)
|
|
2250
2429
|
Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, nil, stored: true)
|
|
2251
2430
|
end
|
|
2252
2431
|
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
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
|
|
2260
2438
|
|
|
2261
|
-
nil
|
|
2439
|
+
warn_read_failure(key, "#{error.message}; #{no_default ? 'returning nil' : 'returning the default'}")
|
|
2440
|
+
no_default ? nil : default
|
|
2262
2441
|
end
|
|
2263
2442
|
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
value
|
|
2270
|
-
when :string_list
|
|
2271
|
-
arr = value.is_a?(Array) ? value : nil
|
|
2272
|
-
raise Quonfig::Errors::TypeMismatchError.new(key, 'Array<String>', value) unless arr&.all?(String)
|
|
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) }
|
|
2273
2447
|
|
|
2274
|
-
|
|
2275
|
-
when :duration
|
|
2276
|
-
return value if value.is_a?(Integer)
|
|
2277
|
-
|
|
2278
|
-
millis = Quonfig::Duration.parse_millis(value)
|
|
2279
|
-
raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value) if millis.nil?
|
|
2280
|
-
|
|
2281
|
-
millis
|
|
2282
|
-
when :json
|
|
2283
|
-
# JSON values are returned as-is (Hash, Array, or scalar from the wire).
|
|
2284
|
-
value
|
|
2285
|
-
when Class
|
|
2286
|
-
raise Quonfig::Errors::TypeMismatchError.new(key, expected_type.to_s, value) unless value.is_a?(expected_type)
|
|
2287
|
-
|
|
2288
|
-
value
|
|
2289
|
-
else
|
|
2290
|
-
value
|
|
2291
|
-
end
|
|
2448
|
+
LOG.warn "[quonfig] read of #{key.to_s.inspect} returned no value: #{reason}"
|
|
2292
2449
|
end
|
|
2293
2450
|
end
|
|
2294
2451
|
|
|
@@ -19,6 +19,13 @@ module Quonfig
|
|
|
19
19
|
|
|
20
20
|
CONFIGS_PATH = '/api/v2/configs'
|
|
21
21
|
|
|
22
|
+
# Size-caps rule (qfg-6vno.8, supervisor-test-contract.md "Rule — size
|
|
23
|
+
# caps"): a 200 body from /api/v2/configs over 16 MiB is a failed leg,
|
|
24
|
+
# exactly like a 5xx. 4x the SSE event cap, so a workspace that outgrows
|
|
25
|
+
# the stream still stays fresh through the fallback poller. A constant,
|
|
26
|
+
# not an option.
|
|
27
|
+
MAX_CONFIG_BODY_BYTES = 16 * 1024 * 1024
|
|
28
|
+
|
|
22
29
|
# Rule R1 (qfg-goi1.10.26): the auth class. A fetch cycle with no 200/304
|
|
23
30
|
# on any leg and one of these on the primary leg is rejected; the client's
|
|
24
31
|
# poller then enters auth-failed (Client#start_polling).
|
|
@@ -186,6 +193,9 @@ module Quonfig
|
|
|
186
193
|
# request, write-back after — the network wait happens with no lock held).
|
|
187
194
|
@etags = {}
|
|
188
195
|
@etag_mutex = Mutex.new
|
|
196
|
+
# Leg indexes in an oversized-body episode (WARN once, then DEBUG until
|
|
197
|
+
# that leg installs an envelope). Guarded by @etag_mutex.
|
|
198
|
+
@oversized_legs = Set.new
|
|
189
199
|
@version = nil
|
|
190
200
|
@environment_id = nil
|
|
191
201
|
@logger = logger || LOG
|
|
@@ -290,7 +300,8 @@ module Quonfig
|
|
|
290
300
|
# when a new event arrives. SSE is a single-leg live stream, so it carries no
|
|
291
301
|
# config_api_urls index and does not change #resolved_from — but it IS
|
|
292
302
|
# guarded by the same reject-older rule (a late SSE snapshot must not regress
|
|
293
|
-
# an established client).
|
|
303
|
+
# an established client). Returns :updated when the envelope installed and
|
|
304
|
+
# :not_modified when the guard dropped it.
|
|
294
305
|
def apply_envelope(envelope)
|
|
295
306
|
install_envelope(envelope, source: :sse, source_index: nil)
|
|
296
307
|
end
|
|
@@ -468,7 +479,8 @@ module Quonfig
|
|
|
468
479
|
# aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
|
|
469
480
|
# path the caller passes config_fetch_hedge_abort_ms; on the sequential /
|
|
470
481
|
# single-URL path it passes config_fetch_timeout_ms.
|
|
471
|
-
conn = Quonfig::HttpConnection.new(source, @options.sdk_key, timeout_ms: timeout_ms
|
|
482
|
+
conn = Quonfig::HttpConnection.new(source, @options.sdk_key, timeout_ms: timeout_ms,
|
|
483
|
+
max_body_bytes: MAX_CONFIG_BODY_BYTES)
|
|
472
484
|
headers = {}
|
|
473
485
|
# Per-leg ETag: snapshot this leg's slot before the request (no lock held
|
|
474
486
|
# during the network wait).
|
|
@@ -483,11 +495,13 @@ module Quonfig
|
|
|
483
495
|
envelope = parse_envelope(response.body)
|
|
484
496
|
result = install_envelope(envelope, source: source, source_index: index)
|
|
485
497
|
# Write this leg's ETag back AFTER the response (per-leg, race-free).
|
|
486
|
-
# Exception (qfg-9dxb.9
|
|
487
|
-
#
|
|
488
|
-
#
|
|
489
|
-
|
|
490
|
-
|
|
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)
|
|
504
|
+
end_oversized_episode(index) if result == :updated
|
|
491
505
|
# install_envelope returns :not_modified when the reject-older guard drops
|
|
492
506
|
# an equal/older payload — surface that so the caller doesn't double-count.
|
|
493
507
|
result == :not_modified ? :not_modified : :updated
|
|
@@ -505,6 +519,12 @@ module Quonfig
|
|
|
505
519
|
auth: AUTH_STATUSES.include?(response.status.to_i))
|
|
506
520
|
:failed
|
|
507
521
|
end
|
|
522
|
+
rescue Quonfig::HttpConnection::BodyTooLargeError => e
|
|
523
|
+
# Size caps (qfg-6vno.20.5): a failed leg, like a 5xx. Reading stopped at
|
|
524
|
+
# the cap; nothing is installed and this leg's ETag is not stored.
|
|
525
|
+
note.call("200 body over the #{e.max_bytes}-byte cap")
|
|
526
|
+
log_oversized_body(index, where, e.max_bytes)
|
|
527
|
+
:failed
|
|
508
528
|
rescue NonEnvelopeError => e
|
|
509
529
|
note.call("non-envelope 200 (#{e.message})")
|
|
510
530
|
# qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
|
|
@@ -545,6 +565,18 @@ module Quonfig
|
|
|
545
565
|
cause.is_a?(Timeout::Error) || cause.is_a?(Errno::ETIMEDOUT)
|
|
546
566
|
end
|
|
547
567
|
|
|
568
|
+
# One WARN per oversized episode per leg; repeats at DEBUG (size-caps R4).
|
|
569
|
+
def log_oversized_body(index, where, max_bytes)
|
|
570
|
+
first = @etag_mutex.synchronize { @oversized_legs.add?(index || 0) }
|
|
571
|
+
msg = "Config fetch from #{where} returned a body over the maximum size of #{max_bytes} bytes; " \
|
|
572
|
+
'treating it as a failed leg'
|
|
573
|
+
first ? @logger.warn(msg) : @logger.debug(msg)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def end_oversized_episode(index)
|
|
577
|
+
@etag_mutex.synchronize { @oversized_legs.delete(index || 0) }
|
|
578
|
+
end
|
|
579
|
+
|
|
548
580
|
def etag_for(index)
|
|
549
581
|
@etag_mutex.synchronize { @etags[index || 0] }
|
|
550
582
|
end
|
|
@@ -676,9 +708,22 @@ module Quonfig
|
|
|
676
708
|
# SSE / datadir installs pass source_index nil and are ignored by the
|
|
677
709
|
# aggregator, so they are never counted.
|
|
678
710
|
@failover_aggregator&.record_resolved_from(source_index)
|
|
711
|
+
:updated
|
|
679
712
|
end
|
|
680
713
|
end
|
|
681
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
|
+
|
|
682
727
|
# The install decision for a network payload (see #install_envelope).
|
|
683
728
|
def should_install?(incoming_gen)
|
|
684
729
|
return true if @held_generation.nil?
|
data/lib/quonfig/dev_context.rb
CHANGED
|
@@ -17,7 +17,14 @@ module Quonfig
|
|
|
17
17
|
TOKENS_BASENAME = File.join('.quonfig', 'tokens.json')
|
|
18
18
|
|
|
19
19
|
def self.load_quonfig_user_context
|
|
20
|
-
|
|
20
|
+
home = begin
|
|
21
|
+
Dir.home
|
|
22
|
+
rescue ArgumentError
|
|
23
|
+
# HOME unset and no passwd entry (some containers): no dev context,
|
|
24
|
+
# as in sdk-go (qfg-7xlj).
|
|
25
|
+
return nil
|
|
26
|
+
end
|
|
27
|
+
path = File.join(home, TOKENS_BASENAME)
|
|
21
28
|
return nil unless File.exist?(path)
|
|
22
29
|
|
|
23
30
|
raw = begin
|
|
@@ -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
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require 'base64'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'zlib'
|
|
5
8
|
require 'timeout'
|
|
6
9
|
|
|
7
10
|
module Quonfig
|
|
@@ -26,6 +29,31 @@ module Quonfig
|
|
|
26
29
|
# structurally cannot catch.
|
|
27
30
|
WALL_CLOCK_HEADROOM_S = 0.25
|
|
28
31
|
|
|
32
|
+
# Raised by #get when +max_body_bytes+ is set and the response body grows
|
|
33
|
+
# past it (size-caps rule, qfg-6vno.20.5). Reading stops at the cap: the
|
|
34
|
+
# body is streamed through a byte counter, never read whole and measured
|
|
35
|
+
# afterwards. Not a Faraday error, so callers handle it explicitly.
|
|
36
|
+
class BodyTooLargeError < StandardError
|
|
37
|
+
attr_reader :max_bytes
|
|
38
|
+
|
|
39
|
+
def initialize(max_bytes)
|
|
40
|
+
@max_bytes = max_bytes
|
|
41
|
+
super("response body exceeds the maximum size of #{max_bytes} bytes")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Size caps (qfg-6vno.20.5): a non-2xx body is read to at most this many
|
|
46
|
+
# bytes, then reading stops and the connection is dropped. Callers never
|
|
47
|
+
# use a non-2xx body (it is never logged), so a large one (a proxy error
|
|
48
|
+
# page, a 401 with 17 MiB of junk) must not be read toward the 16 MiB cap.
|
|
49
|
+
NON_2XX_BODY_READ_LIMIT = 1024
|
|
50
|
+
|
|
51
|
+
# Raised from inside the body read to stop it early; never escapes.
|
|
52
|
+
class StopReading < StandardError; end
|
|
53
|
+
|
|
54
|
+
# Minimal response for the bounded path: the fields callers use.
|
|
55
|
+
BoundedResponse = Struct.new(:status, :headers, :body)
|
|
56
|
+
|
|
29
57
|
# +timeout_ms+ (qfg-7h5d.1.9): per-request bound applied to BOTH the connect
|
|
30
58
|
# (open) and read phases of every request made through this connection, AND
|
|
31
59
|
# (qfg-41nh.6) enforced as a wall-clock ceiling over the whole request. nil
|
|
@@ -39,17 +67,25 @@ module Quonfig
|
|
|
39
67
|
# connect (open) phase — TCP connect plus TLS handshake. nil uses
|
|
40
68
|
# +timeout_ms+ for it, as before. The telemetry reporter passes 5s here and
|
|
41
69
|
# 15s as +timeout_ms+.
|
|
42
|
-
|
|
70
|
+
#
|
|
71
|
+
# +max_body_bytes+ (qfg-6vno.20.5): when set, #get streams the body through
|
|
72
|
+
# a counter and raises BodyTooLargeError as soon as it passes this many
|
|
73
|
+
# bytes, so memory stays bounded near the cap. nil reads the body whole,
|
|
74
|
+
# as before. The config fetch passes ConfigLoader::MAX_CONFIG_BODY_BYTES.
|
|
75
|
+
def initialize(uri, sdk_key, timeout_ms: nil, open_timeout_ms: nil, max_body_bytes: nil)
|
|
43
76
|
@uri = uri
|
|
44
77
|
@sdk_key = sdk_key
|
|
45
78
|
@timeout_ms = timeout_ms
|
|
46
79
|
@open_timeout_ms = open_timeout_ms
|
|
80
|
+
@max_body_bytes = max_body_bytes
|
|
47
81
|
end
|
|
48
82
|
|
|
49
83
|
attr_reader :uri
|
|
50
84
|
|
|
51
85
|
def get(path, headers = {})
|
|
52
|
-
with_wall_clock_deadline { connection(headers).get(path) }
|
|
86
|
+
return with_wall_clock_deadline { connection(headers).get(path) } unless @max_body_bytes
|
|
87
|
+
|
|
88
|
+
get_bounded(path, headers)
|
|
53
89
|
end
|
|
54
90
|
|
|
55
91
|
# A String +body+ is sent verbatim (the telemetry reporter resends a
|
|
@@ -78,6 +114,85 @@ module Quonfig
|
|
|
78
114
|
|
|
79
115
|
private
|
|
80
116
|
|
|
117
|
+
# The bounded GET runs on Net::HTTP directly, because it needs the status
|
|
118
|
+
# BEFORE the body is read: a 2xx body is streamed through a counter that
|
|
119
|
+
# raises BodyTooLargeError past +max_body_bytes+; a non-2xx body is read to
|
|
120
|
+
# at most NON_2XX_BODY_READ_LIMIT bytes. Faraday 1.x's +on_data+ gives no
|
|
121
|
+
# status to the callback, so it cannot tell the two apart. URL, headers and
|
|
122
|
+
# proxy still come from the Faraday connection, so the request is the same.
|
|
123
|
+
# Transport errors are re-raised as the Faraday errors callers rescue.
|
|
124
|
+
def get_bounded(path, headers)
|
|
125
|
+
with_wall_clock_deadline { net_http_get_bounded(path, headers) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def net_http_get_bounded(path, headers)
|
|
129
|
+
conn = connection(headers)
|
|
130
|
+
url = conn.build_exclusive_url(path)
|
|
131
|
+
req = Net::HTTP::Get.new(url.request_uri)
|
|
132
|
+
conn.headers.each { |k, v| req[k] = v }
|
|
133
|
+
result = nil
|
|
134
|
+
begin
|
|
135
|
+
bounded_http(conn, url).start do |http|
|
|
136
|
+
http.request(req) do |res|
|
|
137
|
+
result = BoundedResponse.new(res.code.to_i, response_headers(res), +''.b)
|
|
138
|
+
read_bounded_body(res, result)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
rescue StopReading
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
result.body.force_encoding(Encoding::UTF_8)
|
|
145
|
+
result
|
|
146
|
+
rescue BodyTooLargeError, Faraday::Error
|
|
147
|
+
raise
|
|
148
|
+
rescue Timeout::Error, Errno::ETIMEDOUT => e
|
|
149
|
+
raise Faraday::TimeoutError, e
|
|
150
|
+
rescue IOError, SystemCallError, SocketError, Net::ProtocolError, Net::HTTPBadResponse,
|
|
151
|
+
Net::HTTPHeaderSyntaxError, Zlib::Error, OpenSSL::SSL::SSLError => e
|
|
152
|
+
raise Faraday::ConnectionFailed, e
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def bounded_http(conn, url)
|
|
156
|
+
proxy = conn.proxy
|
|
157
|
+
http = if proxy
|
|
158
|
+
Net::HTTP.new(url.hostname, url.port, proxy.uri.hostname, proxy.uri.port, proxy.user, proxy.password)
|
|
159
|
+
else
|
|
160
|
+
Net::HTTP.new(url.hostname, url.port, nil)
|
|
161
|
+
end
|
|
162
|
+
http.use_ssl = (url.scheme == 'https')
|
|
163
|
+
if @timeout_ms
|
|
164
|
+
http.open_timeout = @timeout_ms / 1000.0
|
|
165
|
+
http.read_timeout = @timeout_ms / 1000.0
|
|
166
|
+
end
|
|
167
|
+
http.open_timeout = @open_timeout_ms / 1000.0 if @open_timeout_ms
|
|
168
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
169
|
+
http
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def response_headers(res)
|
|
173
|
+
headers = Faraday::Utils::Headers.new
|
|
174
|
+
res.each_header { |k, v| headers[k] = v }
|
|
175
|
+
headers
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Reading stops at the limit: the chunk that would cross it is not kept.
|
|
179
|
+
def read_bounded_body(res, result)
|
|
180
|
+
buf = result.body
|
|
181
|
+
if (200..299).cover?(result.status)
|
|
182
|
+
max = @max_body_bytes
|
|
183
|
+
res.read_body do |chunk|
|
|
184
|
+
raise BodyTooLargeError, max if buf.bytesize + chunk.bytesize > max
|
|
185
|
+
|
|
186
|
+
buf << chunk.b
|
|
187
|
+
end
|
|
188
|
+
else
|
|
189
|
+
res.read_body do |chunk|
|
|
190
|
+
buf << chunk.b.byteslice(0, NON_2XX_BODY_READ_LIMIT - buf.bytesize)
|
|
191
|
+
raise StopReading if buf.bytesize >= NON_2XX_BODY_READ_LIMIT
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
81
196
|
# Enforce +timeout_ms+ (+ headroom) as a wall-clock deadline over the whole
|
|
82
197
|
# request. Timeout.timeout is the codebase's accepted backstop pattern (the
|
|
83
198
|
# init path wraps fetch! the same way in Client#perform_initial_fetch); a
|
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
|
|
@@ -76,6 +76,14 @@ module Quonfig
|
|
|
76
76
|
# letting it win the race.
|
|
77
77
|
READ_TIMEOUT_HEADROOM = 30
|
|
78
78
|
|
|
79
|
+
# Size-caps rule (qfg-6vno.8, supervisor-test-contract.md "Rule — size
|
|
80
|
+
# caps"; sdk-go's sseMaxLine): one SSE event, any single line and the sum
|
|
81
|
+
# of a multi-line data:, is capped at 4 MiB. The parser stops reading at
|
|
82
|
+
# the cap, so memory per stream stays near the cap plus one read chunk
|
|
83
|
+
# even for an event with no terminator. Over the cap is not a frame: the
|
|
84
|
+
# connection is a failed attempt. A constant, not an option.
|
|
85
|
+
MAX_EVENT_BYTES = 4 * 1024 * 1024
|
|
86
|
+
|
|
79
87
|
# +on_error+: optional callable invoked on every SSE error edge. Parent
|
|
80
88
|
# Quonfig::Client wires this to drive @sse_state -> :error so that
|
|
81
89
|
# +connection_state+ reflects the disconnect (qfg-47c2.27). A connection
|
|
@@ -118,6 +126,15 @@ module Quonfig
|
|
|
118
126
|
# that reintroduces stream-URL rotation is observable to the failover
|
|
119
127
|
# chaos probe (f05) and to operators.
|
|
120
128
|
@failed_over_to_secondary = false
|
|
129
|
+
# Oversized gate (size caps, sdk-go's +oversized+): set when a stream
|
|
130
|
+
# ended on an event over MAX_EVENT_BYTES. While set, a keepalive is not
|
|
131
|
+
# a frame, so a connection that only re-delivers the oversized snapshot
|
|
132
|
+
# never reports connected and the fallback poller keeps config fresh.
|
|
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.
|
|
137
|
+
@oversized = false
|
|
121
138
|
end
|
|
122
139
|
|
|
123
140
|
# True if the live SSE stream has ever connected to a non-primary leg. Read
|
|
@@ -248,13 +265,22 @@ module Quonfig
|
|
|
248
265
|
# First-frame rule (qfg-goi1.10.43): this connection counts as
|
|
249
266
|
# connected only at its first valid frame. Raised once, from the
|
|
250
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.
|
|
251
274
|
frame_seen = false
|
|
252
|
-
|
|
275
|
+
mark_frame = lambda do
|
|
253
276
|
next if frame_seen
|
|
254
277
|
|
|
255
278
|
frame_seen = true
|
|
256
279
|
invoke_on_connected
|
|
257
280
|
end
|
|
281
|
+
on_frame = lambda do
|
|
282
|
+
mark_frame.call unless @oversized
|
|
283
|
+
end
|
|
258
284
|
begin
|
|
259
285
|
ended_url = stream_once(on_frame) do |event|
|
|
260
286
|
# Persist the most recent id so the next reconnect resumes
|
|
@@ -266,7 +292,11 @@ module Quonfig
|
|
|
266
292
|
# qfg-m3lk: callback exceptions are isolated. A buggy
|
|
267
293
|
# listener must not look like a transport error and trigger
|
|
268
294
|
# a reconnect.
|
|
269
|
-
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
|
|
270
300
|
end
|
|
271
301
|
# A clean EOF before any frame (200 headers then close) is a
|
|
272
302
|
# failed attempt, exactly like a connect error: report it so
|
|
@@ -275,6 +305,11 @@ module Quonfig
|
|
|
275
305
|
# survives a clean-EOF reconnect only until the next connection
|
|
276
306
|
# ends frameless.
|
|
277
307
|
handle_frameless_end(ended_url) unless frame_seen || @stopped.value
|
|
308
|
+
rescue SSEEventTooLargeError => e
|
|
309
|
+
# A failed attempt even if an earlier frame arrived on this
|
|
310
|
+
# connection (sdk-go returns frameSeen=false here).
|
|
311
|
+
frame_seen = false
|
|
312
|
+
handle_oversized_event(e) unless @stopped.value
|
|
278
313
|
rescue StandardError => e
|
|
279
314
|
handle_error(e) unless @stopped.value
|
|
280
315
|
end
|
|
@@ -353,7 +388,7 @@ module Quonfig
|
|
|
353
388
|
http.request(req) do |resp|
|
|
354
389
|
check_response_status!(resp.code.to_i, url)
|
|
355
390
|
|
|
356
|
-
parser = EventParser.new
|
|
391
|
+
parser = EventParser.new(keepalive_is_frame: !@oversized)
|
|
357
392
|
# qfg-tj18: run_loop wraps the body in +:on_blocking+ which
|
|
358
393
|
# *would* still deliver during read_body (read_body is a
|
|
359
394
|
# blocking IO call), but be explicit: we want the watchdog raise
|
|
@@ -449,6 +484,18 @@ module Quonfig
|
|
|
449
484
|
end
|
|
450
485
|
end
|
|
451
486
|
|
|
487
|
+
# Size caps R4: one WARN per oversized episode (redacted source + cap in
|
|
488
|
+
# bytes), repeats at DEBUG. The episode lasts until an envelope gets
|
|
489
|
+
# through (@oversized cleared in run_loop at the first installed envelope).
|
|
490
|
+
def handle_oversized_event(error)
|
|
491
|
+
where = Quonfig::ConfigLoader.redact_userinfo("#{current_url}/api/v2/sse/config")
|
|
492
|
+
msg = "SSE event from #{where} exceeds the maximum size of #{MAX_EVENT_BYTES} bytes; " \
|
|
493
|
+
'dropping it and backing off (fallback polling will take over)'
|
|
494
|
+
@oversized ? @logger.debug(msg) : @logger.warn(msg)
|
|
495
|
+
@oversized = true
|
|
496
|
+
invoke_on_error(error)
|
|
497
|
+
end
|
|
498
|
+
|
|
452
499
|
def handle_error(error)
|
|
453
500
|
if error.is_a?(SSEHTTPAuthError)
|
|
454
501
|
# Already logged once by check_response_status! (R5).
|
|
@@ -462,7 +509,8 @@ module Quonfig
|
|
|
462
509
|
# qfg-m3lk: rescue StandardError (NOT Exception) so SystemExit /
|
|
463
510
|
# Interrupt / SignalException still escape — Ctrl-C inside a customer
|
|
464
511
|
# callback must still kill the process. StandardError is the right
|
|
465
|
-
# 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.
|
|
466
514
|
def invoke_on_envelope_safely(on_envelope, event)
|
|
467
515
|
on_envelope.call(event.envelope, event, :sse)
|
|
468
516
|
rescue StandardError => e
|
|
@@ -546,6 +594,14 @@ module Quonfig
|
|
|
546
594
|
# from any other transport error for backoff/restart purposes.
|
|
547
595
|
class SSEReadDeadlineExceeded < StandardError; end
|
|
548
596
|
|
|
597
|
+
# Raised by the parser when one event passes MAX_EVENT_BYTES (size caps).
|
|
598
|
+
# Ends the connection as a failed attempt; passed to on_error.
|
|
599
|
+
class SSEEventTooLargeError < StandardError
|
|
600
|
+
def initialize(msg = "SSE event exceeds the maximum size of #{MAX_EVENT_BYTES} bytes")
|
|
601
|
+
super
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
549
605
|
# Private: raised from inside +read_body+ when +close+ has been called,
|
|
550
606
|
# so the worker leaves the stream without Net::HTTP draining the body
|
|
551
607
|
# (qfg-yrly). Rescued in +stream_once+; never reaches callers.
|
|
@@ -636,8 +692,11 @@ module Quonfig
|
|
|
636
692
|
# Malformed +data:+ JSON is logged and skipped; one bad event does not
|
|
637
693
|
# tear down the stream.
|
|
638
694
|
class EventParser
|
|
639
|
-
|
|
695
|
+
# +keepalive_is_frame+: false while the client's oversized gate is set,
|
|
696
|
+
# so only an envelope counts as a frame (size caps).
|
|
697
|
+
def initialize(logger: nil, keepalive_is_frame: true)
|
|
640
698
|
@logger = logger
|
|
699
|
+
@keepalive_is_frame = keepalive_is_frame
|
|
641
700
|
@reader = LineReader.new
|
|
642
701
|
@data = +''
|
|
643
702
|
@have_data = false
|
|
@@ -663,7 +722,7 @@ module Quonfig
|
|
|
663
722
|
if event
|
|
664
723
|
on_frame&.call
|
|
665
724
|
yield event
|
|
666
|
-
elsif comment_only
|
|
725
|
+
elsif comment_only && @keepalive_is_frame
|
|
667
726
|
on_frame&.call
|
|
668
727
|
end
|
|
669
728
|
elsif line.start_with?(':')
|
|
@@ -687,6 +746,14 @@ module Quonfig
|
|
|
687
746
|
|
|
688
747
|
case name
|
|
689
748
|
when 'data'
|
|
749
|
+
# Cap the joined multi-line data (each line is already capped by
|
|
750
|
+
# LineReader).
|
|
751
|
+
if @have_data && @data.bytesize + 1 + rest.bytesize > MAX_EVENT_BYTES
|
|
752
|
+
@data = +''
|
|
753
|
+
@have_data = false
|
|
754
|
+
raise SSEEventTooLargeError
|
|
755
|
+
end
|
|
756
|
+
|
|
690
757
|
if @have_data
|
|
691
758
|
@data << "\n" << rest
|
|
692
759
|
else
|
|
@@ -766,9 +833,14 @@ module Quonfig
|
|
|
766
833
|
loop do
|
|
767
834
|
idx = @buffer.index(/[\r\n]/, [pos, @scanned].max)
|
|
768
835
|
if idx.nil?
|
|
836
|
+
# Size caps: an unterminated line past the cap can never be a
|
|
837
|
+
# valid event; stop here instead of buffering the rest.
|
|
838
|
+
raise SSEEventTooLargeError if @buffer.bytesize - pos > MAX_EVENT_BYTES
|
|
839
|
+
|
|
769
840
|
@scanned = @buffer.bytesize
|
|
770
841
|
break
|
|
771
842
|
end
|
|
843
|
+
raise SSEEventTooLargeError if idx - pos > MAX_EVENT_BYTES
|
|
772
844
|
|
|
773
845
|
ch = @buffer.getbyte(idx)
|
|
774
846
|
if idx == pos && ch == LF && @last_was_cr
|
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
|