quonfig 1.7.1 → 1.8.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: 1b22d7d46b27ff2ab1452b41eaa68940d886216aaeb16c61a3faddf0778d352c
4
- data.tar.gz: 0ca1f8e621bcfcd839a28d1d754560b5cf023eff205b24b83049196e64a8e4b0
3
+ metadata.gz: c41b5b972edb95e3718fc1b46f693cf7a1800900e9228c9fd71542b6a775a001
4
+ data.tar.gz: c0d0f5f0c25d44b3d580285d0459a73e829827744c89cb5d8d4ab584fb13bc9a
5
5
  SHA512:
6
- metadata.gz: 720d2acbfe8d30a785c1197fa319d608b1b4e11a1b60657b23d7b3dd921ed0c4882c99e7446b469a009552f09ca54538482509d9dc6a6968dc11df6f0e42d7c2
7
- data.tar.gz: 11a8f0fbb7851dba082a46a7b657e9ec49aa61ae4af155cf9bf2272a35a3e5f75fa8148acdf0f3df8b936798cae5d8619dbc86bb981199f5bf29ceadf5f79567
6
+ metadata.gz: b215ffbd82dc9bc5aef5211a11e8072ab5556637106dc85c65de50652c81849578b4b738d25d0fd4f0113d12ec39ed7aedd0f71bcf461646499e9b50b6001ede
7
+ data.tar.gz: 16836a6666e7e6648baa99f73ad4bf9c491638ad9e31d67a66821040f004ba4382ce6d7532fe40674f40c88ab0ccb467f94641b329e4ac5387590647df5be97f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0 - 2026-10-16
4
+
5
+ - **UPGRADE: retry cadence and log levels change after a rejected 401/403/404/410 fetch cycle; a revoked key logs one config ERROR. `terminal_failure?` changes meaning. Minor (1.8.0): one auth-failure rule across the SDKs (qfg-goi1.10.26). This reverses the qfg-i5xv latch.**
6
+ - **No latch.** Before, the first SSE 401, 403 or 404 stopped the stream for the life of the client and kept the fallback poller off, so a key that was rejected once (or a transient primary 401) left the client on stale config until it was rebuilt. Now an auth-class stream response (401, 403, 404, and now 410) keeps the normal reconnect ladder, and the fallback poller engages on its usual schedule (at once if SSE never connected, 2x the poll interval after a mid-life drop).
7
+ - **Auth-failed.** A poll cycle (the fallback poller's, or each poll with `enable_sse: false`) that gets no 200 or 304 on any leg and a 401/403/404/410 on the primary leg puts the client in auth-failed. Primary rejected + secondary 503 or timeout enters; primary 503 + secondary rejected does not. While auth-failed: no stream, no polling, one fetch probe across every leg every 150-300s (with `enable_sse: false`, the larger of that and the poll interval), with no doubling and no give-up. The first probe that gets a valid 200 or a 304 on any leg installs it, logs one INFO and resumes streaming (from its base backoff) and polling. `connection_state` is `:disconnected` while auth-failed, never `:falling_back`.
8
+ - **Logging.** On entry: one ERROR, `config fetch rejected (HTTP <status> from <host:port>) ...`, naming the status and host, never the key, in place of that cycle's per-leg lines. Probes log at DEBUG. Before entry, an SSE 401/403/404/410 logs one WARN per status (re-armed by a 200) and DEBUG after that; before, a 401/403/404 logged one ERROR and a 410 logged two ERRORs per attempt. Other SSE errors (5xx) log as before.
9
+ - **`terminal_failure?` now means "auth-failed right now".** It turns true when a poll cycle is rejected as above (about 120s after a mid-life stream drop, at once if SSE never connected), not on the first SSE 401, and it goes back to false when a probe is accepted. Code that rebuilt the client when it saw `true` can stop doing so; the client recovers by itself. Name, signature and return type are unchanged.
10
+ - `SSEConfigClient::SSEHTTPTerminalError` and `TERMINAL_HTTP_CODES` are deprecated aliases of the new `SSEHTTPAuthError` and `AUTH_HTTP_CODES` (`[401, 403, 404, 410]`).
11
+ - Tests: supervisor contract Test 8 (a)-(g) (integration-test-data `v2026.10.16`, already contained in the pinned `v2026.10.16.1`) in `test/test_supervisor_auth_failure.rb`, plus the repro8 regression (a revoked key on every leg: one poll cycle, then probes, then recovery).
12
+ - **UPGRADE: may change who matches a rule. Minor: one set of rule-operator semantics across the SDKs (qfg-goi1.5.7).** Pass dates to `PROP_BEFORE` / `PROP_AFTER` rules as epoch milliseconds (an Integer, a Float, or an all-digit string) or as an ISO 8601 / RFC3339 timestamp with `Z` or a numeric offset, such as `2025-01-01T00:00:00Z` or `2025-01-01T09:00:00+09:00`. Three changes:
13
+ - **Dates are strict on both sides.** The context value and the rule's date are now read with one grammar: epoch ms, or RFC3339 with an uppercase `T` and a `Z` or `+hh:mm` / `-hh:mm` offset (fractional seconds allowed, truncated to ms). `DateTime.parse` is gone, so a date-only string (`"2020-01-01"`), an offset-less one (`"2020-01-01T00:00:00"`, which was read as UTC) and free-form text (`"Jan 1 2020"`) are not dates any more, and a rule comparing them does not match. Digit strings no longer accept `Integer()` extras such as `"0x10"` or `"1_000"`. A `Float::NAN` or `Float::INFINITY` context date no longer raises `FloatDomainError`; it does not match.
14
+ - **Whole-number floats compare as plain digits.** In `PROP_IS_ONE_OF` / `PROP_IS_NOT_ONE_OF`, the starts-with / ends-with / contains operators and `HIERARCHICAL_MATCH`, a context value of `1.0`, `1234567.0` or `5e9` now reads as `"1"`, `"1234567"`, `"5000000000"` (it was `"1.0"`, `"1234567.0"`, `"5.0e9"`), so `is one of ["1234567"]` matches it and a list holding `"1.0"` no longer does. Non-integral floats (`1.5`) and Integers are unchanged; booleans still read `"true"` / `"false"`.
15
+ - **Abbreviated versions.** `PROP_SEMVER_*` reads `"2"` as `2.0.0` and `"2.0"` / `"4.1"` as `2.0.0` / `4.1.0`, on the context and the rule side, so an app reporting `"4.1"` now matches a `4.1.0` rule. A prerelease or build suffix still needs the full `x.y.z`, and a `v` prefix is still not a version. `SemanticVersion.parse` itself stays strict.
16
+ Matches integration-test-data `v2026.10.16.1` and the other SDKs.
17
+ - **UPGRADE: may change returned values. Minor: JSON values are returned as a fresh copy on every call (qfg-goi1.10.3).** `get`, `get_json` and `get_json_details` (also on `BoundClient`) used to return the SDK's own Hash or Array for a JSON config. If your code changed the result (for example added a key or appended to a list), every later read in the process saw the change. Each call now returns its own copy of the Hashes and Arrays, so changes stay local. What changes for you: two calls no longer return the same object (`equal?` is false; `==` is still true), and code that relied on mutating the result to change config no longer does. Values are not frozen, so code that mutates its own copy keeps working. Strings are not copied: `get_string` and the strings inside a JSON value are the same objects as before. Matches sdk-go, sdk-net and sdk-java.
18
+ - **CI: unit and release tests now run integration-test-data `v2026.10.16.1` (qfg-goi1.5.7).** It adds the OPERATOR EDGES cases (bool and digit-string one-of, int ranges, prerelease and abbreviated semver, strict dates). The chaos workflows stay on `v2026.10.03`.
19
+ - **Fix (patch): `EnvVarParseError` messages now use the cross-SDK wording (qfg-goi1.10.21).** An ENV_VAR value that cannot be converted to the config's type now raises with `environment variable "<NAME>" cannot be converted to <type> for config "<key>"` (for example `... cannot be converted to int for config "my.key"`), the template pinned by integration-test-data `v2026.10.12`. It was `Evaluating <key> couldn't coerce <NAME> to <type>`. A malformed stored duration reads `stored value cannot be converted to duration for config "<key>"`. The value is still never in the message. Only the message text changes; the exception class and when it is raised are unchanged.
20
+ - **CI: unit, contract and release tests now run integration-test-data `v2026.10.12` (qfg-goi1.10.21).** It adds the coercion-message cases above, confidential weighted-variant and ENV_VAR telemetry redaction cases, and an env-scoped decryption key case. The chaos workflows stay on `v2026.10.03`.
21
+ - **Docs: the README now says that the SDK adds the `qfg login` email to every evaluation as `quonfig-user.email` by default, and how to turn it off (`enable_quonfig_user_context: false` or `QUONFIG_DEV_CONTEXT=false`) (qfg-goi1.2.40).** No code change.
22
+ - **Fix (security, patch): a `confidential` or `decryptWith` variant inside a weighted rollout is now redacted in telemetry (qfg-goi1.2.39).** The evaluation-summary redaction looked only at the outer `weighted_values` wrapper, which is never flagged, so the selected variant went out as `selectedValue` in plaintext (for `decryptWith`, the decrypted secret). The selected variant is now sent as `*****<md5[0,5]>` over its stored value (the ciphertext for `decryptWith`), the same as a non-rollout secret and the same as sdk-go, sdk-node and sdk-java.
23
+ - **Fix (patch): `Client#stdlib_formatter` no longer raises out of the host app's `::Logger` calls (qfg-goi1.2.28).** `Client#get` raises on a resolution error (for example `MissingEnvVarError` when the log level is provided by an env var that is unset on this host, or `DecryptionError`) even when a default is passed, and the formatter let that escape, so every `logger.info` raised. The formatter now emits the line in that case, the same as `SemanticLoggerFilter` since 1.7.2. `Client#should_log?` itself is unchanged.
24
+ - **Fix (patch): example-context telemetry now copies the context when it is recorded (qfg-goi1.2.35).** The SDK kept the caller's nested Hashes and Arrays by reference and serialized them later on the telemetry thread, so changing them after `get` changed what was sent. A `Float::NAN` or `Float::INFINITY`, or a Hash or Array that contains itself, made the whole telemetry window fail to serialize, so that window's evaluation summaries, context shapes and example contexts were all lost. Now, at record time: nested Hashes and Arrays are copied (keys as strings, up to 32 levels deep), strings are copied, symbols become strings, NaN and Infinity become `null`, the edge that closes a cycle is left out, and any other object is sent as its `to_s`, as before (so a UUID-like key object still makes the example reportable). Only a value whose `to_s` raises is left out. Context shapes are unchanged. Evaluation is unchanged.
25
+ - **Tests: the datadir auto-reload burst test no longer sleeps between writes (qfg-goi1.2.29).** A 10ms sleep between five rewrites could outlast the 150ms debounce on a starved CI runner, so the test saw more than one reload (it failed once on Ruby 3.2 CI). The writes are now back to back, and a new filesystem-free test drives `DatadirWatcher` directly: events in one window fire once, a later event fires again, and `stop` cancels a pending fire. No library change.
26
+ - **Fix (patch): an install interrupted part-way through the store write is now repaired by the next delivery of the same envelope (qfg-goi1.10.2).** The SDK recorded the new config generation before it wrote the configs into the store, so an interrupt during the write (for example `Thread#raise` or `Timeout`) left a partly updated store marked as fully installed, and the same-generation redelivery that follows (SSE re-sends the current envelope on every connect) was ignored as a duplicate. The generation now advances only after the store write.
27
+ - **Fix (patch): the SSE line reader no longer rescans its whole buffer on every chunk (qfg-goi1.10.2).** A long SSE line (a large config envelope) delivered in small chunks cost O(n^2) time: 100,000 one-byte chunks took about 9 seconds. Scanning now resumes where the previous chunk stopped, and the consumed bytes are dropped once per chunk instead of once per line. Parsing results are unchanged; there is still no size cap.
28
+
29
+ ## 1.7.2 - 2026-10-09
30
+
31
+ - **Fix (security): `TypeMismatchError` and `EnvVarParseError` messages no longer include the value (qfg-goi1.2.11).** Reading a `confidential` or `decryptWith` config through the wrong typed getter (for example `get_int` on a secret) put the decrypted plaintext into the exception message and into `*_details.error_message`, which end up in logs and error trackers. An unparseable ENV_VAR or a malformed stored duration put the raw value into `EnvVarParseError`. The messages now name the key, the expected type and the actual class (`TypeMismatchError`), or the key, the env var name (or `stored value`) and the target type (`EnvVarParseError`). The doubled word in `expected expected Integer` is fixed. Only the message text changes; the exception classes and when they are raised are unchanged.
32
+ - **Fix: datadir mode and the dev-context loader read their JSON files as UTF-8 whatever the process locale (qfg-goi1.2.11, qfg-xy92).** Under `LANG` unset or `LANG=C` (systemd units, cron, many minimal and distroless images) Ruby's default external encoding is US-ASCII, and any non-ASCII byte in a config file (for example an em dash in a `description`) or in `quonfig.json` made `Client.new` raise `Encoding::InvalidByteSequenceError`, so the app could not boot in datadir mode. The same applied to a non-ASCII email in `~/.quonfig/tokens.json`. These files are now always read as UTF-8.
33
+ - **Fix: `SemanticLoggerFilter` no longer raises out of the host app's log calls (qfg-goi1.2.11).** `Client#get` raises on a resolution error (for example `MissingEnvVarError` when the log level is provided by an env var that is unset on this host, or `DecryptionError`) even when a default is passed, and the filter let that escape, so every log call through SemanticLogger raised. The filter now treats any such error like a missing key: it returns `true` and SemanticLogger's own static level decides.
34
+ - **Docs: the README typed-getter section now matches the code (qfg-goi1.2.11).** It documented `get_string(key, contexts = nil)`, said a missing key or a type mismatch returns `nil`, and said `get_duration` returns `Float` seconds; the examples passed the context as `user: {...}`, which raises `ArgumentError: unknown keyword: :user`. The getters are `get_*(key, default:, context:)`; a missing key returns `default:` or follows `on_no_default` (`:raise` by default); a type mismatch raises `TypeMismatchError`; `get_duration` returns integer milliseconds. No code change.
35
+ - **Fix: concurrent `Quonfig.init` calls build one client (qfg-goi1.2.11).** The "already initialized" check ran outside the write lock, so two threads calling `Quonfig.init` at the same time could each build a `Client`, leaving one orphaned with a live SSE stream and telemetry thread that nothing stopped. The check is now repeated inside the lock; the second caller gets the existing client and the usual "already initialized" warning.
36
+ - **Tests: pin `minitest` below 6 in the Gemfile (qfg-goi1.2.11).** A fresh resolve without `Gemfile.lock` picked minitest 6.0.6, which drops `minitest/mock`, and the suite failed to load. The committed lock already held 5.27.0, so CI was unaffected. No library change.
37
+ - **Fix: `PROP_MATCHES` / `PROP_DOES_NOT_MATCH` regexes have a 0.1s match timeout on Ruby 3.2+ (qfg-goi1.2.11).** A pattern that backtracks catastrophically (backreferences and lookarounds defeat Ruby 3.2's regex memoization) could pin the evaluating thread for seconds to hours. On Ruby 3.2+ the match now stops after 0.1s and the criterion does not match, the same as an invalid regex. Ruby 3.0 and 3.1 have no regex timeout and are unchanged.
38
+ - **Chaos: `server_metric(...)` expectations are now reported as SKIPPED with a reason instead of silently evaluating to 0 and passing (qfg-goi1.1.3).** api-delivery exports its metrics only by OTLP push, so the rig has nothing to scrape; server-side lag is covered by the staging drill (qfg-47c2.19) and the `QuonfigSubscriberLagHigh` alert. Each skipped expectation prints a `SKIP` line with the reason, the scenario summary counts skips separately, and the run ends with a tally of every skipped expression. In a compound expression the skipped leaf is neutral: the other leaves are still enforced (scenario 02 still requires `connected`). No library change.
39
+ - **Chaos: an unknown `client.sdkMetric(...)` name now fails the expectation with `unknown sdkMetric "<name>"` instead of silently evaluating to 0 (qfg-goi1.2.21).** The chaos probe returned 0 for any metric it did not implement, so a typo such as `client.sdkMetric('typo_total') == 0` passed without checking anything. The probe now reports the name as unknown and the evaluator fails loudly, matching sdk-go. Every metric name used by integration-test-data `v2026.10.03` is implemented, so no current result changes. No library change.
40
+ - **CI: the Chaos and Failover Chaos workflows now run integration-test-data `v2026.10.03`, the same tag as the unit and contract tests (qfg-goi1.1.3).** They were on `v2026.05.13` and `v2026.06.19.1`. The new tag adds the continuous freshness hold on scenario 05-sse-down (qfg-e3ja). Both workflows still build api-delivery from `main` on purpose (they exist to catch api-delivery drift) and now write the resolved api-delivery SHA to the run summary. No library change.
41
+
3
42
  ## 1.7.1 - 2026-10-03
4
43
 
5
44
  - **Behaviour change (bug fix): weighted rollouts now hash non-ASCII text the same way as every other Quonfig SDK (qfg-1mvb).** The MurmurHash3 used to pick a weighted variant counted characters instead of UTF-8 bytes, so a config key or hash value containing non-ASCII text (for example `josé`, `日本語`, `🚀`) hashed to a different number than sdk-java, sdk-python, sdk-go and sdk-node. It now hashes the UTF-8 bytes. Ruby users whose hash value (or the flag's key) contains non-ASCII characters may move to a different variant on upgrade; afterwards they get the same variant as in the other SDKs. ASCII-only values and keys are unchanged.
data/README.md CHANGED
@@ -35,8 +35,8 @@ limit = client.get_int('rate-limit')
35
35
  name = client.get_string('app.display-name')
36
36
  regions = client.get_string_list('allowed-regions')
37
37
 
38
- # Context-aware evaluation — pass a context hash as the last argument
39
- value = client.get_string('homepage-hero', user: { key: 'user-123', country: 'US' })
38
+ # Context-aware evaluation — pass a context hash with `context:`
39
+ value = client.get_string('homepage-hero', context: { user: { key: 'user-123', country: 'US' } })
40
40
  ```
41
41
 
42
42
  ## Context
@@ -47,7 +47,7 @@ attach a context in three ways:
47
47
  ### 1. Per-call context
48
48
 
49
49
  ```ruby
50
- client.get_bool('beta-feature', user: { key: 'user-123', plan: 'pro' })
50
+ client.get_bool('beta-feature', context: { user: { key: 'user-123', plan: 'pro' } })
51
51
  ```
52
52
 
53
53
  ### 2. `with_context` block
@@ -82,6 +82,16 @@ bound.get_int('rate-limit')
82
82
  > `in_context` is a deprecated alias of `with_context` kept for backward
83
83
  > compatibility through 1.0.0. New code should use `with_context`.
84
84
 
85
+ ### Developer context (on by default)
86
+
87
+ On a machine where someone ran `qfg login`, the SDK reads the signed-in email
88
+ from `~/.quonfig/tokens.json` and adds it to every evaluation as
89
+ `quonfig-user.email`, so rules can target it and it reaches telemetry example
90
+ contexts; pass `enable_quonfig_user_context: false` or set
91
+ `QUONFIG_DEV_CONTEXT=false` to turn it off. Servers without that file are
92
+ unaffected, and a `quonfig-user` context you pass in `global_context:`
93
+ replaces it.
94
+
85
95
  ## Datadir / offline mode
86
96
 
87
97
  For tests, CI, or air-gapped environments, point the client at a local workspace
@@ -246,6 +256,7 @@ for the cross-SDK story (sdk-node, sdk-go, sdk-ruby, sdk-python, sdk-java).
246
256
  | `QUONFIG_BACKEND_SDK_KEY` | SDK key used to authenticate against the Quonfig API. Used when `sdk_key:` is omitted. |
247
257
  | `QUONFIG_DIR` | Path to a workspace directory. When set, the SDK runs in datadir/offline mode. |
248
258
  | `QUONFIG_ENVIRONMENT` | Environment name (`production`, `staging`, `development`) evaluated in datadir mode. |
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). |
249
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. |
250
261
 
251
262
  ## Constructor options
@@ -279,6 +290,7 @@ Quonfig::Client.new(
279
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). |
280
291
  | `on_no_default` | `Symbol` | `:raise` | Behavior when a key has no value and no default: `:raise` (raise `Quonfig::Errors::MissingDefaultError`) or `:return_nil`. |
281
292
  | `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
293
+ | `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). |
282
294
  | `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
283
295
  | `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
284
296
  | `data_dir_auto_reload` | `Boolean` | `false` | Datadir mode only. When `true`, the SDK watches the datadir and re-reads the envelope when files change. See [Datadir mode: auto-reload on file changes](#datadir-mode-auto-reload-on-file-changes). |
@@ -333,31 +345,43 @@ model.
333
345
 
334
346
  ## Typed getters
335
347
 
336
- Each typed getter takes a config key and an optional context hash. If the key
337
- is missing or the stored value does not match the requested type, the getter
338
- returns `nil`.
339
-
340
- | Method | Returns |
341
- |-------------------------------------------------|-------------------------------|
342
- | `get_string(key, contexts = nil)` | `String` or `nil` |
343
- | `get_int(key, contexts = nil)` | `Integer` or `nil` |
344
- | `get_float(key, contexts = nil)` | `Float` or `nil` |
345
- | `get_bool(key, contexts = nil)` | `true`, `false`, or `nil` |
346
- | `get_string_list(key, contexts = nil)` | `Array<String>` or `nil` |
347
- | `get_duration(key, contexts = nil)` | `Float` (seconds) or `nil` |
348
- | `get_json(key, contexts = nil)` | `Hash`, `Array`, or `nil` |
349
- | `enabled?(feature_name, contexts = nil)` | `true` or `false` |
348
+ Each typed getter takes a config key plus two optional keyword arguments:
349
+ `default:` (returned when the key is missing) and `context:` (a context hash
350
+ for this call). On a `BoundClient` (see `with_context` above) the getters take
351
+ only `default:`; the context is the bound one.
352
+
353
+ - **Missing key:** returns `default:` when you pass one. Without a default the
354
+ `on_no_default` option decides: `:raise` (the default) raises
355
+ `Quonfig::Errors::MissingDefaultError`, `:return_nil` returns `nil`.
356
+ - **Wrong type:** a value that does not match the getter's type (for example
357
+ `get_int` on a string config) raises `Quonfig::Errors::TypeMismatchError`,
358
+ with or without a `default:`.
359
+
360
+ | Method | Returns |
361
+ |-----------------------------------------------------|-------------------------------------------|
362
+ | `get_string(key, default:, context:)` | `String` |
363
+ | `get_int(key, default:, context:)` | `Integer` |
364
+ | `get_float(key, default:, context:)` | `Float` |
365
+ | `get_bool(key, default:, context:)` | `true` or `false` |
366
+ | `get_string_list(key, default:, context:)` | `Array<String>` |
367
+ | `get_duration(key, default:, context:)` | `Integer` (milliseconds) |
368
+ | `get_json(key, default:, context:)` | `Hash`, `Array`, or a scalar |
369
+ | `enabled?(feature_name, context = nil)` | `true` or `false` (`false` when missing) |
370
+
371
+ `get_duration` returns integer milliseconds (`PT1.5S` is `1500`). A duration
372
+ value that is not valid ISO-8601 returns `default:` and logs one warning per
373
+ key; with no default it follows `on_no_default`.
350
374
 
351
375
  Example:
352
376
 
353
377
  ```ruby
354
378
  client.get_string('app.display-name')
355
- client.get_int('rate-limit', user: { key: 'user-123' })
356
- client.get_float('pricing.multiplier')
357
- client.get_bool('flags.new-checkout')
358
- client.get_string_list('allowed-regions')
359
- client.get_duration('request-timeout')
360
- client.get_json('homepage.layout')
379
+ client.get_int('rate-limit', default: 100, context: { user: { key: 'user-123' } })
380
+ client.get_float('pricing.multiplier', default: 1.0)
381
+ client.get_bool('flags.new-checkout', default: false)
382
+ client.get_string_list('allowed-regions', default: [])
383
+ client.get_duration('request-timeout', default: 5_000) # milliseconds
384
+ client.get_json('homepage.layout', default: {})
361
385
  client.enabled?('beta-feature', user: { key: 'user-123' })
362
386
  ```
363
387
 
@@ -652,6 +676,18 @@ process.
652
676
 
653
677
  There is intentionally no `client.healthy?` primitive.
654
678
 
679
+ ### A rejected SDK key
680
+
681
+ When a whole config fetch cycle is rejected (no 200 or 304 on any leg, and a
682
+ 401, 403, 404 or 410 on the primary), the SDK key is invalid or revoked. The
683
+ SDK logs one ERROR naming the status and host, stops streaming and polling,
684
+ and retries with one fetch every 150-300 seconds for as long as the process
685
+ runs. The first accepted fetch installs the config, logs one INFO, and resumes
686
+ streaming and polling on its own; you do not need to rebuild the client. While
687
+ this lasts `client.connection_state` is `:disconnected` and
688
+ `client.terminal_failure?` is `true`. A rejection on the stream alone never
689
+ does this: the SDK keeps reconnecting and its fallback poller decides.
690
+
655
691
  ## Telemetry
656
692
 
657
693
  With an SDK key the client sends usage telemetry to `telemetry_url` so the
@@ -21,6 +21,10 @@ module Quonfig
21
21
  class Client
22
22
  LOG = Quonfig::InternalLogger.new(self)
23
23
 
24
+ # Auth-failed probe period bounds, rule R3 (qfg-goi1.10.26).
25
+ AUTH_PROBE_MIN_SECONDS = 150.0
26
+ AUTH_PROBE_MAX_SECONDS = 300.0
27
+
24
28
  # qfg-ryov: instance registry for the Process._fork hook. Every live
25
29
  # Client is tracked here so the hook can fan out after_fork_in_child
26
30
  # across all of them without the customer needing to name a specific
@@ -86,7 +90,11 @@ module Quonfig
86
90
  @sse_state = :idle
87
91
  @sse_ever_connected = false
88
92
  @fallback_engage_timer = nil
89
- @sse_terminal_failure = false
93
+ # Auth-failed regime (rule R2-R4, qfg-goi1.10.26). Set by the poller's
94
+ # fetch cycle on entry, cleared by the first accepted probe. While set:
95
+ # no SSE, no polling, one probe per [150s, 300s]. Read by
96
+ # #terminal_failure? and #connection_state.
97
+ @auth_failed = false
90
98
  # The process that owns this client's threads, sockets, and store.
91
99
  # Re-stamped when a child takes ownership in #rebuild_in_child!. A pid
92
100
  # mismatch is PROOF that we are looking at a fork(2) child, which is
@@ -493,7 +501,7 @@ module Quonfig
493
501
  # (it had connected, it had errored, ...). None of them apply here.
494
502
  @sse_state = :idle
495
503
  @sse_ever_connected = false
496
- @sse_terminal_failure = false
504
+ @auth_failed = false
497
505
  @sse_error_callback = nil
498
506
 
499
507
  # A client that never finished network init has nothing to rebuild.
@@ -581,6 +589,9 @@ module Quonfig
581
589
  # running. Saying so is the honest answer, and a diagnostic must not
582
590
  # be what triggers a blocking fetch.
583
591
  next :initializing if @fork_rebuild_pending
592
+ # Auth-failed is not a state of its own (R6): SSE and the poller are
593
+ # off, so the client is disconnected, never falling_back.
594
+ next :disconnected if @auth_failed
584
595
  next :falling_back if @poll_supervisor&.alive?
585
596
  # Liveness beats the stored flag (qfg-lv4n.1). A client whose SSE
586
597
  # session was torn down keeps a stale @sse_state; answering
@@ -1079,31 +1090,27 @@ module Quonfig
1079
1090
  @sse_error_callback ||= ->(error) { handle_sse_error(error) }
1080
1091
  end
1081
1092
 
1082
- def handle_sse_error(error)
1083
- # qfg-i5xv: classify terminal HTTP failures (401/403/404). The same SDK
1084
- # key that won't auth over SSE won't auth over HTTP polling either, so
1085
- # we must NOT engage the Layer 2 fallback — that just moves the
1086
- # auth-failure storm from one endpoint to another. Once flipped,
1087
- # @sse_terminal_failure latches: a buggy customer retry loop cannot
1088
- # un-classify the failure by driving the state machine.
1089
- @state_mutex.synchronize { @sse_terminal_failure = true } if error.is_a?(Quonfig::SSEConfigClient::SSEHTTPTerminalError)
1093
+ # Every SSE error, auth-class included, is an ordinary drop: SSE alone
1094
+ # never enters auth-failed (rule R2, qfg-goi1.10.26; this reverses the
1095
+ # qfg-i5xv latch). The fallback poller engages on its usual schedule and
1096
+ # its fetch cycle decides.
1097
+ def handle_sse_error(_error)
1090
1098
  handle_sse_state_change(:error)
1091
1099
  end
1092
1100
 
1093
1101
  def handle_sse_state_change(new_state)
1094
1102
  state = new_state.to_sym
1095
- ever_connected, terminal = @state_mutex.synchronize do
1103
+ ever_connected, auth_failed = @state_mutex.synchronize do
1096
1104
  @sse_state = state
1097
1105
  @sse_ever_connected = true if state == :connected
1098
- [@sse_ever_connected, @sse_terminal_failure]
1106
+ [@sse_ever_connected, @auth_failed]
1099
1107
  end
1100
1108
 
1101
1109
  return unless @options.respond_to?(:fallback_poll_enabled) && @options.fallback_poll_enabled
1102
1110
  return if @stopped
1103
- # qfg-i5xv: a terminal SSE classification suppresses polling engage in
1104
- # every branch — the customer's key is bad and HTTP polling will fail
1105
- # identically. Operators surface this via #terminal_failure?.
1106
- return if terminal
1111
+ # While auth-failed SSE is stopped; a late edge from the stream being
1112
+ # torn down changes nothing (the probe loop owns the schedule).
1113
+ return if auth_failed
1107
1114
 
1108
1115
  case state
1109
1116
  when :connected
@@ -1120,15 +1127,22 @@ module Quonfig
1120
1127
 
1121
1128
  public
1122
1129
 
1123
- # qfg-i5xv: true once the SSE layer has classified an HTTP response as
1124
- # terminal (401/403/404) — bad SDK key, revoked workspace permission,
1125
- # or wrong endpoint. The classification latches: the SDK will not
1126
- # auto-recover, and a customer-supplied retry must rebuild the client.
1127
- # Surfaced for operator alerting; `connection_state` still reports
1128
- # `:disconnected` to honor the documented connection_state vocabulary
1129
- # (supervisor-test-contract.md §"connectionState()" — values fixed).
1130
+ # True while the client is auth-failed (since 1.8.0, qfg-goi1.10.26): a
1131
+ # whole config fetch cycle got no 200/304 on any leg and a 401, 403, 404
1132
+ # or 410 on the primary leg, which means the SDK key is invalid or
1133
+ # revoked. It turns true when the fallback poller's cycle (or, with
1134
+ # +enable_sse: false+, a poll) is rejected: about 120s after a mid-life
1135
+ # stream drop, at once if SSE never connected. While true, SSE and polling
1136
+ # are off and the client sends one fetch probe every 150-300s. It goes back
1137
+ # to false on the first probe that gets a valid 200 or a 304, and the
1138
+ # client resumes streaming and polling on its own; there is no need to
1139
+ # rebuild the client.
1140
+ #
1141
+ # Before 1.8.0 this latched true on the first SSE 401/403/404 for the life
1142
+ # of the client (qfg-i5xv). +connection_state+ reports +:disconnected+
1143
+ # while this is true.
1130
1144
  def terminal_failure?
1131
- @state_mutex.synchronize { @sse_terminal_failure }
1145
+ @state_mutex.synchronize { @auth_failed }
1132
1146
  end
1133
1147
 
1134
1148
  private
@@ -1179,7 +1193,7 @@ module Quonfig
1179
1193
 
1180
1194
  @fallback_engage_timer = Thread.new do
1181
1195
  Thread.current.report_on_exception = false
1182
- sleep grace_seconds
1196
+ Quonfig::Waits.sleep(:engage, grace_seconds)
1183
1197
  @state_mutex.synchronize { @fallback_engage_timer = nil }
1184
1198
  start_polling unless @stopped
1185
1199
  end
@@ -1522,30 +1536,41 @@ module Quonfig
1522
1536
  # engage() (immediate Fetch, then ticker). A sleep-first worker made the
1523
1537
  # first post-engage data arrive a full extra interval late (~180s after
1524
1538
  # SSE loss with the default 60s interval, vs ~120s in go).
1539
+ #
1540
+ # Auth-failure rule (qfg-goi1.10.26, R2-R4): a poll cycle with no 200/304
1541
+ # on any leg and an auth-class status on the primary enters auth-failed.
1542
+ # The same worker then probes once per T in [150s, 300s] (polling mode:
1543
+ # max(T, interval)) until a probe is accepted. The flag lives on the
1544
+ # client, so a supervisor restart of this worker keeps probing.
1525
1545
  worker = lambda do |notify_delivered|
1526
1546
  loop do
1527
1547
  break if stopped_ref.call
1528
1548
 
1529
- result = @config_loader.fetch!
1530
- # Liveness honesty (qfg-41nh.6): stamp freshness only when the fetch
1531
- # actually SUCCEEDED. :updated and :not_modified (a 304, or a
1532
- # guard-rejected-but-successful response) are successes — the
1533
- # channel is healthy; :failed must NOT advance
1534
- # last_successful_refresh (pre-fix every tick stamped + notified
1535
- # regardless of outcome, so ready?/freshness reported healthy over
1536
- # an empty store through a total outage). on_update fires only on a
1537
- # real install, matching sdk-go (OnConfigUpdate fires in
1538
- # installEnvelope only).
1539
- if result != :failed
1540
- sync_evaluator_env_id!
1541
- record_refresh!
1542
- notify_delivered.call
1549
+ if auth_failed?
1550
+ Quonfig::Waits.sleep(:probe, auth_probe_wait(poll_seconds))
1551
+ break if stopped_ref.call
1552
+
1553
+ case run_auth_probe(notify_delivered)
1554
+ when :rejected then next
1555
+ when :handed_to_sse then break
1556
+ end
1557
+ # Accepted in polling mode: resume at the interval, no immediate
1558
+ # fetch (the probe just fetched).
1559
+ Quonfig::Waits.sleep(:poll, poll_seconds)
1560
+ next
1543
1561
  end
1544
- notify_on_update_callback if result == :updated
1562
+
1563
+ cycle = @config_loader.fetch_cycle!(log_mode: :poll)
1564
+ if cycle.auth_rejected?
1565
+ enter_auth_failed!(cycle) unless stopped_ref.call
1566
+ next
1567
+ end
1568
+
1569
+ after_poll_result(cycle.result, notify_delivered)
1545
1570
 
1546
1571
  break if stopped_ref.call
1547
1572
 
1548
- sleep poll_seconds
1573
+ Quonfig::Waits.sleep(:poll, poll_seconds)
1549
1574
  end
1550
1575
  end
1551
1576
 
@@ -1560,6 +1585,85 @@ module Quonfig
1560
1585
  LOG.warn "[quonfig] Layer 2 fallback poller engaged (interval=#{poll_ms}ms)"
1561
1586
  end
1562
1587
 
1588
+ # Liveness honesty (qfg-41nh.6): stamp freshness only when the fetch
1589
+ # actually SUCCEEDED. :updated and :not_modified (a 304, or a
1590
+ # guard-rejected-but-successful response) are successes — the channel is
1591
+ # healthy; :failed must NOT advance last_successful_refresh (pre-fix every
1592
+ # tick stamped + notified regardless of outcome, so ready?/freshness
1593
+ # reported healthy over an empty store through a total outage). on_update
1594
+ # fires only on a real install, matching sdk-go (OnConfigUpdate fires in
1595
+ # installEnvelope only).
1596
+ def after_poll_result(result, notify_delivered)
1597
+ if result != :failed
1598
+ sync_evaluator_env_id!
1599
+ record_refresh!
1600
+ notify_delivered.call
1601
+ end
1602
+ notify_on_update_callback if result == :updated
1603
+ end
1604
+
1605
+ def auth_failed?
1606
+ @state_mutex.synchronize { @auth_failed }
1607
+ end
1608
+
1609
+ # Rule R3: T uniform in [150s, 300s], re-drawn per probe, never doubled.
1610
+ # With SSE disabled the wait is max(T, poll interval). Internal: tests
1611
+ # reach it through Quonfig::Waits, it is not a customer option.
1612
+ def auth_probe_wait(poll_seconds)
1613
+ t = AUTH_PROBE_MIN_SECONDS + (rand * (AUTH_PROBE_MAX_SECONDS - AUTH_PROBE_MIN_SECONDS))
1614
+ t = poll_seconds if !@options.enable_sse && poll_seconds > t
1615
+ t
1616
+ end
1617
+
1618
+ # Entry (R2-R5), on the poller's thread, in this order: SSE off (stream
1619
+ # closed and its worker joined, the engage timer cancelled), the flag up,
1620
+ # then one ERROR naming the status and the primary host, never the key.
1621
+ def enter_auth_failed!(cycle)
1622
+ cancel_fallback_engage_timer
1623
+ sse = @state_mutex.synchronize do
1624
+ @auth_failed = true
1625
+ @sse_state = :idle
1626
+ s = @sse_client
1627
+ @sse_client = nil
1628
+ s
1629
+ end
1630
+ begin
1631
+ sse&.close
1632
+ rescue StandardError => e
1633
+ LOG.debug "Error closing SSE client on auth-failed entry: #{e.message}"
1634
+ end
1635
+ LOG.error "[quonfig] config fetch rejected (HTTP #{cycle.primary_status} from #{cycle.primary_host}): " \
1636
+ 'the SDK key is invalid or revoked (check the SDK key); streaming and polling stopped, ' \
1637
+ 'retrying every few minutes until it is accepted'
1638
+ end
1639
+
1640
+ # One auth-failed probe: a fetch cycle across every leg (R3), logged at
1641
+ # DEBUG only. The first valid 200 or a 304 on any leg exits (R4): one
1642
+ # INFO, and SSE restarts from its base backoff (a brand-new stream
1643
+ # client), handing the update channel back from this poller. With SSE
1644
+ # disabled the poller resumes polling. Returns :rejected, :handed_to_sse
1645
+ # or :resume_polling.
1646
+ def run_auth_probe(notify_delivered)
1647
+ cycle = @config_loader.fetch_cycle!(log_mode: :probe)
1648
+ if cycle.result == :failed
1649
+ LOG.debug "[quonfig] auth-failed probe rejected (HTTP #{cycle.primary_status} from #{cycle.primary_host}); " \
1650
+ 'retrying later'
1651
+ return :rejected
1652
+ end
1653
+
1654
+ after_poll_result(cycle.result, notify_delivered)
1655
+ handing_to_sse = @options.enable_sse && !@stopped
1656
+ # Stop this poller first (from its own thread: WorkerSupervisor#stop only
1657
+ # flags it) so connection_state never reads falling_back on the way out.
1658
+ stop_fallback_poller('auth-recovered') if handing_to_sse
1659
+ @state_mutex.synchronize { @auth_failed = false }
1660
+ LOG.info '[quonfig] config fetch accepted; leaving auth-failed and resuming streaming and polling'
1661
+ return :resume_polling unless handing_to_sse
1662
+
1663
+ start_sse
1664
+ :handed_to_sse
1665
+ end
1666
+
1563
1667
  # Invoke the customer-supplied on_update callback under a rescue. A raise
1564
1668
  # here is the customer's bug, but it must NOT take down the SSE listener
1565
1669
  # or polling supervisor. Log at ERROR with a message containing
@@ -1794,7 +1898,7 @@ module Quonfig
1794
1898
  end
1795
1899
 
1796
1900
  def malformed_stored_duration_error(result)
1797
- Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, 'stored value')
1901
+ Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, nil, stored: true)
1798
1902
  end
1799
1903
 
1800
1904
  def malformed_duration(key, default, error)
@@ -1830,7 +1934,7 @@ module Quonfig
1830
1934
  # JSON values are returned as-is (Hash, Array, or scalar from the wire).
1831
1935
  value
1832
1936
  when Class
1833
- raise Quonfig::Errors::TypeMismatchError.new(key, "expected #{expected_type}", value) unless value.is_a?(expected_type)
1937
+ raise Quonfig::Errors::TypeMismatchError.new(key, expected_type.to_s, value) unless value.is_a?(expected_type)
1834
1938
 
1835
1939
  value
1836
1940
  else