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 +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +48 -3
- data/lib/quonfig/client.rb +406 -29
- data/lib/quonfig/config_loader.rb +137 -30
- data/lib/quonfig/dev_context.rb +8 -1
- data/lib/quonfig/errors/initialization_error.rb +31 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +9 -4
- data/lib/quonfig/evaluation_details.rb +3 -0
- data/lib/quonfig/http_connection.rb +118 -3
- data/lib/quonfig/internal_logger.rb +21 -14
- data/lib/quonfig/options.rb +50 -2
- data/lib/quonfig/sse_config_client.rb +62 -3
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d1016bb1930249b2a74a4f670e25cb2143ff0e664edd54062c585437a85527a
|
|
4
|
+
data.tar.gz: 8e2e0844f5853cf5c1d27f45054e71dc6cda6f8f5aa398468c982a485659d1b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: :
|
|
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: :
|
|
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
|
|