quonfig 1.8.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.
@@ -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
@@ -1090,6 +1142,10 @@ module Quonfig
1090
1142
  @sse_error_callback ||= ->(error) { handle_sse_error(error) }
1091
1143
  end
1092
1144
 
1145
+ def sse_connected_callback
1146
+ @sse_connected_callback ||= -> { handle_sse_state_change(:connected) unless @stopped }
1147
+ end
1148
+
1093
1149
  # Every SSE error, auth-class included, is an ordinary drop: SSE alone
1094
1150
  # never enters auth-failed (rule R2, qfg-goi1.10.26; this reverses the
1095
1151
  # qfg-i5xv latch). The fallback poller engages on its usual schedule and
@@ -1273,10 +1329,8 @@ module Quonfig
1273
1329
  # propagates through the auto-reload path. Records a refresh timestamp.
1274
1330
  # Caller is responsible for firing on_update.
1275
1331
  def apply_datadir_envelope(envelope)
1276
- new_keys = envelope.configs.map { |cfg| cfg['key'] }.compact.to_set
1277
- old_keys = @store.keys.to_set
1278
- (old_keys - new_keys).each { |k| @store.delete(k) }
1279
- envelope.configs.each { |cfg| @store.set(cfg['key'], cfg) }
1332
+ # One atomic swap, like a network install (qfg-i86q).
1333
+ @store.replace_all(envelope.configs.to_h { |cfg| [cfg['key'], cfg] })
1280
1334
  # qfg-pinh: evaluate against the installed envelope's meta.environment,
1281
1335
  # matching sdk-go. In datadir mode the loader stamps meta.environment =
1282
1336
  # the resolved env (the `environment:` pin or QUONFIG_ENVIRONMENT), so
@@ -1336,6 +1390,7 @@ module Quonfig
1336
1390
  warn_if_hedge_abort_exceeds_init_timeout
1337
1391
  warn_if_explicit_api_urls_disables_failover
1338
1392
 
1393
+ @network_mode = true
1339
1394
  # ||=: after a fork the child already built its loader over its fresh
1340
1395
  # store (see #reset_store_in_child!).
1341
1396
  @config_loader ||= Quonfig::ConfigLoader.new(@store, @options, failover_aggregator: @failover_aggregator)
@@ -1365,29 +1420,285 @@ module Quonfig
1365
1420
  start_polling if @options.enable_polling && !sse_started
1366
1421
  end
1367
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).
1368
1441
  def perform_initial_fetch
1369
- timeout = (@options.init_timeout_ms || 10_000) / 1000.0
1370
- 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
1371
1472
 
1372
- begin
1373
- Timeout.timeout(timeout) do
1374
- 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
1375
1481
  end
1376
- rescue Timeout::Error
1377
- handle_init_failure(
1378
- Quonfig::Errors::InitializationTimeoutError.new(timeout, nil)
1379
- )
1380
- return
1381
1482
  end
1382
1483
 
1383
- if result == :failed
1384
- handle_init_failure(RuntimeError.new('Config fetch failed against all api_urls'))
1385
- else
1386
- sync_evaluator_env_id!
1387
- 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
1388
1576
  end
1389
1577
  end
1390
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
+
1391
1702
  # qfg-pinh: In SDK-key DELIVERY mode the server's `meta.environment` is
1392
1703
  # AUTHORITATIVE. The server scopes each config to a single environment and
1393
1704
  # reports the active env id in `meta.environment` (the loader captures it
@@ -1429,6 +1740,26 @@ module Quonfig
1429
1740
  )
1430
1741
  end
1431
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
+
1432
1763
  # qfg-7h5d.1.14: the per-leg hedge abort MUST be < init_timeout_ms, otherwise
1433
1764
  # the init-path heal leg is clipped by the overall init deadline before it can
1434
1765
  # heal forward. Mirrors sdk-go's construction-time warning in options.go. Warn
@@ -1443,6 +1774,8 @@ module Quonfig
1443
1774
  abort_ms = @options.config_fetch_hedge_abort_ms
1444
1775
  init_ms = @options.init_timeout_ms
1445
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
1446
1779
  return if init_ms > abort_ms
1447
1780
 
1448
1781
  LOG.warn(
@@ -1474,15 +1807,6 @@ module Quonfig
1474
1807
  )
1475
1808
  end
1476
1809
 
1477
- def handle_init_failure(err)
1478
- if @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
1479
- LOG.warn "[quonfig] Initialization did not complete cleanly; continuing with empty store: #{err.message}"
1480
- return
1481
- end
1482
-
1483
- raise err
1484
- end
1485
-
1486
1810
  # Returns true if SSE started successfully, false otherwise. A false here
1487
1811
  # signals the caller to fall back to polling.
1488
1812
  def start_sse
@@ -1494,7 +1818,12 @@ module Quonfig
1494
1818
  @config_loader,
1495
1819
  nil,
1496
1820
  nil,
1497
- on_error: sse_error_callback
1821
+ on_error: sse_error_callback,
1822
+ # First-frame rule (qfg-goi1.10.43): connected at the connection's
1823
+ # first valid frame, keepalive included, never on the 200 headers.
1824
+ # Cancels the engage timer / disengages the poller without stamping
1825
+ # last_successful_refresh (only an install or fetch does that).
1826
+ on_connected: sse_connected_callback
1498
1827
  )
1499
1828
  @sse_client.start do |envelope, _event, _source|
1500
1829
  next if @stopped
@@ -1755,6 +2084,25 @@ module Quonfig
1755
2084
  nil
1756
2085
  end
1757
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
+
1758
2106
  # Build a Quonfig::EvaluationDetails for +key+, evaluated against the
1759
2107
  # caller's context, after coercing/checking +expected_type+. Never
1760
2108
  # raises; all exceptions become ERROR details.
@@ -1763,6 +2111,7 @@ module Quonfig
1763
2111
  jit = context == NO_DEFAULT_PROVIDED ? nil : context
1764
2112
  ctx = build_context(jit)
1765
2113
  record_context_for_telemetry(ctx)
2114
+ return provider_not_ready_details if provider_not_ready?
1766
2115
 
1767
2116
  result =
1768
2117
  begin