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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +44 -3
- data/lib/quonfig/client.rb +370 -28
- data/lib/quonfig/config_loader.rb +106 -29
- 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 +1 -1
- data/lib/quonfig/internal_logger.rb +21 -14
- data/lib/quonfig/options.rb +50 -2
- 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: 774d181106fd045eddb916eb177c89744a97f014d7168a4e89d4d51ca2b3d6e2
|
|
4
|
+
data.tar.gz: c34620585b746c0f013ec473870306593e45fd617b62187ebc31a3d44e14d709
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: :
|
|
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
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -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
|
-
|
|
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::
|
|
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 :
|
|
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
|
|
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
|
-
|
|
1372
|
-
|
|
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
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
+
leg_errors ||= Concurrent::Map.new
|
|
264
|
+
return FetchCycle.new(:failed, statuses, '', leg_errors) if urls.empty?
|
|
215
265
|
|
|
216
|
-
cycle_log
|
|
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,
|
|
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 #{
|
|
495
|
+
@logger.debug "Configs not modified (304) from #{where}"
|
|
436
496
|
:not_modified
|
|
437
497
|
when 401, 403
|
|
438
|
-
|
|
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
|
-
|
|
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 #{
|
|
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 #{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/quonfig/options.rb
CHANGED
|
@@ -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::
|
|
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
|
-
|
|
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
|
data/lib/quonfig/version.rb
CHANGED
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.
|
|
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-
|
|
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
|