quonfig 1.9.0 → 1.11.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: 06b78bb9ba3c5950d54b844388ca57e91c23c7bf4cb595c82c57738e807a1716
4
- data.tar.gz: be216cc0d6ea8cee975bc799b94ac2f5a97829dae7fcb4056fded44835deb918
3
+ metadata.gz: 6d1016bb1930249b2a74a4f670e25cb2143ff0e664edd54062c585437a85527a
4
+ data.tar.gz: 8e2e0844f5853cf5c1d27f45054e71dc6cda6f8f5aa398468c982a485659d1b8
5
5
  SHA512:
6
- metadata.gz: 3abbf9a8a7afe7883670d60869289fab0e1263d56a309713c89dc3d187fe9e8460905ae6d89f98bf3c1bfc17c45d81238abdc3cb693bf892aace508fff06ea59
7
- data.tar.gz: fe1b42b9a3b97d41eabad20fd06610d3a7b237226e32b7af76463f98d5ee7425c0efe6958babbe4d9d0ccaf633d5d8b7c0d587a3245f2a9517534133b4691021
6
+ metadata.gz: 705580a318dd7d773b6b0b01236f0509b6d60f440e44e21d07228de7ce6915d71c9ffe9c9c60a6a4c50ed95ed39018727bbb2ed89389610d4df8c9058b196f48
7
+ data.tar.gz: a9e69afa6be50db19b7508e9224203dda7c62ef522d1b591d2d7050ffdd69cbef217433791330fead5435c9ebe9fcbd364c245be5127ff9fbb1443e74be6a586
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.0 - 2026-10-23
4
+
5
+ - **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.
6
+ - The SDK stops reading at the cap. It never reads the whole event or body and then measures it.
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - 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`.
11
+ - **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`.
12
+ - **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`.
13
+ - **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.
14
+ - **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.
15
+
16
+ ## 1.10.0 - 2026-10-21
17
+
18
+ - **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`.
19
+ - **Fix (patch): init-failure conformance (qfg-9p4i.17.4).**
20
+ - `init_timeout_ms` <= 0 now runs exactly one fetch attempt, lets it finish, then applies `on_init_failure`. Before, the attempt got a 1ms budget, so it was cut off before it could answer and the boot raised `InitializationTimeoutError` even against a healthy delivery.
21
+ - Config-fetch log lines (including the per-leg lines logged during init) show `scheme://host:port`, never URL userinfo (`user:pass@`), and no longer log the body of a 401/403 response. A non-JSON 200 body no longer reaches the init error's `errors`. The wall-clock fetch timeout message also drops userinfo.
22
+ - The unauthorized init line and error report the primary leg's status, not whichever leg answered first.
23
+ - When the init deadline cuts off an attempt, the error summary keeps the errors of legs that already failed (e.g. `primary=...: HTTP 503`) instead of reporting every leg as "no response before initTimeout". Such an attempt counts as hung (`reason` `:timeout`) only if no leg failed fast; otherwise `reason` is `:unavailable`, as in sdk-go.
24
+ - A connect timeout (`Net::OpenTimeout`, which Faraday wraps in `ConnectionFailed`) counts as a hung leg, so a blackholed delivery host raises `InitializationTimeoutError` (`reason` `:timeout`), as sdk-go does for a dial timeout.
25
+ - **UPGRADE (minor): `on_init_failure` takes `:fail`, `:wait` or `:continue` (qfg-9p4i.10).** `:fail` is the default, as `:raise` was, so a boot that cannot load config still raises. What changes for you:
26
+ - `Client.new` now **retries** a fast failure (refused connection, DNS, 5xx) with a jittered 0.5s, 1s, 2s ... backoff until `init_timeout_ms` (default 10s) instead of raising on the first failed attempt. A boot during a short blip now succeeds, and a boot with delivery down raises after `init_timeout_ms`, not at once.
27
+ - When every delivery leg rejects the SDK key (401/403/404/410) the init call does not wait: `:fail` raises at once, `:continue` returns at once.
28
+ - The raised error is now `Quonfig::Errors::InitializationError` (`reason`: `:unavailable`, `:unauthorized` or `:timeout`; `errors`: the per-leg summary with hosts only). A hang still raises `Quonfig::Errors::InitializationTimeoutError`, now a subclass of it. **If you rescued `RuntimeError` around `Client.new`, rescue `Quonfig::Errors::InitializationError` instead**; it still descends from `StandardError` via `Quonfig::Error`.
29
+ - New `:wait` keeps blocking and retrying with no upper bound. `:continue` (the new name for `:return`) returns a client over an empty store that keeps retrying in the background.
30
+ - `:raise` and `:return` (and `ON_INITIALIZATION_FAILURE::RAISE` / `RETURN`) still work as deprecated aliases and log a one-time WARN. `Options#on_init_failure` now reads back the canonical value (`:fail` for `:raise`). An unknown value runs as `:fail` with a WARN.
31
+ - **Added (minor): `Client#wait_for_ready(timeout_ms: nil)`.** Blocks until the client has config; returns `true`, or `false` when the timeout runs out or the client is stopped. `ready?` is unchanged.
32
+ - **Changed (minor): init logging follows the cross-SDK contract.** A first failed attempt logs one WARN (`[quonfig] init: config not loaded yet, retrying until initTimeout (10s). primary=<host>: <error>; secondary=<host>: <error>`), then a WARN every 5s; the timeout logs one ERROR naming the options; a recovery logs `[quonfig] init recovered: ...` at WARN. A `:continue` client that is still not connected logs a WARN every 30s for 5 minutes, then every 5 minutes. The old `Initialization did not complete cleanly` line is gone. Hosts are logged, never the SDK key.
33
+ - **Fix (patch): a boot against refused legs no longer sometimes raises a timeout (qfg-9p4i.10).** When the init backoff ended a few ms before `init_timeout_ms`, the SDK started one more attempt with that few-ms budget; its Timeout guard fired mid-connect and the boot raised `InitializationTimeoutError` (`reason: :timeout`) instead of `InitializationError` with `reason: :unavailable` (about 1 boot in 40 with a 500ms timeout). A retry now starts only with at least 250ms left; otherwise the SDK waits out the deadline and decides on the last attempt's outcome. Test: `test/test_init_failure.rb`.
34
+ - **Added (minor): the `*_details` getters report `PROVIDER_NOT_READY` before config loads (qfg-9p4i.10, design B.3).** On a client with no config yet (`on_init_failure: :continue` before the background retry heals, or a caught init failure) `get_*_details` now return `reason: "ERROR"`, `error_code: "PROVIDER_NOT_READY"` (`Quonfig::EvaluationDetails::ERROR_PROVIDER_NOT_READY`) instead of `FLAG_NOT_FOUND`. `get(key, default)` still returns the default.
35
+ - **Changed (minor): init log lines (section C).** The progress WARN now fires every 5s through a long backoff, and the `:wait` timeout WARN fires at `init_timeout_ms` rather than after the attempt that straddles it. The auth line names the legs (`by primary and secondary`), and a heal through the stream names its leg (`via primary`) instead of `via stream`. With SemanticLogger, each init line carries the payload `event: "quonfig.init.<name>"` (`start`, `retrying`, `waiting`, `wait`, `failed`, `continue`, `unauthorized`, `not_connected`, `recovered`, `deprecated_alias`).
36
+ - **Fix (patch): a subclass of `Quonfig::Client` registers with the fork hook.** `register_instance` used the subclass's own (unset) registry and raised `NoMethodError` in `Client.new`.
37
+ - **Tests: unit and release tests now run integration-test-data `v2026.10.20`.** It adds the init-failure cases (fail by default, fail on refused/hung/401 legs, continue, and the `:raise`/`:return` aliases) and drops the Prefab-era keys. The chaos workflows stay on `v2026.10.16.2`.
38
+
3
39
  ## 1.9.0 - 2026-10-20
4
40
 
5
41
  - **UPGRADE: SSE reports `connected` at the first frame, not at the 200 response (qfg-goi1.10.9).** A stream connection now counts as connected, for `connection_state`, the reconnect backoff and the fallback poller, only after it delivers its first valid frame: a config envelope or a keepalive. Before, it counted as soon as the 200 response headers arrived.
data/README.md CHANGED
@@ -259,6 +259,45 @@ for the cross-SDK story (sdk-node, sdk-go, sdk-ruby, sdk-python, sdk-java).
259
259
  | `QUONFIG_DEV_CONTEXT` | `false` turns off the developer context (`quonfig-user.email` from `qfg login`). An explicit `enable_quonfig_user_context:` wins. See [Developer context](#developer-context-on-by-default). |
260
260
  | `QUONFIG_DOMAIN` | Base domain used to derive api, sse, and telemetry URLs. Defaults to `quonfig.com`. Set to `quonfig-staging.com` to point at staging. Explicit `api_urls:` / `telemetry_url:` kwargs override this. |
261
261
 
262
+ ## Startup failure (`on_init_failure`)
263
+
264
+ `Client.new` fetches config from both delivery legs (primary, then the
265
+ secondary in parallel) and retries through blips with a jittered backoff
266
+ (0.5s, 1s, 2s ... capped at 30s) for up to `init_timeout_ms` (default 10s).
267
+ A healthy boot logs nothing above DEBUG. If no config has loaded by then,
268
+ `on_init_failure` decides:
269
+
270
+ | Value | At `init_timeout_ms` | Every leg rejects the SDK key (401/403/404/410) |
271
+ |---|---|---|
272
+ | `:fail` (default) | Raises `Quonfig::Errors::InitializationError` (`reason` `:unavailable`), or its subclass `InitializationTimeoutError` (`reason` `:timeout`) when an attempt was still in flight. No client is returned, so your orchestrator's restart is the retry. | Raises at once (`reason` `:unauthorized`). |
273
+ | `:wait` | Keeps blocking and retrying, with no upper bound, logging a WARN every 30s. | Logs an ERROR and keeps retrying. |
274
+ | `:continue` | Returns a client with no config: getters return your default (and `get_or_raise` raises `MissingDefaultError`). It keeps retrying in the background and serves live config as soon as it lands. | Returns at once. |
275
+
276
+ ```ruby
277
+ begin
278
+ client = Quonfig::Client.new(sdk_key: key, on_init_failure: :fail, init_timeout_ms: 10_000)
279
+ rescue Quonfig::Errors::InitializationError => e
280
+ e.reason # => :unavailable, :unauthorized or :timeout
281
+ e.errors # => "primary=primary.quonfig.com:443: ...; secondary=secondary.quonfig.com:443: ..."
282
+ raise
283
+ end
284
+ ```
285
+
286
+ `init_timeout_ms` <= 0 runs exactly one fetch attempt and lets it finish (it
287
+ is bounded by the per-leg fetch timeouts), then applies `on_init_failure` as
288
+ in the table: `:fail` raises, `:continue` starts on code defaults and keeps
289
+ retrying, `:wait` keeps retrying. It never means "no deadline".
290
+
291
+ Init log lines and `InitializationError#message` / `#errors` name delivery
292
+ hosts (`host:port`) and HTTP statuses only, never URL credentials
293
+ (`user:pass@`) or upstream response bodies.
294
+
295
+ Readiness: `client.ready?` is true once config has loaded, and
296
+ `client.wait_for_ready(timeout_ms: nil)` blocks until it has (true) or the
297
+ timeout runs out (false). Under `:wait`, point your readiness probe at
298
+ `ready?`. `:raise` and `:return` are the deprecated spellings of `:fail` and
299
+ `:continue`; they still work and log a one-time WARN.
300
+
262
301
  ## Constructor options
263
302
 
264
303
  ```ruby
@@ -270,6 +309,7 @@ Quonfig::Client.new(
270
309
  fallback_poll_enabled: true,
271
310
  fallback_poll_interval_ms: 60_000,
272
311
  init_timeout_ms: 10_000,
312
+ on_init_failure: :fail,
273
313
  on_no_default: :raise,
274
314
  global_context: {},
275
315
  datadir: '/path/to/workspace',
@@ -287,7 +327,8 @@ Quonfig::Client.new(
287
327
  | `enable_sse` | `Boolean` | `true` | Receive real-time updates over Server-Sent Events. |
288
328
  | `fallback_poll_enabled` | `Boolean` | `true` | Engage HTTP polling as a fallback when SSE is unavailable for >= 2x `fallback_poll_interval_ms`. Deprecated alias: `enable_polling`. |
289
329
  | `fallback_poll_interval_ms` | `Integer` (ms) | `60_000` | Interval between fallback HTTP polls, in milliseconds. Deprecated alias: `poll_interval` (seconds, multiplied by 1000 internally). |
290
- | `init_timeout_ms` | `Integer` (ms) | `10_000` | Maximum time to wait for the initial config load, in milliseconds. Deprecated alias: `initialization_timeout_sec` (seconds, multiplied by 1000 internally). |
330
+ | `init_timeout_ms` | `Integer` (ms) | `10_000` | Maximum time to wait for the initial config load, in milliseconds. `<= 0` runs exactly one attempt, then applies `on_init_failure`. Deprecated alias: `initialization_timeout_sec` (seconds, multiplied by 1000 internally). |
331
+ | `on_init_failure` | `Symbol` | `:fail` | What `Client.new` does when no config has loaded by `init_timeout_ms`: `:fail`, `:wait` or `:continue`. See [Startup failure](#startup-failure-on_init_failure). Deprecated aliases: `:raise` (= `:fail`), `:return` (= `:continue`). |
291
332
  | `on_no_default` | `Symbol` | `:raise` | Behavior when a key has no value and no default: `:raise` (raise `Quonfig::Errors::MissingDefaultError`) or `:return_nil`. |
292
333
  | `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
293
334
  | `enable_quonfig_user_context` | `Boolean` or `nil` | `nil` (on, unless `QUONFIG_DEV_CONTEXT=false`) | Add the `qfg login` email as `quonfig-user.email`. See [Developer context](#developer-context-on-by-default). |
@@ -500,7 +541,7 @@ Caveats:
500
541
  in-process SSE state is moot.
501
542
  - The first lookup in a forked child **blocks** on that child's own config
502
543
  fetch, under the same `init_timeout_ms` and `on_init_failure` options a
503
- fresh client uses. **The default is `on_init_failure: :raise`**, so if the
544
+ fresh client uses. **The default is `on_init_failure: :fail`**, so if the
504
545
  child's fetch fails against every `api_urls` leg (primary and secondary
505
546
  both unreachable) the failure **raises out of that first lookup**, exactly
506
547
  as `Client.new` would at boot, and later lookups keep raising — without
@@ -508,7 +549,7 @@ Caveats:
508
549
  the client serves config normally again. On 1.3.0 and earlier a child in
509
550
  that situation silently served the parent's snapshot instead. If you would
510
551
  rather a forked child serve defaults through an outage, set
511
- `on_init_failure: :return`: a failed fetch then logs one line and the child
552
+ `on_init_failure: :continue`: a failed fetch then logs one line and the child
512
553
  serves defaults until its stream or poller lands the first envelope.
513
554
  - **Other threads wait.** Every thread that reaches the client while that
514
555
  first fetch is in flight blocks on it and then sees the fetched config. One
@@ -667,6 +708,10 @@ reuse the parent's client object in a child process without it.
667
708
  polling). Returns `nil` before the first install. Preserved across `stop`.
668
709
  - `client.connection_state` — a `Symbol` describing the aggregate state:
669
710
  `:initializing`, `:connected`, `:disconnected`, or `:falling_back`.
711
+ - `client.callback_errors_total(callback: 'on_config_update')` — how many
712
+ times your `on_update` block raised. The SDK catches the error, logs it at
713
+ ERROR with its stack, and keeps delivering updates; a raise is not a worker
714
+ restart.
670
715
 
671
716
  > 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.
672
717