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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 774d181106fd045eddb916eb177c89744a97f014d7168a4e89d4d51ca2b3d6e2
4
- data.tar.gz: c34620585b746c0f013ec473870306593e45fd617b62187ebc31a3d44e14d709
3
+ metadata.gz: c7947aba694daddefb1357930d7d1b3e63d13c3f68ad71b1e5d33eff15584aa6
4
+ data.tar.gz: db349b68906829c24953f9504511200f7b837c8980ad9beb53a755586420f470
5
5
  SHA512:
6
- metadata.gz: 1e56307ab528445682d284fc48b651dd40224ba007f245e6abc4816bbb0b329e1d8fc0f5d6eff6f73137d375b8abbaf47488f074c803fe1d2944d197c3911910
7
- data.tar.gz: 8dcfd007ada4cd44417a1b57928a90bf1e47a33e609b2cc14cb4484460911730f00d9e60cc0ef15fb150de61eab245d8e26e19e0c0f51e9ad0e50132afccbc03
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
- - **Wrong type:** a value that does not match the getter's type (for example
398
- `get_int` on a string config) raises `Quonfig::Errors::TypeMismatchError`,
399
- with or without a `default:`.
417
+ - **Failed read:** a read that cannot produce a value of the getter's type
418
+ returns `default:` when you pass one and logs one WARN per key (it names the
419
+ key, never the value). Without a default it follows `on_no_default`:
420
+ `:return_nil` returns `nil` (and logs the WARN), `:raise` raises the error:
421
+
422
+ | Situation | Error raised with no default under `:raise` |
423
+ |---|---|
424
+ | Value of another type (e.g. `get_bool` on a string config) | `Quonfig::Errors::TypeMismatchError` |
425
+ | Malformed stored or ENV_VAR value (an int `"twelve"`, a duration `"30s"`) | `Quonfig::Errors::EnvVarParseError` |
426
+ | ENV_VAR not set | `Quonfig::Errors::MissingEnvVarError` |
427
+ | Secret cannot be decrypted | `Quonfig::Errors::DecryptionError` |
428
+
429
+ Strings are never coerced: a stored `"false"` is a string, so `get_bool`
430
+ treats it as a type mismatch instead of returning `false`. The only conversions
431
+ are between numbers, and only when exact: `get_float` reads an int config
432
+ (`30` -> `30.0`), and `get_int` reads a double config holding a whole number
433
+ within +/-(2^53-1) (`3.0` -> `3`; `3.9` is a type mismatch, never truncated).
434
+ The untyped `get(key, default)` follows the same rule for the failures it can
435
+ see (malformed values, ENV_VAR and decryption errors).
436
+
437
+ `enabled?` never raises. It returns `true` only for a flag that resolves to the
438
+ bool `true`, and `false` (with one WARN per key) for a missing key, a flag with
439
+ no matching rule, a value that is not a bool (a stored string `"true"`
440
+ included) or any resolution error.
441
+
442
+ A wrong decryption key or an unset ENV_VAR therefore degrades to your defaults
443
+ with one WARN per key. If a broken config must stop your service, call the
444
+ getter without a `default:` on a client with `on_no_default: :raise` (the
445
+ default), or use `get_or_raise`.
400
446
 
401
447
  | Method | Returns |
402
448
  |-----------------------------------------------------|-------------------------------------------|
@@ -407,11 +453,10 @@ only `default:`; the context is the bound one.
407
453
  | `get_string_list(key, default:, context:)` | `Array<String>` |
408
454
  | `get_duration(key, default:, context:)` | `Integer` (milliseconds) |
409
455
  | `get_json(key, default:, context:)` | `Hash`, `Array`, or a scalar |
410
- | `enabled?(feature_name, context = nil)` | `true` or `false` (`false` when missing) |
456
+ | `enabled?(feature_name, context = nil)` | `true` or `false`; never raises |
411
457
 
412
458
  `get_duration` returns integer milliseconds (`PT1.5S` is `1500`). A duration
