quonfig 1.7.1 → 1.7.2

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: 07d679520810cf5814af80cd173f859976ce05ccd24f2c03bcbd417b57565a17
4
+ data.tar.gz: aebf8fecb3821bd58cfa1465028ddefadf977e1c4c30d11f18e486ef2c290e67
5
5
  SHA512:
6
- metadata.gz: 720d2acbfe8d30a785c1197fa319d608b1b4e11a1b60657b23d7b3dd921ed0c4882c99e7446b469a009552f09ca54538482509d9dc6a6968dc11df6f0e42d7c2
7
- data.tar.gz: 11a8f0fbb7851dba082a46a7b657e9ec49aa61ae4af155cf9bf2272a35a3e5f75fa8148acdf0f3df8b936798cae5d8619dbc86bb981199f5bf29ceadf5f79567
6
+ metadata.gz: 8823f04fc9b80149680bb384355309f2df1cca790932ede846c56f2a116f0aa9ae88858f956b43c11360aa546e979777652541e027a1865246335564c2d33b1a
7
+ data.tar.gz: 1f930fffeba37875820d6871d1b9ec83981579856d12e79a5c812919a1a41f9d262618450e986d138bd1bdd84d69eb9fd4a240bd678cecdfe3bf464c8cd25aa3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.2 - 2026-10-09
4
+
5
+ - **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.
6
+ - **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.
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+
3
16
  ## 1.7.1 - 2026-10-03
4
17
 
5
18
  - **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
@@ -333,31 +333,43 @@ model.
333
333
 
334
334
  ## Typed getters
335
335
 
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` |
336
+ Each typed getter takes a config key plus two optional keyword arguments:
337
+ `default:` (returned when the key is missing) and `context:` (a context hash
338
+ for this call). On a `BoundClient` (see `with_context` above) the getters take
339
+ only `default:`; the context is the bound one.
340
+
341
+ - **Missing key:** returns `default:` when you pass one. Without a default the
342
+ `on_no_default` option decides: `:raise` (the default) raises
343
+ `Quonfig::Errors::MissingDefaultError`, `:return_nil` returns `nil`.
344
+ - **Wrong type:** a value that does not match the getter's type (for example
345
+ `get_int` on a string config) raises `Quonfig::Errors::TypeMismatchError`,
346
+ with or without a `default:`.
347
+
348
+ | Method | Returns |
349
+ |-----------------------------------------------------|-------------------------------------------|
350
+ | `get_string(key, default:, context:)` | `String` |
351
+ | `get_int(key, default:, context:)` | `Integer` |
352
+ | `get_float(key, default:, context:)` | `Float` |
353
+ | `get_bool(key, default:, context:)` | `true` or `false` |
354
+ | `get_string_list(key, default:, context:)` | `Array<String>` |
355
+ | `get_duration(key, default:, context:)` | `Integer` (milliseconds) |
356
+ | `get_json(key, default:, context:)` | `Hash`, `Array`, or a scalar |
357
+ | `enabled?(feature_name, context = nil)` | `true` or `false` (`false` when missing) |
358
+
359
+ `get_duration` returns integer milliseconds (`PT1.5S` is `1500`). A duration
360
+ value that is not valid ISO-8601 returns `default:` and logs one warning per
361
+ key; with no default it follows `on_no_default`.
350
362
 
351
363
  Example:
352
364
 
353
365
  ```ruby
354
366
  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')
367
+ client.get_int('rate-limit', default: 100, context: { user: { key: 'user-123' } })
368
+ client.get_float('pricing.multiplier', default: 1.0)
369
+ client.get_bool('flags.new-checkout', default: false)
370
+ client.get_string_list('allowed-regions', default: [])
371
+ client.get_duration('request-timeout', default: 5_000) # milliseconds
372
+ client.get_json('homepage.layout', default: {})
361
373
  client.enabled?('beta-feature', user: { key: 'user-123' })
362
374
  ```
363
375
 
@@ -1830,7 +1830,7 @@ module Quonfig
1830
1830
  # JSON values are returned as-is (Hash, Array, or scalar from the wire).
1831
1831
  value
1832
1832
  when Class
1833
- raise Quonfig::Errors::TypeMismatchError.new(key, "expected #{expected_type}", value) unless value.is_a?(expected_type)
1833
+ raise Quonfig::Errors::TypeMismatchError.new(key, expected_type.to_s, value) unless value.is_a?(expected_type)
1834
1834
 
1835
1835
  value
1836
1836
  else
@@ -39,7 +39,10 @@ module Quonfig
39
39
  .sort
40
40
  .each do |filename|
41
41
  path = File.join(dir, filename)
42
- raw = JSON.parse(File.read(path))
42
+ # Always read as UTF-8: under LANG unset / LANG=C the process
43
+ # default is US-ASCII and JSON.parse would raise on any non-ASCII
44
+ # byte (qfg-goi1.2.11 / qfg-xy92).
45
+ raw = JSON.parse(File.read(path, encoding: 'UTF-8'))
43
46
  raise ArgumentError, "[quonfig] config has empty key — file is not a Quonfig Config: #{path}" if raw['key'].nil? || raw['key'].to_s.empty?
44
47
 
45
48
  coerce_numeric_values(raw)
@@ -68,7 +71,7 @@ module Quonfig
68
71
 
69
72
  raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}" unless File.exist?(quonfig_path)
70
73
 
71
- environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
74
+ environments = JSON.parse(File.read(quonfig_path, encoding: 'UTF-8')).fetch('environments', [])
72
75
 
73
76
  raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments) if !environments.empty? && !environments.include?(environment)
74
77
 
@@ -21,7 +21,8 @@ module Quonfig
21
21
  return nil unless File.exist?(path)
22
22
 
23
23
  raw = begin
24
- File.read(path)
24
+ # UTF-8 regardless of the process locale (qfg-goi1.2.11 / qfg-xy92).
25
+ File.read(path, encoding: 'UTF-8')
25
26
  rescue StandardError => e
26
27
  LOG.warn "dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
27
28
  return nil
@@ -3,7 +3,7 @@
3
3
  module Quonfig
4
4
  module Errors
5
5
  class EnvVarParseError < Quonfig::Error
6
- def initialize(env_var, config, env_var_name)
6
+ def initialize(_env_var, config, env_var_name)
7
7
  key, value_type =
8
8
  if config.is_a?(Hash)
9
9
  [config[:key] || config['key'],
@@ -11,7 +11,9 @@ module Quonfig
11
11
  else
12
12
  [config.key, config.value_type]
13
13
  end
14
- super("Evaluating #{key} couldn't coerce #{env_var_name} of #{env_var} to #{value_type}")
14
+ # +_env_var+ (the raw value) is deliberately left out of the message:
15
+ # an env var or stored value can be a secret (qfg-goi1.2.11).
16
+ super("Evaluating #{key} couldn't coerce #{env_var_name} to #{value_type}")
15
17
  end
16
18
  end
17
19
  end
@@ -3,8 +3,12 @@
3
3
  module Quonfig
4
4
  module Errors
5
5
  class TypeMismatchError < Quonfig::Error
6
+ # The message names the key, the expected type and the actual value's
7
+ # class only. The value itself is never interpolated: it can be a
8
+ # decrypted secret, and exception messages end up in logs and error
9
+ # trackers (qfg-goi1.2.11).
6
10
  def initialize(key, expected, actual_value)
7
- super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}: #{actual_value.inspect}")
11
+ super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}")
8
12
  end
9
13
  end
10
14
  end
@@ -56,6 +56,14 @@ module Quonfig
56
56
 
57
57
  MAGIC_CURRENT_TIME_PROPS = %w[quonfig.current-time prefab.current-time reforge.current-time].freeze
58
58
 
59
+ # PROP_MATCHES / PROP_DOES_NOT_MATCH match timeout (qfg-goi1.2.11). Ruby
60
+ # 3.2+ supports Regexp.new(src, timeout:); Regexp::TimeoutError is a
61
+ # RegexpError, so a pattern that runs away (backreferences and lookarounds
62
+ # defeat Onigmo's memoization) does not match instead of pinning a
63
+ # thread. Ruby 3.0/3.1 have no timeout and are unchanged.
64
+ REGEX_MATCH_TIMEOUT_SECONDS = 0.1
65
+ REGEX_TIMEOUT_SUPPORTED = Regexp.respond_to?(:timeout)
66
+
59
67
  attr_reader :store
60
68
  attr_accessor :project_env_id, :env_id
61
69
 
@@ -218,7 +226,7 @@ module Quonfig
218
226
  mv = hget(match_value, :value)
219
227
  if context_exists && context_value.is_a?(String) && mv.is_a?(String)
220
228
  begin
221
- re = Regexp.new(mv)
229
+ re = REGEX_TIMEOUT_SUPPORTED ? Regexp.new(mv, timeout: REGEX_MATCH_TIMEOUT_SECONDS) : Regexp.new(mv)
222
230
  matched = re.match?(context_value)
223
231
  return matched == (operator == OP_PROP_MATCHES)
224
232
  rescue RegexpError
@@ -11,6 +11,14 @@ module Quonfig
11
11
  end
12
12
 
13
13
  @@lock.with_write_lock do
14
+ # Re-check under the lock: two threads can both pass the check above,
15
+ # and the loser must not build (and orphan) a second Client with its
16
+ # own SSE stream and telemetry thread (qfg-goi1.2.11).
17
+ unless @singleton.nil?
18
+ LOG.warn 'Quonfig already initialized.'
19
+ next @singleton
20
+ end
21
+
14
22
  @singleton = Quonfig::Client.new(options)
15
23
  end
16
24
  end
@@ -52,6 +52,10 @@ module Quonfig
52
52
 
53
53
  # SemanticLogger filter contract: return true to emit, false to suppress.
54
54
  # Missing config key → return true so SemanticLogger's static level decides.
55
+ # A resolution error (unset ENV_VAR, decryption failure, ...) is treated
56
+ # the same way: the filter runs inside the host app's log calls and must
57
+ # never raise out of them (qfg-goi1.2.11). Nothing is logged here, since
58
+ # logging from inside a log filter can recurse.
55
59
  def call(log)
56
60
  configured = @client.get(@config_key, nil, context_for(log))
57
61
  return true if configured.nil?
@@ -59,6 +63,8 @@ module Quonfig
59
63
  log_severity = LEVELS[log.level] || LEVELS[:debug]
60
64
  min_severity = LEVELS[normalize_level(configured)] || LEVELS[:debug]
61
65
  log_severity >= min_severity
66
+ rescue StandardError
67
+ true
62
68
  end
63
69
 
64
70
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.7.1'
4
+ VERSION = '1.7.2'
5
5
  end
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.7.1
4
+ version: 1.7.2
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-03 00:00:00.000000000 Z
11
+ date: 2026-10-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport