quonfig 1.9.0 → 1.10.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: 774d181106fd045eddb916eb177c89744a97f014d7168a4e89d4d51ca2b3d6e2
4
+ data.tar.gz: c34620585b746c0f013ec473870306593e45fd617b62187ebc31a3d44e14d709
5
5
  SHA512:
6
- metadata.gz: 3abbf9a8a7afe7883670d60869289fab0e1263d56a309713c89dc3d187fe9e8460905ae6d89f98bf3c1bfc17c45d81238abdc3cb693bf892aace508fff06ea59
7
- data.tar.gz: fe1b42b9a3b97d41eabad20fd06610d3a7b237226e32b7af76463f98d5ee7425c0efe6958babbe4d9d0ccaf633d5d8b7c0d587a3245f2a9517534133b4691021
6
+ metadata.gz: 1e56307ab528445682d284fc48b651dd40224ba007f245e6abc4816bbb0b329e1d8fc0f5d6eff6f73137d375b8abbaf47488f074c803fe1d2944d197c3911910
7
+ data.tar.gz: 8dcfd007ada4cd44417a1b57928a90bf1e47a33e609b2cc14cb4484460911730f00d9e60cc0ef15fb150de61eab245d8e26e19e0c0f51e9ad0e50132afccbc03
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.10.0 - 2026-10-21
4
+
5
+ - **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`.
6
+ - **Fix (patch): init-failure conformance (qfg-9p4i.17.4).**
7
+ - `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.
8
+ - 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.
9
+ - The unauthorized init line and error report the primary leg's status, not whichever leg answered first.
10
+ - 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.
11
+ - 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.
12
+ - **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:
13
+ - `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.
14
+ - 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.
15
+ - 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`.
16
+ - 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.
17
+ - `: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.
18
+ - **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.
19
+ - **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.
20
+ - **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`.
21
+ - **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.
22
+ - **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`).
23
+ - **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`.
24
+ - **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`.
25
+
3
26
  ## 1.9.0 - 2026-10-20
4
27
 
5
28
  - **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
@@ -21,6 +21,12 @@ module Quonfig
21
21
  class Client
22
22
  LOG = Quonfig::InternalLogger.new(self)
23
23
 
24
+ # Init fetch loop cadence (qfg-9p4i.10; init-failure design B.3, C).
25
+ INIT_PROGRESS_EVERY_S = 5
26
+ INIT_WAIT_PROGRESS_EVERY_S = 30
27
+ INIT_BACKOFF_CAP_S = 30.0
28
+ private_constant :INIT_PROGRESS_EVERY_S, :INIT_WAIT_PROGRESS_EVERY_S, :INIT_BACKOFF_CAP_S
29
+
24
30
  # Auth-failed probe period bounds, rule R3 (qfg-goi1.10.26).
25
31
  AUTH_PROBE_MIN_SECONDS = 150.0
26
32
  AUTH_PROBE_MAX_SECONDS = 300.0
@@ -62,6 +68,7 @@ module Quonfig
62
68
  Quonfig::Options.new(option_kwargs)
63
69
  end
64
70
  Quonfig::InternalLogger.user_logger = @options.logger if @options.logger
71
+ warn_on_init_failure_spelling(@options)
65
72
  @global_context = build_initial_global_context(@options)
66
73
  @instance_hash = SecureRandom.uuid
67
74
  @store = store || Quonfig::ConfigStore.new
@@ -129,7 +136,8 @@ module Quonfig
129
136
 
130
137
  # Register only for non-store-injected clients (a caller-supplied store
131
138
  # is the test/bootstrap path; the fork hook does not apply there).
132
- self.class.register_instance(self) unless store
139
+ # Always the base class registry: a subclass has no @instances of its own.
140
+ Quonfig::Client.register_instance(self) unless store
133
141
  end
134
142
 
135
143
  # ---- Lookup --------------------------------------------------------
@@ -621,6 +629,33 @@ module Quonfig
621
629
  !last_successful_refresh.nil?
622
630
  end
623
631
 
632
+ # Block until the client has config (see #ready?), up to +timeout_ms+ (nil
633
+ # = no bound). Returns true once ready, false if the timeout ran out or the
634
+ # client was stopped first (qfg-9p4i.10; init-failure design B.4). Never
635
+ # raises for an outage: under on_init_failure: :continue this is how a
636
+ # caller waits for the background retry to heal.
637
+ #
638
+ # In a forked child that has not used the client yet, this runs the
639
+ # child's re-initialization (as any first use does) and keeps waiting if
640
+ # that fails.
641
+ def wait_for_ready(timeout_ms: nil)
642
+ deadline = timeout_ms && (monotonic_now + (timeout_ms / 1000.0))
643
+ begin
644
+ ensure_initialized_after_fork
645
+ rescue Quonfig::Errors::InitializationError
646
+ nil
647
+ end
648
+ loop do
649
+ return true if ready?
650
+ return false if @stopped
651
+ return false if deadline && monotonic_now >= deadline
652
+
653
+ sleep WAIT_FOR_READY_POLL_S
654
+ end
655
+ end
656
+
657
+ WAIT_FOR_READY_POLL_S = 0.05
658
+
624
659
  # Meta.generation of the currently-held envelope (0 before the first install
625
660
  # or when the backend does not emit a generation). Canonical ordering: an
626
661
  # established client never regresses to a lower generation.
@@ -794,6 +829,10 @@ module Quonfig
794
829
 
795
830
  cancel_fallback_engage_timer
796
831
 
832
+ # Sleep-only thread with no I/O and nothing to drain: killing it is safe.
833
+ @not_ready_reporter&.kill
834
+ @not_ready_reporter = nil
835
+
797
836
  begin
798
837
  @poll_supervisor&.stop
799
838
  rescue StandardError => e
@@ -829,6 +868,8 @@ module Quonfig
829
868
  @telemetry_reporter = nil
830
869
  @datadir_watcher = nil
831
870
  @fallback_engage_timer = nil
871
+ @not_ready_reporter = nil
872
+ @init_heal_pending = false
832
873
 
833
874
  # Dropping our reference is not enough for the reporter: its
834
875
  # `Kernel.at_exit { final_drain_on_exit }` closure is process-wide, it
@@ -939,9 +980,9 @@ module Quonfig
939
980
  end
940
981
  end
941
982
 
942
- return unless @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::RAISE
983
+ return unless @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::FAIL
943
984
 
944
- # Parity with a fresh Client.new, which raises under :raise. Stored so
985
+ # Parity with a fresh Client.new, which raises under :fail. Stored so
945
986
  # later calls keep raising rather than re-running the fetch on every
946
987
  # lookup.
947
988
  @fork_rebuild_error = err
@@ -1049,7 +1090,18 @@ module Quonfig
1049
1090
  # path that hands an envelope to the cache: datadir load, initial HTTP
1050
1091
  # fetch, SSE event apply, and polling worker fetch.
1051
1092
  def record_refresh!
1052
- @state_mutex.synchronize { @last_successful_refresh = Time.now.utc }
1093
+ healed_from = @state_mutex.synchronize do
1094
+ @last_successful_refresh = Time.now.utc
1095
+ next nil unless @init_heal_pending
1096
+
1097
+ # First install after an init that continued without config. Cleared
1098
+ # under the lock so racing installers log the heal exactly once.
1099
+ @init_heal_pending = false
1100
+ @init_started_at || monotonic_now
1101
+ end
1102
+ # The section C heal line, at WARN so the filter level that saw the
1103
+ # failure also sees the recovery. Logged outside the lock.
1104
+ log_init_recovered(healed_from) if healed_from
1053
1105
  end
1054
1106
 
1055
1107
  def sse_restart_total
@@ -1338,6 +1390,7 @@ module Quonfig
1338
1390
  warn_if_hedge_abort_exceeds_init_timeout
1339
1391
  warn_if_explicit_api_urls_disables_failover
1340
1392
 
1393
+ @network_mode = true
1341
1394
  # ||=: after a fork the child already built its loader over its fresh
1342
1395
  # store (see #reset_store_in_child!).
1343
1396
  @config_loader ||= Quonfig::ConfigLoader.new(@store, @options, failover_aggregator: @failover_aggregator)
@@ -1367,29 +1420,285 @@ module Quonfig
1367
1420
  start_polling if @options.enable_polling && !sse_started
1368
1421
  end
1369
1422
 
1423
+ # The init call's fetch loop (qfg-9p4i.10; init-failure design B.3 and the
1424
+ # section C log contract). Attempts the hedged primary+secondary fetch, and
1425
+ # between failed attempts sleeps a jittered 0.5s, 1s, 2s ... (30s cap) until
1426
+ # one installs. At init_timeout_ms, on_init_failure decides:
1427
+ #
1428
+ # - :fail raise InitializationError (reason :unavailable), or
1429
+ # InitializationTimeoutError when an attempt was still in flight
1430
+ # - :wait keep going with no upper bound
1431
+ # - :continue return over the empty store; the update channel started by
1432
+ # the caller keeps retrying and heals it
1433
+ #
1434
+ # Every leg answering an auth-class status (401/403/404/410) short-circuits
1435
+ # :fail and :continue at once; :wait logs it and keeps retrying (the key
1436
+ # may be provisioned late).
1437
+ #
1438
+ # init_timeout_ms <= 0 runs exactly one attempt, lets it finish (bounded by
1439
+ # the per-leg fetch timeouts, not the init deadline), then applies
1440
+ # on_init_failure. :wait keeps retrying after it (decision 8, qfg-9p4i.17).
1370
1441
  def perform_initial_fetch
1371
- timeout = (@options.init_timeout_ms || 10_000) / 1000.0
1372
- result = :failed
1442
+ mode = @options.on_init_failure
1443
+ timeout_ms = @options.init_timeout_ms || Quonfig::Options::DEFAULT_INIT_TIMEOUT_MS
1444
+ timeout_ms = 0 if timeout_ms.negative?
1445
+ one_shot = timeout_ms.zero?
1446
+ timeout_s = timeout_ms / 1000.0
1447
+ start = monotonic_now
1448
+ @init_started_at = start
1449
+ deadline = start + timeout_s
1450
+ urls = Array(@options.config_api_urls)
1451
+ LOG.debug "[quonfig] init: fetching config (initTimeout=#{format_secs(timeout_s)}, onInitFailure=#{mode})",
1452
+ event: 'quonfig.init.start'
1453
+
1454
+ waiting = mode == Quonfig::Options::ON_INITIALIZATION_FAILURE::WAIT
1455
+ attempt = 0
1456
+ errs = ''
1457
+ next_progress = start + init_progress_every_s
1458
+ timed_out_logged = false
1459
+
1460
+ # The section C progress lines, on their own cadence: called after every
1461
+ # attempt and at each wake-up inside the backoff sleep.
1462
+ report = lambda do |now|
1463
+ if waiting && !timed_out_logged && now >= deadline
1464
+ timed_out_logged = true
1465
+ next_progress = now + INIT_WAIT_PROGRESS_EVERY_S
1466
+ LOG.warn "[quonfig] init: no config after #{format('%.1f', now - start)}s. onInitFailure=wait: still " \
1467
+ "blocking startup and retrying indefinitely. #{errs}. Use onInitFailure=fail or continue to bound startup.",
1468
+ event: 'quonfig.init.wait'
1469
+ end
1470
+ next unless now >= next_progress
1471
+ next unless now < deadline || waiting
1373
1472
 
1374
- begin
1375
- Timeout.timeout(timeout) do
1376
- result = @config_loader.fetch!
1473
+ if now < deadline
1474
+ LOG.warn "[quonfig] init: still waiting for config (#{format('%.1f', now - start)}s elapsed of " \
1475
+ "#{format_secs(timeout_s)}, attempt #{attempt}). #{errs}", event: 'quonfig.init.waiting'
1476
+ next_progress += init_progress_every_s while next_progress <= now
1477
+ else
1478
+ LOG.warn "[quonfig] init: still blocked waiting for config (#{(now - start).round}s elapsed, " \
1479
+ "attempt #{attempt}). #{errs}", event: 'quonfig.init.waiting'
1480
+ next_progress += INIT_WAIT_PROGRESS_EVERY_S while next_progress <= now
1377
1481
  end
1378
- rescue Timeout::Error
1379
- handle_init_failure(
1380
- Quonfig::Errors::InitializationTimeoutError.new(timeout, nil)
1381
- )
1382
- return
1383
1482
  end
1384
1483
 
1385
- if result == :failed
1386
- handle_init_failure(RuntimeError.new('Config fetch failed against all api_urls'))
1387
- else
1388
- sync_evaluator_env_id!
1389
- record_refresh!
1484
+ loop do
1485
+ return if @stopped
1486
+
1487
+ attempt += 1
1488
+ cycle = nil
1489
+ cycle_log = @config_loader.new_cycle_log
1490
+ leg_errors = Concurrent::Map.new
1491
+ begin
1492
+ if waiting || one_shot
1493
+ cycle = @config_loader.fetch_cycle!(cycle_log: cycle_log, leg_errors: leg_errors)
1494
+ else
1495
+ # Never 0: Timeout.timeout(0) means "no timeout".
1496
+ budget = [deadline - monotonic_now, 0.001].max
1497
+ Timeout.timeout(budget) { cycle = @config_loader.fetch_cycle!(cycle_log: cycle_log, leg_errors: leg_errors) }
1498
+ end
1499
+ rescue Timeout::Error
1500
+ # An attempt was still in flight at init_timeout_ms. Its legs run on
1501
+ # until the hedge abort; their late lines go to DEBUG. Keep the leg
1502
+ # errors already known: a leg that failed fast means the attempt did
1503
+ # not hang, so the reason is :unavailable (decision 7).
1504
+ cycle_log.silence!
1505
+ errs = format_leg_errors(urls, leg_errors, in_flight: true)
1506
+ return init_timed_out(mode, timeout_s, errs, hung: attempt_hung?(urls, leg_errors))
1507
+ end
1508
+
1509
+ if cycle.result != :failed
1510
+ log_init_recovered(start, attempt) if attempt > 1
1511
+ sync_evaluator_env_id!
1512
+ record_refresh!
1513
+ return
1514
+ end
1515
+
1516
+ errs = format_leg_errors(urls, cycle.leg_errors)
1517
+ last_hung = attempt_hung?(urls, cycle.leg_errors)
1518
+ now = monotonic_now
1519
+ if cycle.all_legs_unauthorized?(urls.length)
1520
+ # Every leg is auth-class here (decision 6); report the primary's.
1521
+ status = cycle.primary_status
1522
+ legs = leg_names_phrase(urls.length)
1523
+ if waiting
1524
+ LOG.error "[quonfig] init: SDK key rejected (#{status}) by #{legs}. Check the key and " \
1525
+ "environment. onInitFailure=wait: still retrying. #{errs}", event: 'quonfig.init.unauthorized'
1526
+ else
1527
+ msg = "[quonfig] init FAILED: SDK key rejected (#{status}) by #{legs}. Check the key " \
1528
+ "and environment. onInitFailure=#{mode}: " \
1529
+ "#{mode == Quonfig::Options::ON_INITIALIZATION_FAILURE::FAIL ? 'raising now' : 'starting on code defaults now'}."
1530
+ LOG.error msg, event: 'quonfig.init.unauthorized'
1531
+ return init_continue_on_defaults(errs) if mode == Quonfig::Options::ON_INITIALIZATION_FAILURE::CONTINUE
1532
+
1533
+ raise Quonfig::Errors::InitializationError.new(
1534
+ "Quonfig init failed: SDK key rejected (#{status}) by #{legs}. #{errs}",
1535
+ reason: :unauthorized, errors: errs
1536
+ )
1537
+ end
1538
+ elsif attempt == 1 && !one_shot
1539
+ LOG.warn "[quonfig] init: config not loaded yet, retrying until initTimeout (#{format_secs(timeout_s)}). #{errs}",
1540
+ event: 'quonfig.init.retrying'
1541
+ end
1542
+
1543
+ if !waiting && now >= deadline
1544
+ # A one-shot attempt reports how long it actually took.
1545
+ elapsed_s = one_shot ? now - start : timeout_s
1546
+ return init_timed_out(mode, elapsed_s, errs, hung: last_hung)
1547
+ end
1548
+
1549
+ report.call(now)
1550
+
1551
+ # Decision 10 (sdk-go's rule): a new attempt starts whenever time
1552
+ # remains before the deadline, however little. If the deadline cuts it
1553
+ # off, it is the last attempt and decides the reason (the rescue
1554
+ # above); if the deadline lands in this backoff, this attempt decides.
1555
+ wake = now + init_backoff_s(attempt)
1556
+ wake = deadline if !waiting && wake > deadline
1557
+ wake_points = -> { [next_progress, (deadline if waiting && !timed_out_logged)].compact }
1558
+ init_sleep_until(wake, wake_points) { |t| report.call(t) }
1559
+ return init_timed_out(mode, timeout_s, errs, hung: last_hung) if !waiting && monotonic_now >= deadline
1560
+ end
1561
+ end
1562
+
1563
+ # Sleep until +wake+, waking early at each of +wake_points+ (re-read every
1564
+ # time) to yield the current time, so progress lines keep their cadence
1565
+ # through a long backoff. Returns early once the client is stopped.
1566
+ def init_sleep_until(wake, wake_points)
1567
+ loop do
1568
+ return if @stopped
1569
+
1570
+ now = monotonic_now
1571
+ return if now >= wake
1572
+
1573
+ nxt = (wake_points.call.select { |t| t > now } << wake).min
1574
+ sleep(nxt - now)
1575
+ yield monotonic_now
1390
1576
  end
1391
1577
  end
1392
1578
 
1579
+ # Seconds between "still waiting for config" lines before the timeout.
1580
+ def init_progress_every_s
1581
+ INIT_PROGRESS_EVERY_S
1582
+ end
1583
+
1584
+ # "primary", "primary and secondary", "primary, secondary and url2".
1585
+ def leg_names_phrase(count)
1586
+ names = Array.new([count, 1].max) { |i| leg_label(i) }
1587
+ names.length == 1 ? names.first : "#{names[0..-2].join(', ')} and #{names.last}"
1588
+ end
1589
+
1590
+ def leg_label(index)
1591
+ case index
1592
+ when 0 then 'primary'
1593
+ when 1 then 'secondary'
1594
+ else "url#{index}"
1595
+ end
1596
+ end
1597
+
1598
+ # 0.5s, 1s, 2s, 4s ... capped at 30s, jittered +/-20%.
1599
+ def init_backoff_s(attempt)
1600
+ base = [0.5 * (2**(attempt - 1)), INIT_BACKOFF_CAP_S].min
1601
+ base * (0.8 + (rand * 0.4))
1602
+ end
1603
+
1604
+ def init_timed_out(mode, timeout_s, errs, hung:)
1605
+ elapsed = format('%.1f', timeout_s)
1606
+ if mode == Quonfig::Options::ON_INITIALIZATION_FAILURE::CONTINUE
1607
+ LOG.error "[quonfig] init: no config after #{elapsed}s. onInitFailure=continue: STARTING ON CODE DEFAULTS. " \
1608
+ 'This instance may disagree with the rest of your fleet. Retrying in background.',
1609
+ event: 'quonfig.init.continue'
1610
+ return init_continue_on_defaults(errs)
1611
+ end
1612
+
1613
+ LOG.error "[quonfig] init FAILED: no config after #{elapsed}s. #{errs}. onInitFailure=fail: raising " \
1614
+ 'Quonfig::Errors::InitializationError. Options: on_init_failure: :wait to keep waiting, ' \
1615
+ 'on_init_failure: :continue to start on code defaults, or raise init_timeout_ms.',
1616
+ event: 'quonfig.init.failed'
1617
+ message = "Quonfig init failed: no config after #{elapsed}s. #{errs}"
1618
+ raise Quonfig::Errors::InitializationTimeoutError.new(timeout_s, nil, message: message, errors: errs) if hung
1619
+
1620
+ raise Quonfig::Errors::InitializationError.new(message, reason: :unavailable, errors: errs)
1621
+ end
1622
+
1623
+ # :continue over an empty store. The update channel the caller starts next
1624
+ # keeps retrying; #record_refresh! logs the heal, and the not-ready reporter
1625
+ # logs the outage on the section C cadence until then.
1626
+ def init_continue_on_defaults(errs)
1627
+ @state_mutex.synchronize do
1628
+ @init_heal_pending = true
1629
+ @init_last_errs = errs
1630
+ end
1631
+ start_not_ready_reporter
1632
+ nil
1633
+ end
1634
+
1635
+ # WARN every 30s for 5 minutes, then every 5 minutes, while a :continue
1636
+ # client has no config (section C, "Background retry while not ready").
1637
+ def start_not_ready_reporter
1638
+ return if @not_ready_reporter&.alive?
1639
+
1640
+ @not_ready_reporter = Thread.new do
1641
+ Thread.current.name = 'quonfig-not-ready-reporter' if Thread.current.respond_to?(:name=)
1642
+ loop do
1643
+ elapsed = monotonic_now - @init_started_at
1644
+ sleep(elapsed < 300 ? 30 : 300)
1645
+ break if @stopped || ready?
1646
+
1647
+ since = (monotonic_now - @init_started_at).round
1648
+ errs = @state_mutex.synchronize { @init_last_errs }
1649
+ LOG.warn "[quonfig] still not connected to Quonfig delivery (#{since / 60}m#{since % 60}s since start); " \
1650
+ "serving defaults. #{errs}", event: 'quonfig.init.not_connected'
1651
+ end
1652
+ rescue StandardError
1653
+ nil
1654
+ end
1655
+ end
1656
+
1657
+ def log_init_recovered(start, _attempt = nil)
1658
+ via = @config_loader.respond_to?(:resolved_from) ? @config_loader.resolved_from : ''
1659
+ # A stream install leaves resolved_from empty: name the SSE leg.
1660
+ via = sse_failed_over_to_secondary? ? leg_label(1) : leg_label(0) if via.to_s.empty?
1661
+ version = @config_loader.respond_to?(:version) ? @config_loader.version : nil
1662
+ LOG.warn "[quonfig] init recovered: config loaded after #{format('%.1f', monotonic_now - start)}s via #{via}" \
1663
+ "#{" (version #{version})" if version}. Now serving live config.", event: 'quonfig.init.recovered'
1664
+ end
1665
+
1666
+ # Whether an attempt hung (decision 7): no leg failed fast. Every leg that
1667
+ # settled ended on a timeout, and a leg with no error yet was still in
1668
+ # flight when the init deadline cut the attempt off.
1669
+ def attempt_hung?(urls, leg_errors)
1670
+ urls.each_index.all? do |i|
1671
+ err = leg_errors[i]
1672
+ err.nil? || err == Quonfig::ConfigLoader::LEG_TIMED_OUT
1673
+ end
1674
+ end
1675
+
1676
+ # primary=<host>: <error>; secondary=<host>: <error>. Hosts only, never the
1677
+ # SDK key or URL credentials.
1678
+ def format_leg_errors(urls, leg_errors, in_flight: false)
1679
+ urls.each_with_index.map do |url, i|
1680
+ label = leg_label(i)
1681
+ err = leg_errors[i] || (in_flight ? 'no response before initTimeout' : 'no response')
1682
+ "#{label}=#{safe_host(url)}: #{err}"
1683
+ end.join('; ')
1684
+ end
1685
+
1686
+ def safe_host(url)
1687
+ uri = URI.parse(url.to_s)
1688
+ uri.host ? "#{uri.host}:#{uri.port}" : '?'
1689
+ rescue URI::InvalidURIError
1690
+ '?'
1691
+ end
1692
+
1693
+ # "10s" for whole seconds, "0.5s" otherwise.
1694
+ def format_secs(secs)
1695
+ secs == secs.to_i ? "#{secs.to_i}s" : "#{secs}s"
1696
+ end
1697
+
1698
+ def monotonic_now
1699
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1700
+ end
1701
+
1393
1702
  # qfg-pinh: In SDK-key DELIVERY mode the server's `meta.environment` is
1394
1703
  # AUTHORITATIVE. The server scopes each config to a single environment and
1395
1704
  # reports the active env id in `meta.environment` (the loader captures it
@@ -1431,6 +1740,26 @@ module Quonfig
1431
1740
  )
1432
1741
  end
1433
1742
 
1743
+ DEPRECATED_INIT_FAILURE_WARNED = Set.new
1744
+ DEPRECATED_INIT_FAILURE_MUTEX = Mutex.new
1745
+ private_constant :DEPRECATED_INIT_FAILURE_WARNED, :DEPRECATED_INIT_FAILURE_MUTEX
1746
+
1747
+ # One WARN per process per deprecated spelling (section C), and a WARN for
1748
+ # an unrecognized value, which runs as :fail.
1749
+ def warn_on_init_failure_spelling(options)
1750
+ if (bad = options.on_init_failure_unknown)
1751
+ LOG.warn "[quonfig] unknown on_init_failure #{bad.inspect}; using :fail (expected :fail, :wait or :continue)"
1752
+ end
1753
+ old = options.on_init_failure_alias
1754
+ return if old.nil?
1755
+
1756
+ first = DEPRECATED_INIT_FAILURE_MUTEX.synchronize { DEPRECATED_INIT_FAILURE_WARNED.add?(old) }
1757
+ return unless first
1758
+
1759
+ LOG.warn "[quonfig] on_init_failure=:#{old} is deprecated; use :#{options.on_init_failure}",
1760
+ event: 'quonfig.init.deprecated_alias'
1761
+ end
1762
+
1434
1763
  # qfg-7h5d.1.14: the per-leg hedge abort MUST be < init_timeout_ms, otherwise
1435
1764
  # the init-path heal leg is clipped by the overall init deadline before it can
1436
1765
  # heal forward. Mirrors sdk-go's construction-time warning in options.go. Warn
@@ -1445,6 +1774,8 @@ module Quonfig
1445
1774
  abort_ms = @options.config_fetch_hedge_abort_ms
1446
1775
  init_ms = @options.init_timeout_ms
1447
1776
  return if abort_ms.nil? || init_ms.nil?
1777
+ # <= 0 runs one attempt to completion; no init deadline clips its legs.
1778
+ return if init_ms <= 0
1448
1779
  return if init_ms > abort_ms
1449
1780
 
1450
1781
  LOG.warn(
@@ -1476,15 +1807,6 @@ module Quonfig
1476
1807
  )
1477
1808
  end
1478
1809
 
1479
- def handle_init_failure(err)
1480
- if @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
1481
- LOG.warn "[quonfig] Initialization did not complete cleanly; continuing with empty store: #{err.message}"
1482
- return
1483
- end
1484
-
1485
- raise err
1486
- end
1487
-
1488
1810
  # Returns true if SSE started successfully, false otherwise. A false here
1489
1811
  # signals the caller to fall back to polling.
1490
1812
  def start_sse
@@ -1762,6 +2084,25 @@ module Quonfig
1762
2084
  nil
1763
2085
  end
1764
2086
 
2087
+ # A delivery-mode client that has never installed config (init continued
2088
+ # on code defaults, or the caller caught a failed init): the *_details
2089
+ # getters answer ERROR / PROVIDER_NOT_READY (init-failure design B.3)
2090
+ # rather than claiming the key does not exist.
2091
+ def provider_not_ready?
2092
+ @network_mode && !ready?
2093
+ end
2094
+
2095
+ def provider_not_ready_details
2096
+ Quonfig::EvaluationDetails.new(
2097
+ value: nil,
2098
+ reason: Quonfig::EvaluationDetails::REASON_ERROR,
2099
+ error_code: Quonfig::EvaluationDetails::ERROR_PROVIDER_NOT_READY,
2100
+ error_message: 'Quonfig has not loaded config yet',
2101
+ variant: build_variant(Quonfig::EvaluationDetails::REASON_ERROR, nil, nil),
2102
+ flag_metadata: build_flag_metadata(nil, nil, nil, nil, nil)
2103
+ )
2104
+ end
2105
+
1765
2106
  # Build a Quonfig::EvaluationDetails for +key+, evaluated against the
1766
2107
  # caller's context, after coercing/checking +expected_type+. Never
1767
2108
  # raises; all exceptions become ERROR details.
@@ -1770,6 +2111,7 @@ module Quonfig
1770
2111
  jit = context == NO_DEFAULT_PROVIDED ? nil : context
1771
2112
  ctx = build_context(jit)
1772
2113
  record_context_for_telemetry(ctx)
2114
+ return provider_not_ready_details if provider_not_ready?
1773
2115
 
1774
2116
  result =
1775
2117
  begin
@@ -24,17 +24,54 @@ module Quonfig
24
24
  # poller then enters auth-failed (Client#start_polling).
25
25
  AUTH_STATUSES = [401, 403, 404, 410].freeze
26
26
 
27
+ # The leg error recorded when a leg ended on a timeout (its per-leg
28
+ # deadline or the hedge abort). The init loop reads it to tell a hung
29
+ # attempt from one that failed fast (init-failure design, decision 7).
30
+ LEG_TIMED_OUT = 'timed out'
31
+
32
+ # host:port of +url+ (scheme kept when present), never its userinfo, path
33
+ # or query. For log lines and leg errors (init-failure design, decision 9).
34
+ def self.display_url(url)
35
+ return url.to_s unless url.is_a?(String) || url.is_a?(URI::Generic)
36
+
37
+ uri = URI.parse(url.to_s)
38
+ return redact_userinfo(url.to_s) unless uri.host
39
+
40
+ "#{"#{uri.scheme}://" if uri.scheme}#{uri.host}:#{uri.port}"
41
+ rescue URI::InvalidURIError
42
+ redact_userinfo(url.to_s)
43
+ end
44
+
45
+ # Strip "user:pass@" from every URL in +text+.
46
+ def self.redact_userinfo(text)
47
+ text.to_s.gsub(%r{(//)[^/@\s]+@}, '\\1')
48
+ end
49
+
27
50
  # Result of one fetch cycle across the configured legs. +statuses+ maps a
28
51
  # config_api_urls index to the HTTP status that leg returned (0 = no
29
52
  # response: timeout, connection failure). +primary_host+ is host:port of
30
53
  # the primary leg, for the entry ERROR (never the key).
31
54
  class FetchCycle
32
- attr_reader :result, :statuses, :primary_host
55
+ attr_reader :result, :statuses, :primary_host, :leg_errors
33
56
 
34
- def initialize(result, statuses, primary_host)
57
+ # +leg_errors+ maps a config_api_urls index to a one-line description of
58
+ # why that leg failed (host-free, key-free), for the init log contract.
59
+ def initialize(result, statuses, primary_host, leg_errors = {})
35
60
  @result = result
36
61
  @statuses = statuses
37
62
  @primary_host = primary_host
63
+ @leg_errors = leg_errors
64
+ end
65
+
66
+ # Every leg answered and every answer was in the auth class (401/403,
67
+ # plus 404/410 per rule R1): the key is rejected (init-failure design
68
+ # B.3, "401/403 from both legs"). Stricter than #auth_rejected? (the
69
+ # poller's rule R2, primary leg only), because init short-circuits on it.
70
+ def all_legs_unauthorized?(leg_count)
71
+ return false unless @result == :failed
72
+
73
+ values = @statuses.values
74
+ values.size >= leg_count && values.all? { |s| AUTH_STATUSES.include?(s) }
38
75
  end
39
76
 
40
77
  def primary_status
@@ -66,8 +103,16 @@ module Quonfig
66
103
  @mutex = Mutex.new
67
104
  end
68
105
 
106
+ # Send this cycle's remaining lines to DEBUG. The init loop calls it when
107
+ # it gives up on an attempt that is still in flight: its legs keep running
108
+ # on their own threads up to the hedge abort, and their late failures are
109
+ # already summarized in the init ERROR (qfg-9p4i.10).
110
+ def silence!
111
+ @silenced = true
112
+ end
113
+
69
114
  def log(level, msg, auth: false)
70
- return @logger.debug(msg) if @mode == :probe
115
+ return @logger.debug(msg) if @mode == :probe || @silenced
71
116
 
72
117
  if @mode == :poll && auth
73
118
  hold = @mutex.synchronize do
@@ -208,27 +253,39 @@ module Quonfig
208
253
  # leg's status (rule R2, qfg-goi1.10.26). +log_mode+ is one of CycleLog's
209
254
  # modes; the poller passes :poll for an ordinary cycle and :probe while
210
255
  # auth-failed.
211
- def fetch_cycle!(log_mode: :normal)
256
+ #
257
+ # +leg_errors+ lets a caller pass in the map the legs record their errors
258
+ # into, so it can still read them if it abandons the cycle mid-flight (the
259
+ # init loop at init_timeout_ms).
260
+ def fetch_cycle!(log_mode: :normal, cycle_log: nil, leg_errors: nil)
212
261
  urls = Array(@options.config_api_urls)
213
262
  statuses = Concurrent::Map.new
214
- return FetchCycle.new(:failed, statuses, '') if urls.empty?
263
+ leg_errors ||= Concurrent::Map.new
264
+ return FetchCycle.new(:failed, statuses, '', leg_errors) if urls.empty?
215
265
 
216
- cycle_log = CycleLog.new(@logger, log_mode)
266
+ cycle_log ||= new_cycle_log(log_mode)
217
267
  # Single leg (or no secondary configured): no hedge, just fetch on the
218
268
  # calling thread under the SEQUENTIAL per-URL timeout (config_fetch_timeout_ms
219
269
  # is unchanged and still governs any non-hedged path). Preserves the
220
270
  # synchronous, single-request-per-call shape the legacy/mock callers depend
221
271
  # on.
222
272
  result = if urls.length < 2
223
- fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses, cycle_log: cycle_log)
273
+ fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses,
274
+ cycle_log: cycle_log, leg_errors: leg_errors)
224
275
  else
225
- fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log)
276
+ fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log, leg_errors: leg_errors)
226
277
  end
227
- cycle = FetchCycle.new(result, statuses, host_of(urls[0]))
278
+ cycle = FetchCycle.new(result, statuses, host_of(urls[0]), leg_errors)
228
279
  cycle_log.release(drop: cycle.auth_rejected?)
229
280
  cycle
230
281
  end
231
282
 
283
+ # A CycleLog over this loader's logger, for a caller that wants to hold on
284
+ # to it (see CycleLog#silence!).
285
+ def new_cycle_log(mode = :normal)
286
+ CycleLog.new(@logger, mode)
287
+ end
288
+
232
289
  # Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
233
290
  # when a new event arrives. SSE is a single-leg live stream, so it carries no
234
291
  # config_api_urls index and does not change #resolved_from — but it IS
@@ -265,7 +322,7 @@ module Quonfig
265
322
  # the hedge abort inside fetch_from, and heals forward through the
266
323
  # reject-older guard. We never join the slow leg — a hung primary must not
267
324
  # block a successful secondary install.
268
- def fetch_hedged(urls, statuses: nil, cycle_log: nil)
325
+ def fetch_hedged(urls, statuses: nil, cycle_log: nil, leg_errors: nil)
269
326
  hedge_delay_s = hedge_delay_ms / 1000.0
270
327
  abort_ms = hedge_abort_ms
271
328
 
@@ -293,7 +350,8 @@ module Quonfig
293
350
  Thread.new do
294
351
  result = :failed
295
352
  begin
296
- result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log)
353
+ result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log,
354
+ leg_errors: leg_errors)
297
355
  rescue StandardError => e
298
356
  @logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
299
357
  ensure
@@ -402,8 +460,10 @@ module Quonfig
402
460
  ''
403
461
  end
404
462
 
405
- def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil)
463
+ def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil, leg_errors: nil)
406
464
  cycle_log ||= CycleLog.new(@logger, :normal)
465
+ where = self.class.display_url(source)
466
+ note = ->(msg) { leg_errors[index || 0] = self.class.redact_userinfo(msg)[0, 200] if leg_errors }
407
467
  # qfg-7h5d.1.9 / .1.14: bound this single per-leg attempt so a hung upstream
408
468
  # aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
409
469
  # path the caller passes config_fetch_hedge_abort_ms; on the sequential /
@@ -432,35 +492,59 @@ module Quonfig
432
492
  # an equal/older payload — surface that so the caller doesn't double-count.
433
493
  result == :not_modified ? :not_modified : :updated
434
494
  when 304
435
- @logger.debug "Configs not modified (304) from #{source}"
495
+ @logger.debug "Configs not modified (304) from #{where}"
436
496
  :not_modified
437
497
  when 401, 403
438
- cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}", auth: true)
498
+ note.call("HTTP #{response.status} (SDK key rejected)")
499
+ # Never the response body: it is upstream content (decision 9).
500
+ cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{where}", auth: true)
439
501
  :failed
440
502
  else
441
- cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{source}",
503
+ note.call("HTTP #{response.status}")
504
+ cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{where}",
442
505
  auth: AUTH_STATUSES.include?(response.status.to_i))
443
506
  :failed
444
507
  end
445
508
  rescue NonEnvelopeError => e
509
+ note.call("non-envelope 200 (#{e.message})")
446
510
  # qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
447
511
  # hedge/failover proceeds and this leg's ETag is never stored (so a junk
448
512
  # 200 cannot pin itself through later 304s).
449
- cycle_log.log(:warn, "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
513
+ cycle_log.log(:warn, "Config fetch from #{where} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
450
514
  :failed
451
515
  rescue Quonfig::ConfigShape::AllInvalidError => e
516
+ note.call(e.message)
452
517
  # qfg-krjt: a payload with no loadable config is a leg error, so it can
453
518
  # never install as an empty workspace.
454
- cycle_log.log(:warn, "Config fetch from #{source}: #{e.message}; treating as a failed leg")
519
+ cycle_log.log(:warn, "Config fetch from #{where}: #{e.message}; treating as a failed leg")
520
+ :failed
521
+ rescue JSON::ParserError
522
+ # The parser's message quotes the body; never pass it on (decision 9).
523
+ note.call('non-JSON 200 body')
524
+ cycle_log.log(:warn, "Config fetch from #{where} returned a non-JSON 200; treating as a failed leg")
525
+ :failed
526
+ rescue Faraday::TimeoutError
527
+ note.call(LEG_TIMED_OUT)
528
+ cycle_log.log(:warn, "Timeout error fetching configs from #{where}")
455
529
  :failed
456
530
  rescue Faraday::ConnectionFailed => e
457
- @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
531
+ # The Net::HTTP adapter wraps a connect timeout (Net::OpenTimeout) in
532
+ # ConnectionFailed. That is a hang, not a fast failure (decision 7).
533
+ note.call(connect_timeout?(e) ? LEG_TIMED_OUT : e.message)
534
+ @logger.debug "Connection failure fetching configs from #{where}: #{self.class.redact_userinfo(e.message)}"
458
535
  :failed
459
536
  rescue StandardError => e
460
- cycle_log.log(:warn, "Unexpected error fetching configs from #{source}: #{e.message}")
537
+ note.call("#{e.class}: #{e.message}")
538
+ cycle_log.log(:warn, "Unexpected error fetching configs from #{where}: #{self.class.redact_userinfo(e.message)}")
461
539
  :failed
462
540
  end
463
541
 
542
+ def connect_timeout?(err)
543
+ cause = err.respond_to?(:wrapped_exception) ? err.wrapped_exception : nil
544
+ cause ||= err.cause
545
+ cause.is_a?(Timeout::Error) || cause.is_a?(Errno::ETIMEDOUT)
546
+ end
547
+
464
548
  def etag_for(index)
465
549
  @etag_mutex.synchronize { @etags[index || 0] }
466
550
  end
@@ -484,13 +568,6 @@ module Quonfig
484
568
  Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
485
569
  end
486
570
 
487
- def short_body(response)
488
- return '' if response.body.nil?
489
-
490
- str = response.body.to_s
491
- str.length > 200 ? "#{str[0, 200]}..." : str
492
- end
493
-
494
571
  def install_envelope(envelope, source:, source_index: nil)
495
572
  meta = envelope.meta || {}
496
573
  incoming_gen = extract_generation(meta)
@@ -523,12 +600,12 @@ module Quonfig
523
600
  # Unversioned payload while a real generation is held: not provably
524
601
  # older (it carries no ordering info), so a silent no-op — NOT
525
602
  # counted as guardRejected.
526
- @logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{source})"
603
+ @logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{self.class.display_url(source)})"
527
604
  return :not_modified
528
605
  end
529
606
 
530
607
  if incoming_gen < @held_generation
531
- @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{source})"
608
+ @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{self.class.display_url(source)})"
532
609
  # Failover observability (qfg-41nh.18): count the guard rejection.
533
610
  # This single guard covers BOTH the HTTP config-fetch path and the
534
611
  # SSE message path (apply_envelope) — every network install funnels
@@ -548,7 +625,7 @@ module Quonfig
548
625
  # `guardRejected` made a healthy client report failover activity from
549
626
  # init alone. Not installed, not counted — but still :not_modified, so
550
627
  # the caller's liveness stamp is unchanged.
551
- @logger.debug "Same-generation re-delivery: ignoring incoming generation #{incoming_gen} (source=#{source})"
628
+ @logger.debug "Same-generation re-delivery: ignoring incoming generation #{incoming_gen} (source=#{self.class.display_url(source)})"
552
629
  end
553
630
  return :not_modified
554
631
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Errors
5
+ # Raised by Client.new under on_init_failure: :fail (the default) when no
6
+ # config could be loaded (qfg-9p4i.10; init-failure design B.4). The base
7
+ # of the init-failure family: rescue this to catch every init failure.
8
+ #
9
+ # +reason+ is one of:
10
+ # - +:unavailable+ - every attempt failed fast (refused, DNS, 5xx) until
11
+ # init_timeout_ms ran out
12
+ # - +:unauthorized+ - every delivery leg rejected the SDK key (401/403);
13
+ # raised at once, without waiting out the timeout
14
+ # - +:timeout+ - an attempt was still in flight at init_timeout_ms
15
+ # (InitializationTimeoutError)
16
+ #
17
+ # +errors+ is the per-leg summary (+primary=<host>: <error>; ...+). It names
18
+ # hosts only, never the SDK key.
19
+ class InitializationError < Quonfig::Error
20
+ REASONS = %i[unavailable unauthorized timeout].freeze
21
+
22
+ attr_reader :reason, :errors
23
+
24
+ def initialize(message = 'Quonfig failed to initialize', reason: :unavailable, errors: nil)
25
+ @reason = reason
26
+ @errors = errors
27
+ super(message)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -2,10 +2,15 @@
2
2
 
3
3
  module Quonfig
4
4
  module Errors
5
- class InitializationTimeoutError < Quonfig::Error
6
- def initialize(timeout_sec, key)
7
- message = "Quonfig couldn't initialize in #{timeout_sec} second timeout. Trying to fetch key `#{key}`."
8
- super(message)
5
+ # The init-failure error for a hang: an attempt was still in flight when
6
+ # init_timeout_ms ran out. A subclass of InitializationError (reason
7
+ # +:timeout+), so rescuing either class catches it.
8
+ class InitializationTimeoutError < InitializationError
9
+ # +timeout_sec+ and +key+ keep the original positional signature.
10
+ def initialize(timeout_sec, key = nil, message: nil, errors: nil)
11
+ message ||= "Quonfig couldn't initialize in #{timeout_sec} second timeout."
12
+ message += " Trying to fetch key `#{key}`." if key
13
+ super(message, reason: :timeout, errors: errors)
9
14
  end
10
15
  end
11
16
  end
@@ -16,6 +16,8 @@ module Quonfig
16
16
  # +error_code+ (only when reason == "ERROR") is one of:
17
17
  # "FLAG_NOT_FOUND" — the key was unknown to the store
18
18
  # "TYPE_MISMATCH" — the resolved value didn't satisfy the requested type
19
+ # "PROVIDER_NOT_READY" — the client has no config yet (init continued
20
+ # on code defaults, or a caught init failure)
19
21
  # "GENERAL" — any other failure
20
22
  class EvaluationDetails
21
23
  REASON_STATIC = 'STATIC'
@@ -27,6 +29,7 @@ module Quonfig
27
29
  ERROR_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'
28
30
  ERROR_TYPE_MISMATCH = 'TYPE_MISMATCH'
29
31
  ERROR_GENERAL = 'GENERAL'
32
+ ERROR_PROVIDER_NOT_READY = 'PROVIDER_NOT_READY'
30
33
 
31
34
  attr_reader :value, :reason, :error_code, :error_message, :variant, :flag_metadata
32
35
 
@@ -90,7 +90,7 @@ module Quonfig
90
90
 
91
91
  deadline_s = (@timeout_ms / 1000.0) + WALL_CLOCK_HEADROOM_S
92
92
  Timeout.timeout(deadline_s, Faraday::TimeoutError,
93
- "wall-clock per-request deadline #{deadline_s}s exceeded for #{@uri}") do
93
+ "wall-clock per-request deadline #{deadline_s}s exceeded for #{Quonfig::ConfigLoader.redact_userinfo(@uri)}") do
94
94
  block.call
95
95
  end
96
96
  end
@@ -34,28 +34,28 @@ module Quonfig
34
34
  end
35
35
 
36
36
  # Log methods
37
- def trace(message = nil, &block)
38
- log_message(:trace, message, &block)
37
+ def trace(message = nil, event: nil, &block)
38
+ log_message(:trace, message, event: event, &block)
39
39
  end
40
40
 
41
- def debug(message = nil, &block)
42
- log_message(:debug, message, &block)
41
+ def debug(message = nil, event: nil, &block)
42
+ log_message(:debug, message, event: event, &block)
43
43
  end
44
44
 
45
- def info(message = nil, &block)
46
- log_message(:info, message, &block)
45
+ def info(message = nil, event: nil, &block)
46
+ log_message(:info, message, event: event, &block)
47
47
  end
48
48
 
49
- def warn(message = nil, &block)
50
- log_message(:warn, message, &block)
49
+ def warn(message = nil, event: nil, &block)
50
+ log_message(:warn, message, event: event, &block)
51
51
  end
52
52
 
53
- def error(message = nil, &block)
54
- log_message(:error, message, &block)
53
+ def error(message = nil, event: nil, &block)
54
+ log_message(:error, message, event: event, &block)
55
55
  end
56
56
 
57
- def fatal(message = nil, &block)
58
- log_message(:fatal, message, &block)
57
+ def fatal(message = nil, event: nil, &block)
58
+ log_message(:fatal, message, event: event, &block)
59
59
  end
60
60
 
61
61
  def level
@@ -172,7 +172,10 @@ module Quonfig
172
172
  level_str&.downcase&.to_sym
173
173
  end
174
174
 
175
- def log_message(level, message, &block)
175
+ # +event+ (e.g. 'quonfig.init.failed') is a structured field: SemanticLogger
176
+ # gets it as the payload ({ event: ... }); plain Logger-shaped sinks only
177
+ # get the message, which already carries the full line.
178
+ def log_message(level, message, event: nil, &block)
176
179
  override = Quonfig::InternalLogger.user_logger
177
180
  if override
178
181
  write_to_user_logger(override, level, message, &block)
@@ -180,7 +183,11 @@ module Quonfig
180
183
  end
181
184
 
182
185
  if @using_semantic
183
- @logger.send(level, message, &block)
186
+ if event
187
+ @logger.send(level, message, { event: event }, &block)
188
+ else
189
+ @logger.send(level, message, &block)
190
+ end
184
191
  else
185
192
  # stdlib Logger doesn't have trace
186
193
  level = :debug if level == :trace
@@ -13,6 +13,11 @@ module Quonfig
13
13
  :telemetry_max_retained_bytes, :telemetry_max_retained_age_ms
14
14
  attr_accessor :is_fork
15
15
 
16
+ # The deprecated on_init_failure spelling passed (:raise / :return), or nil.
17
+ attr_reader :on_init_failure_alias
18
+ # An unrecognized on_init_failure value (treated as :fail), or nil.
19
+ attr_reader :on_init_failure_unknown
20
+
16
21
  # Default fallback poll interval, in milliseconds. The SDK polls api-delivery
17
22
  # at this cadence only when SSE is unavailable for >= 2x this value.
18
23
  DEFAULT_FALLBACK_POLL_INTERVAL_MS = 60_000
@@ -76,9 +81,42 @@ module Quonfig
76
81
  ms == ms.to_i ? ms.to_i : ms
77
82
  end
78
83
 
84
+ # What Client.new does when no config has loaded by +init_timeout_ms+
85
+ # (qfg-9p4i.10; project/plans/2026-10-05-init-failure-design.md B.1-B.3):
86
+ #
87
+ # - +:fail+ (default): raise Quonfig::Errors::InitializationError. Every
88
+ # delivery leg answering 401/403 raises at once, without waiting out the
89
+ # timeout.
90
+ # - +:wait+: keep blocking and retrying with no upper bound.
91
+ # - +:continue+: return a client over an empty store (getters answer with
92
+ # the caller's default) that keeps retrying in the background.
93
+ #
94
+ # +:raise+ and +:return+ are the deprecated earlier spellings of +:fail+ and
95
+ # +:continue+. They are still accepted and log a one-time WARN.
79
96
  module ON_INITIALIZATION_FAILURE
97
+ FAIL = :fail
98
+ WAIT = :wait
99
+ CONTINUE = :continue
100
+ # @deprecated Use FAIL.
80
101
  RAISE = :raise
102
+ # @deprecated Use CONTINUE.
81
103
  RETURN = :return
104
+
105
+ MODES = [FAIL, WAIT, CONTINUE].freeze
106
+ ALIASES = { RAISE => FAIL, RETURN => CONTINUE }.freeze
107
+
108
+ # [canonical mode, deprecated alias given or nil, unknown value or nil].
109
+ # Strings are accepted ('fail', ':raise'). An unknown value resolves to
110
+ # FAIL, the safe default, and is reported so the client can warn.
111
+ def self.resolve(value)
112
+ return [FAIL, nil, nil] if value.nil?
113
+
114
+ sym = value.to_s.delete_prefix(':').to_sym
115
+ return [sym, nil, nil] if MODES.include?(sym)
116
+ return [ALIASES[sym], sym, nil] if ALIASES.key?(sym)
117
+
118
+ [FAIL, nil, value]
119
+ end
82
120
  end
83
121
 
84
122
  module ON_NO_DEFAULT
@@ -217,6 +255,13 @@ module Quonfig
217
255
  private
218
256
 
219
257
  # @!method initialize(options = {})
258
+ # @option options [Integer] :init_timeout_ms (10000)
259
+ # How long +Client.new+ retries the initial config fetch before
260
+ # +:on_init_failure+ decides. A value <= 0 runs exactly one fetch
261
+ # attempt, lets it finish (it is bounded by the per-leg fetch timeouts,
262
+ # not by this option), then applies +:on_init_failure+: +:fail+ raises,
263
+ # +:continue+ starts on code defaults and keeps retrying in the
264
+ # background, +:wait+ keeps retrying. It never means "no deadline".
220
265
  # @option options [Boolean] :data_dir_auto_reload (false)
221
266
  # Datadir mode only. When +true+, the SDK watches the workspace
222
267
  # directory and re-reads the envelope whenever files inside it
@@ -271,7 +316,7 @@ module Quonfig
271
316
  on_no_default: ON_NO_DEFAULT::RAISE,
272
317
  init_timeout_ms: nil,
273
318
  initialization_timeout_sec: nil,
274
- on_init_failure: ON_INITIALIZATION_FAILURE::RAISE,
319
+ on_init_failure: ON_INITIALIZATION_FAILURE::FAIL,
275
320
  config_fetch_timeout_ms: nil,
276
321
  config_fetch_hedge_delay_ms: nil,
277
322
  config_fetch_hedge_abort_ms: nil,
@@ -334,7 +379,10 @@ module Quonfig
334
379
  else
335
380
  DEFAULT_INIT_TIMEOUT_MS
336
381
  end
337
- @on_init_failure = on_init_failure
382
+ # Stored canonical (:fail / :wait / :continue); the deprecated spelling
383
+ # the caller used, if any, is kept for the client's one-time WARN.
384
+ @on_init_failure, @on_init_failure_alias, @on_init_failure_unknown =
385
+ ON_INITIALIZATION_FAILURE.resolve(on_init_failure)
338
386
  # qfg-7h5d.1.9: per-URL config-fetch timeout. nil → DEFAULT_CONFIG_FETCH_TIMEOUT_MS.
339
387
  @config_fetch_timeout_ms = config_fetch_timeout_ms || DEFAULT_CONFIG_FETCH_TIMEOUT_MS
340
388
  # qfg-7h5d.1.14: parallel-failover hedge knobs. nil → defaults. The hedge
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.9.0'
4
+ VERSION = '1.10.0'
5
5
  end
data/lib/quonfig.rb CHANGED
@@ -32,6 +32,7 @@ require 'quonfig/exponential_backoff'
32
32
  require 'quonfig/waits'
33
33
  require 'quonfig/worker_supervisor'
34
34
  require 'quonfig/periodic_sync'
35
+ require 'quonfig/errors/initialization_error'
35
36
  require 'quonfig/errors/initialization_timeout_error'
36
37
  require 'quonfig/errors/invalid_sdk_key_error'
37
38
  require 'quonfig/errors/missing_default_error'
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.9.0
4
+ version: 1.10.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-20 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -101,6 +101,7 @@ files:
101
101
  - lib/quonfig/error.rb
102
102
  - lib/quonfig/errors/decryption_error.rb
103
103
  - lib/quonfig/errors/env_var_parse_error.rb
104
+ - lib/quonfig/errors/initialization_error.rb
104
105
  - lib/quonfig/errors/initialization_timeout_error.rb
105
106
  - lib/quonfig/errors/invalid_environment_error.rb
106
107
  - lib/quonfig/errors/invalid_sdk_key_error.rb