413
- value that is not valid ISO-8601 returns `default:` and logs one warning per
414
- key; with no default it follows `on_no_default`.
459
+ value that is not valid ISO-8601 is a malformed value (see above).
415
460
 
416
461
  Example:
417
462
 
@@ -426,13 +471,18 @@ client.get_json('homepage.layout', default: {})
426
471
  client.enabled?('beta-feature', user: { key: 'user-123' })
427
472
  ```
428
473
 
429
- `get_or_raise(key, default:, context:)` returns the value like `get`, but a
430
- missing key without a `default:` always raises
431
- `Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to), and
432
- an unset or malformed ENV_VAR value, a malformed stored duration, or a
433
- decryption failure raises its error. `get_duration_details(key, context:)`
434
- returns `Quonfig::EvaluationDetails` with the duration in integer milliseconds,
435
- like the other `*_details` getters.
474
+ `get_or_raise(key, default:, context:)` returns the value like `get` (untyped),
475
+ but a missing key without a `default:` always raises
476
+ `Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to),
477
+ and an unset or malformed ENV_VAR value, a malformed stored value, or a
478
+ decryption failure raises its error even when you pass a `default:` (the
479
+ default only covers a missing key). For a typed read that raises, call the
480
+ typed getter with no `default:` under `on_no_default: :raise`.
481
+
482
+ The `*_details` getters (`get_int_details(key, context:)` and so on) never
483
+ raise. They return `Quonfig::EvaluationDetails`; a wrong-type or malformed
484
+ value is `reason: "ERROR"` with `error_code: "TYPE_MISMATCH"`.
485
+ `get_duration_details` carries the duration in integer milliseconds.
436
486
 
437
487
  ## Dynamic log levels (SemanticLogger)
438
488
 
@@ -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
 
@@ -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 malformed duration value (qfg-2agi.10).
79
- @malformed_duration_warned = Set.new
80
- @malformed_duration_mutex = Mutex.new
120
+ # Keys already warned about for a failed read: a wrong-type value, a
121
+ # malformed value, an unset ENV_VAR or a decryption failure, plus
122
+ # enabled? returning false for a missing key (qfg-2agi.10, qfg-zvyy).
123
+ # One WARN per key per client.
124
+ @read_failure_warned = Set.new
125
+ @read_failure_mutex = Mutex.new
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
- result =
150
- begin
151
- @resolver.get(key, ctx)
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
- record_evaluation_for_telemetry(result)
161
- result.unwrapped_value
209
+ record_evaluation_for_telemetry(result)
210
+ unwrap_for_read(result)
211
+ rescue *READ_FAILURES => e
212
+ failed_read(key, default, e)
213
+ end
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
- # - A config whose valueType is not duration raises TypeMismatchError,
187
- # like get_int on a string config.
188
- # - Values follow the shared grammar (Quonfig::Duration); ms are rounded
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
- ensure_initialized_after_fork
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
- value = result.unwrapped_value
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
- value = get(feature_name, false, jit_context)
289
- [true, 'true'].include?(value)
323
+ ensure_initialized_after_fork
324
+ begin
325
+ ctx = build_context(jit_context)
326
+ record_context_for_telemetry(ctx)
327
+ result = resolve_for_read(feature_name, ctx)
328
+ if result.nil?
329
+ warn_read_failure(feature_name, 'missing key or no matching rule; enabled? returns false')
330
+ return false
331
+ end
332
+
333
+ record_evaluation_for_telemetry(result)
334
+ typed_value(feature_name, result, :bool) == true
335
+ rescue StandardError => e
336
+ warn_read_failure(feature_name, "#{e.message}; enabled? returns false")
337
+ false
338
+ end
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
- LOG.error "[quonfig] onConfigUpdate callback raised: #{e.class}: #{e.message}"
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
- if expected_type == :duration
2147
- raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', raw_value) unless duration_result?(result)
2148
- raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, raw_value)
2149
- end
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
- jit = context == NO_DEFAULT_PROVIDED ? NO_DEFAULT_PROVIDED : context
2218
- value = get(key, default, jit)
2316
+ ensure_initialized_after_fork
2317
+ ctx = build_context(context)
2318
+ record_context_for_telemetry(ctx)
2319
+ begin
2320
+ result = resolve_for_read(key, ctx)
2321
+ return handle_missing(key, default) if result.nil?
2322
+
2323
+ record_evaluation_for_telemetry(result)
2324
+ typed_value(key, result, expected_type)
2325
+ rescue *READ_FAILURES => e
2326
+ failed_read(key, default, e)
2327
+ end
2328
+ end
2329
+
2330
+ # The resolved Evaluation for +key+, or nil when the key is missing or no
2331
+ # rule matched. Resolution errors (MissingEnvVarError, EnvVarParseError,
2332
+ # DecryptionError) propagate.
2333
+ def resolve_for_read(key, ctx)
2334
+ @resolver.get(key, ctx)
2335
+ rescue Quonfig::Errors::MissingDefaultError
2336
+ nil
2337
+ end
2338
+
2339
+ # +result+'s value with no type check. A stored value that does not parse
2340
+ # as its declared type (an int "twelve", a duration "30s") raises
2341
+ # EnvVarParseError, as a malformed ENV_VAR value does.
2342
+ def unwrap_for_read(result)
2343
+ value =
2344
+ begin
2345
+ result.unwrapped_value
2346
+ rescue ArgumentError, TypeError, FloatDomainError
2347
+ raise malformed_stored_value_error(result)
2348
+ end
2349
+ raise malformed_stored_value_error(result) if malformed_stored_duration?(result, value)
2350
+
2351
+ value
2352
+ end
2353
+
2354
+ # +result+'s value as +expected_type+, or raise TypeMismatchError (a value
2355
+ # of another type) / EnvVarParseError (a malformed value). Strings are
2356
+ # never coerced: a stored "false" is not a bool and "3" is not an int.
2357
+ def typed_value(key, result, expected_type)
2358
+ stored = stored_value_type(result)
2359
+ accepted = ACCEPTED_TYPES.fetch(expected_type)
2360
+ raise Quonfig::Errors::TypeMismatchError.new(key, TYPE_NAMES.fetch(expected_type), nil, actual_type: stored) unless stored.nil? || accepted.include?(stored)
2219
2361
 
2220
- # Missing path: resolver returned the caller's default (or nil under
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
- coerce_and_check(key, value, expected_type)
2365
+ if [Integer, Float].include?(expected_type)
2366
+ typed_number(key, result, value, expected_type)
2367
+ elsif expected_type == :bool
2368
+ typed_bool(key, result)
2369
+ else
2370
+ check_class(key, value, expected_type)
2371
+ end
2226
2372
  end
2227
2373
 
2228
- def duration_millis(key, result, default)
2229
- raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', result.unwrapped_value) unless duration_result?(result)
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
- value = result.unwrapped_value
2232
- millis = value.is_a?(Integer) ? value : Quonfig::Duration.parse_millis(value)
2233
- return millis unless millis.nil?
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
- malformed_duration(key, default, malformed_stored_duration_error(result))
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
- def duration_result?(result)
2239
- config = result.config || {}
2240
- value_type = config['valueType'] || config[:valueType] || config['value_type'] || config[:value_type]
2241
- value_type == 'duration' || result.type == 'duration'
2402
+ # A bool Value unwraps with !!raw, so check the raw value: only a real
2403
+ # true/false is a bool.
2404
+ def typed_bool(key, result)
2405
+ raw = result.raw_value
2406
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', raw) unless [true, false].include?(raw)
2407
+
2408
+ raw
2409
+ end
2410
+
2411
+ def check_class(key, value, expected_type)
2412
+ ok =
2413
+ if expected_type == String then value.is_a?(String)
2414
+ elsif expected_type == :string_list then value.is_a?(Array) && value.all?(String)
2415
+ elsif expected_type == :duration then value.is_a?(Integer)
2416
+ else true # :json: any JSON value
2417
+ end
2418
+ raise Quonfig::Errors::TypeMismatchError.new(key, TYPE_NAMES.fetch(expected_type), value) unless ok
2419
+
2420
+ value
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 malformed_stored_duration_error(result)
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
- def malformed_duration(key, default, error)
2254
- if @malformed_duration_mutex.synchronize { @malformed_duration_warned.add?(key.to_s) }
2255
- LOG.warn "[quonfig] #{key} is not a valid ISO-8601 duration; " \
2256
- "#{default == NO_DEFAULT_PROVIDED ? 'no default given' : 'returning the default'}"
2257
- end
2258
- return default unless default == NO_DEFAULT_PROVIDED
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
- def coerce_and_check(key, value, expected_type)
2265
- case expected_type
2266
- when :bool
2267
- raise Quonfig::Errors::TypeMismatchError.new(key, 'Boolean', value) unless [true, false].include?(value)
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
- arr
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): an ignored gen<=0 payload keeps the previous
487
- # ETag. Its ETag is the git sha, and the server may later repair the
488
- # generation for the SAME sha; remembering it would 304 that repair.
489
- ignored_unversioned = result == :not_modified && extract_generation(envelope.meta || {}) <= 0
490
- set_etag_for(index, new_etag) unless ignored_unversioned
498
+ # Exception (qfg-9dxb.9, qfg-j7ui; supervisor contract Test 11): an
499
+ # ignored gen<=0 payload, or one dropped as STRICTLY older, keeps the
500
+ # previous ETag. Its ETag is the git sha, and the server may later
501
+ # repair the generation for the SAME sha; remembering it would 304 that
502
+ # repair until the next commit. A same-generation 200 stores its ETag.
503
+ set_etag_for(index, new_etag) unless result == :not_modified && refused_by_generation?(envelope)
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?
@@ -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
- path = File.join(Dir.home, TOKENS_BASENAME)
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
- def initialize(key, expected, actual_value)
11
- super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}")
10
+ # +actual_type+, when given, is the config's declared value type
11
+ # ('string', 'duration', ...) and is named instead of the Ruby class.
12
+ def initialize(key, expected, actual_value, actual_type: nil)
13
+ got = actual_type ? "a #{actual_type} value" : actual_value.class
14
+ super("Quonfig value for key '#{key}' expected #{expected}, got #{got}")
12
15
  end
13
16
  end
14
17
  end
@@ -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
- def initialize(uri, sdk_key, timeout_ms: nil, open_timeout_ms: nil)
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
@@ -257,15 +257,27 @@ module Quonfig
257
257
  )
258
258
  end
259
259
 
260
+ # A missing, unmatched or empty key is a decryption failure too, so the
261
+ # typed getters apply the caller's default (qfg-zvyy). DecryptionError is
262
+ # a Quonfig::Error, so rescuing Quonfig::Error still catches it.
260
263
  key_cfg = @store.get(decrypt_with)
261
- raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
264
+ if key_cfg.nil?
265
+ raise Quonfig::Errors::DecryptionError.new(config_key(config),
266
+ %(decryption key config "#{decrypt_with}" not found))
267
+ end
262
268
 
263
269
  key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
264
- raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
270
+ if key_match.nil?
271
+ raise Quonfig::Errors::DecryptionError.new(config_key(config),
272
+ %(decryption key config "#{decrypt_with}" did not match))
273
+ end
265
274
 
266
275
  resolved_key = resolve_value_on_path(key_match.value, key_cfg, context, key_path)
267
276
  secret_key = vget(resolved_key, :value, 'value').to_s
268
- raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
277
+ if secret_key.empty?
278
+ raise Quonfig::Errors::DecryptionError.new(config_key(config),
279
+ %(decryption key from "#{decrypt_with}" is empty))
280
+ end
269
281
 
270
282
  ciphertext = vget(value, :value, 'value').to_s
271
283
  begin
@@ -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
- on_frame = lambda do
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
- def initialize(logger: nil)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.10.0'
4
+ VERSION = '2.0.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport