quonfig 1.7.2 → 1.9.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
4
5
 
5
6
  module Quonfig
6
7
  # Fetches config envelopes from the Quonfig delivery API and installs them
@@ -18,6 +19,79 @@ module Quonfig
18
19
 
19
20
  CONFIGS_PATH = '/api/v2/configs'
20
21
 
22
+ # Rule R1 (qfg-goi1.10.26): the auth class. A fetch cycle with no 200/304
23
+ # on any leg and one of these on the primary leg is rejected; the client's
24
+ # poller then enters auth-failed (Client#start_polling).
25
+ AUTH_STATUSES = [401, 403, 404, 410].freeze
26
+
27
+ # Result of one fetch cycle across the configured legs. +statuses+ maps a
28
+ # config_api_urls index to the HTTP status that leg returned (0 = no
29
+ # response: timeout, connection failure). +primary_host+ is host:port of
30
+ # the primary leg, for the entry ERROR (never the key).
31
+ class FetchCycle
32
+ attr_reader :result, :statuses, :primary_host
33
+
34
+ def initialize(result, statuses, primary_host)
35
+ @result = result
36
+ @statuses = statuses
37
+ @primary_host = primary_host
38
+ end
39
+
40
+ def primary_status
41
+ @statuses[0] || 0
42
+ end
43
+
44
+ # Rule R2: no leg returned 200/304 and the primary returned an
45
+ # auth-class status. A 200 the guard dropped, or a 304, never qualifies.
46
+ def auth_rejected?
47
+ return false unless @result == :failed
48
+ return false if @statuses.values.any? { |s| [200, 304].include?(s) }
49
+
50
+ AUTH_STATUSES.include?(primary_status)
51
+ end
52
+ end
53
+
54
+ # Collects one cycle's per-leg log lines. +mode+:
55
+ # :normal - log at once, as always (init fetch, refresh, legacy callers)
56
+ # :poll - a poller cycle: hold the auth-class lines until the cycle is
57
+ # classified, so an entering cycle logs its one ERROR in their
58
+ # place (rule R5); otherwise they are logged unchanged
59
+ # :probe - an auth-failed probe: every line at DEBUG (rule R5)
60
+ class CycleLog
61
+ def initialize(logger, mode)
62
+ @logger = logger
63
+ @mode = mode
64
+ @held = []
65
+ @released = false
66
+ @mutex = Mutex.new
67
+ end
68
+
69
+ def log(level, msg, auth: false)
70
+ return @logger.debug(msg) if @mode == :probe
71
+
72
+ if @mode == :poll && auth
73
+ hold = @mutex.synchronize do
74
+ @held << [level, msg] unless @released
75
+ !@released
76
+ end
77
+ return if hold
78
+ end
79
+ @logger.public_send(level, msg)
80
+ end
81
+
82
+ # Log (or with +drop+, discard) the held lines. Lines from a leg that
83
+ # settles later are logged as they come.
84
+ def release(drop: false)
85
+ held = @mutex.synchronize do
86
+ @released = true
87
+ h = @held
88
+ @held = []
89
+ h
90
+ end
91
+ held.each { |level, msg| @logger.public_send(level, msg) } unless drop
92
+ end
93
+ end
94
+
21
95
  attr_reader :version, :environment_id
22
96
 
23
97
  # qfg-7h5d.1.9 (canonical ordering). Diagnostic surface read by the failover/
@@ -127,17 +201,32 @@ module Quonfig
127
201
  # :not_modified -- a leg answered 304 (no change) and nothing installed
128
202
  # :failed -- every fired leg failed
129
203
  def fetch!
204
+ fetch_cycle!.result
205
+ end
206
+
207
+ # One fetch cycle, as #fetch!, returning a FetchCycle that carries each
208
+ # leg's status (rule R2, qfg-goi1.10.26). +log_mode+ is one of CycleLog's
209
+ # modes; the poller passes :poll for an ordinary cycle and :probe while
210
+ # auth-failed.
211
+ def fetch_cycle!(log_mode: :normal)
130
212
  urls = Array(@options.config_api_urls)
131
- return :failed if urls.empty?
213
+ statuses = Concurrent::Map.new
214
+ return FetchCycle.new(:failed, statuses, '') if urls.empty?
132
215
 
216
+ cycle_log = CycleLog.new(@logger, log_mode)
133
217
  # Single leg (or no secondary configured): no hedge, just fetch on the
134
218
  # calling thread under the SEQUENTIAL per-URL timeout (config_fetch_timeout_ms
135
219
  # is unchanged and still governs any non-hedged path). Preserves the
136
220
  # synchronous, single-request-per-call shape the legacy/mock callers depend
137
221
  # on.
138
- return fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms) if urls.length < 2
139
-
140
- fetch_hedged(urls)
222
+ result = if urls.length < 2
223
+ fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses, cycle_log: cycle_log)
224
+ else
225
+ fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log)
226
+ end
227
+ cycle = FetchCycle.new(result, statuses, host_of(urls[0]))
228
+ cycle_log.release(drop: cycle.auth_rejected?)
229
+ cycle
141
230
  end
142
231
 
143
232
  # Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
@@ -176,7 +265,7 @@ module Quonfig
176
265
  # the hedge abort inside fetch_from, and heals forward through the
177
266
  # reject-older guard. We never join the slow leg — a hung primary must not
178
267
  # block a successful secondary install.
179
- def fetch_hedged(urls)
268
+ def fetch_hedged(urls, statuses: nil, cycle_log: nil)
180
269
  hedge_delay_s = hedge_delay_ms / 1000.0
181
270
  abort_ms = hedge_abort_ms
182
271
 
@@ -204,7 +293,7 @@ module Quonfig
204
293
  Thread.new do
205
294
  result = :failed
206
295
  begin
207
- result = fetch_from(urls[index], index, timeout_ms: abort_ms)
296
+ result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log)
208
297
  rescue StandardError => e
209
298
  @logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
210
299
  ensure
@@ -306,7 +395,15 @@ module Quonfig
306
395
  @options.respond_to?(:config_fetch_timeout_ms) ? @options.config_fetch_timeout_ms : nil
307
396
  end
308
397
 
309
- def fetch_from(source, index = nil, timeout_ms: nil)
398
+ def host_of(url)
399
+ uri = URI(url.to_s)
400
+ uri.host ? "#{uri.host}:#{uri.port}" : url.to_s
401
+ rescue URI::InvalidURIError
402
+ ''
403
+ end
404
+
405
+ def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil)
406
+ cycle_log ||= CycleLog.new(@logger, :normal)
310
407
  # qfg-7h5d.1.9 / .1.14: bound this single per-leg attempt so a hung upstream
311
408
  # aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
312
409
  # path the caller passes config_fetch_hedge_abort_ms; on the sequential /
@@ -318,6 +415,7 @@ module Quonfig
318
415
  etag = etag_for(index)
319
416
  headers['If-None-Match'] = etag if etag
320
417
  response = conn.get(CONFIGS_PATH, headers)
418
+ statuses[index || 0] = response.status.to_i if statuses
321
419
 
322
420
  case response.status
323
421
  when 200
@@ -337,23 +435,29 @@ module Quonfig
337
435
  @logger.debug "Configs not modified (304) from #{source}"
338
436
  :not_modified
339
437
  when 401, 403
340
- @logger.warn "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}"
438
+ cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}", auth: true)
341
439
  :failed
342
440
  else
343
- @logger.info "Config fetch failed: status #{response.status} from #{source}"
441
+ cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{source}",
442
+ auth: AUTH_STATUSES.include?(response.status.to_i))
344
443
  :failed
345
444
  end
346
445
  rescue NonEnvelopeError => e
347
446
  # qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
348
447
  # hedge/failover proceeds and this leg's ETag is never stored (so a junk
349
448
  # 200 cannot pin itself through later 304s).
350
- @logger.warn "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg"
449
+ cycle_log.log(:warn, "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
450
+ :failed
451
+ rescue Quonfig::ConfigShape::AllInvalidError => e
452
+ # qfg-krjt: a payload with no loadable config is a leg error, so it can
453
+ # never install as an empty workspace.
454
+ cycle_log.log(:warn, "Config fetch from #{source}: #{e.message}; treating as a failed leg")
351
455
  :failed
352
456
  rescue Faraday::ConnectionFailed => e
353
457
  @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
354
458
  :failed
355
459
  rescue StandardError => e
356
- @logger.warn "Unexpected error fetching configs from #{source}: #{e.message}"
460
+ cycle_log.log(:warn, "Unexpected error fetching configs from #{source}: #{e.message}")
357
461
  :failed
358
462
  end
359
463
 
@@ -373,10 +477,11 @@ module Quonfig
373
477
  data = body.is_a?(String) ? JSON.parse(body) : body
374
478
  raise NonEnvelopeError, 'missing meta.version' unless Quonfig::ConfigEnvelope.wire_envelope?(data)
375
479
 
376
- Quonfig::ConfigEnvelope.new(
377
- configs: data['configs'] || [],
378
- meta: data['meta'] || {}
379
- )
480
+ meta = data['meta'] || {}
481
+ # Skip one malformed config, keep the rest (qfg-krjt). Raises
482
+ # ConfigShape::AllInvalidError when none is loadable.
483
+ configs = Quonfig::ConfigShape.filter(data['configs'] || [], logger: @logger, version: meta['version'])
484
+ Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
380
485
  end
381
486
 
382
487
  def short_body(response)
@@ -461,6 +566,27 @@ module Quonfig
461
566
  @version = meta['version'] || meta[:version] || @version
462
567
  @environment_id = meta['environment'] || meta[:environment] || @environment_id
463
568
 
569
+ # Replace the live store first, then advance the generation
570
+ # (qfg-goi1.10.2). An interrupt part-way through the store write
571
+ # (Thread#raise, Timeout) then leaves the held generation where it
572
+ # was, so the same-generation redelivery that follows installs again
573
+ # and repairs the store instead of being ignored as a duplicate.
574
+ #
575
+ # The new config set is built off to the side and swapped in with one
576
+ # reference write (qfg-i86q), so a concurrent lookup never sees a flag
577
+ # from this envelope with a segment from the last one, or a key that
578
+ # is momentarily absent.
579
+ unless @store.nil?
580
+ next_configs = {}
581
+ envelope.configs.each do |cfg|
582
+ key = config_key(cfg)
583
+ next if key.nil?
584
+
585
+ next_configs[key] = cfg
586
+ end
587
+ @store.replace_all(next_configs)
588
+ end
589
+
464
590
  # qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
465
591
  # ordering info and must never LOWER a positive held generation. Under
466
592
  # the qfg-9dxb.9 rule it only installs when held is nil/0 anyway, so
@@ -473,21 +599,6 @@ module Quonfig
473
599
  # SSE / datadir installs pass source_index nil and are ignored by the
474
600
  # aggregator, so they are never counted.
475
601
  @failover_aggregator&.record_resolved_from(source_index)
476
-
477
- # Replace the live store atomically.
478
- return if @store.nil?
479
-
480
- new_keys = next_map.keys.to_set
481
- old_keys = @store.keys.to_set
482
- # Drop keys that disappeared server-side.
483
- (old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
484
-
485
- envelope.configs.each do |cfg|
486
- key = config_key(cfg)
487
- next if key.nil?
488
-
489
- @store.set(key, cfg)
490
- end
491
602
  end
492
603
  end
493
604
 
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Decode-time shape check for config entries (qfg-krjt; the WS8
5
+ # skip-one-bad-config rule, as sdk-go 2f6b98b, sdk-node qfg-goi1.10.1 and
6
+ # sdk-python qfg-goi1.10.5).
7
+ #
8
+ # The store keeps raw JSON and the evaluator walks it as Hashes and Arrays,
9
+ # so a config whose rule, criterion or default has the wrong shape, or whose
10
+ # weighted value has a non-integer weight, would install and then raise
11
+ # NoMethodError/TypeError out of every read of that key. Each delivery path
12
+ # (HTTP, SSE, datadir) runs its configs through .filter instead: a bad entry
13
+ # is skipped with a WARN naming its key, so the key reads as not found, and
14
+ # the rest installs. If every entry fails, .filter raises AllInvalidError so
15
+ # the payload is rejected and can never install as an empty workspace.
16
+ module ConfigShape
17
+ LOG = Quonfig::InternalLogger.new(self)
18
+
19
+ # Raised by .filter when a non-empty payload has no loadable config.
20
+ class AllInvalidError < StandardError; end
21
+
22
+ module_function
23
+
24
+ # Returns the configs that pass #problem, in order. +version+ is only used
25
+ # in the log line.
26
+ def filter(configs, logger: nil, version: nil)
27
+ raise AllInvalidError, 'every config failed to load: configs is not a list' unless configs.is_a?(Array)
28
+
29
+ log = logger || LOG
30
+ first_problem = nil
31
+ kept = configs.select do |cfg|
32
+ reason = problem(cfg)
33
+ next true if reason.nil?
34
+
35
+ first_problem ||= reason
36
+ log.warn "[quonfig] skipping config \"#{key_of(cfg)}\" that failed to load (version #{version}): " \
37
+ "#{reason}; other configs are unaffected"
38
+ false
39
+ end
40
+ raise AllInvalidError, "every config failed to load (first: #{first_problem})" if kept.empty? && first_problem
41
+
42
+ kept
43
+ end
44
+
45
+ # nil when +cfg+ has a shape the evaluator can walk, else a short reason.
46
+ def problem(cfg)
47
+ return 'config entry is not an object' unless cfg.is_a?(Hash)
48
+
49
+ key = field(cfg, 'key')
50
+ return 'config entry has no key' unless key.is_a?(String) && !key.empty?
51
+
52
+ default = field(cfg, 'default')
53
+ unless default.nil?
54
+ return 'default is not an object' unless default.is_a?(Hash)
55
+
56
+ reason = rules_problem(field(default, 'rules'), 'default')
57
+ return reason if reason
58
+ end
59
+
60
+ env = field(cfg, 'environment')
61
+ return nil if env.nil?
62
+ return 'environment is not an object' unless env.is_a?(Hash)
63
+
64
+ rules_problem(field(env, 'rules'), 'environment')
65
+ end
66
+
67
+ def rules_problem(rules, where)
68
+ return nil if rules.nil?
69
+ return "#{where}.rules is not a list" unless rules.is_a?(Array)
70
+
71
+ rules.each_with_index do |rule, i|
72
+ reason = rule_problem(rule)
73
+ return "#{where}.rules[#{i}]: #{reason}" if reason
74
+ end
75
+ nil
76
+ end
77
+
78
+ def rule_problem(rule)
79
+ return 'rule is not an object' unless rule.is_a?(Hash)
80
+
81
+ criteria = field(rule, 'criteria')
82
+ unless criteria.nil?
83
+ return 'criteria is not a list' unless criteria.is_a?(Array)
84
+
85
+ criteria.each_with_index do |criterion, i|
86
+ return "criteria[#{i}] is not an object" unless criterion.is_a?(Hash)
87
+
88
+ match = field(criterion, 'valueToMatch')
89
+ return "criteria[#{i}].valueToMatch is not an object" unless match.nil? || match.is_a?(Hash)
90
+ end
91
+ end
92
+
93
+ value_problem(field(rule, 'value'))
94
+ end
95
+
96
+ def value_problem(value)
97
+ return nil if value.nil?
98
+ return 'value is not an object' unless value.is_a?(Hash)
99
+ return nil unless field(value, 'type') == 'weighted_values'
100
+
101
+ weighted = field(value, 'value')
102
+ return 'weighted_values value is not an object' unless weighted.is_a?(Hash)
103
+
104
+ entries = field(weighted, 'weightedValues')
105
+ return nil if entries.nil?
106
+ return 'weightedValues is not a list' unless entries.is_a?(Array)
107
+
108
+ entries.each_with_index do |entry, i|
109
+ return "weightedValues[#{i}] is not an object" unless entry.is_a?(Hash)
110
+
111
+ # A missing weight counts as 0; a present one must be an Integer
112
+ # (sdk-go's int weight fails json.Unmarshal the same way).
113
+ next unless entry.key?('weight') || entry.key?(:weight)
114
+ next if field(entry, 'weight').is_a?(Integer)
115
+
116
+ return "weightedValues[#{i}].weight must be an integer (got #{field(entry, 'weight').class})"
117
+ end
118
+ nil
119
+ end
120
+
121
+ def field(hash, name)
122
+ return hash[name] if hash.key?(name)
123
+
124
+ hash[name.to_sym]
125
+ end
126
+
127
+ def key_of(cfg)
128
+ return '' unless cfg.is_a?(Hash)
129
+
130
+ key = field(cfg, 'key')
131
+ key.is_a?(String) ? key : ''
132
+ end
133
+ end
134
+ end
@@ -3,48 +3,94 @@
3
3
  module Quonfig
4
4
  # In-memory store of configs keyed by config key.
5
5
  #
6
- # Mirrors sdk-node's ConfigStore (src/store.ts). Integration tests and the
7
- # new Resolver/Evaluator trio construct this directly, independent of any
8
- # Client/ConfigLoader plumbing.
6
+ # Integration tests and the Resolver/Evaluator trio construct this
7
+ # directly, independent of any Client/ConfigLoader plumbing.
9
8
  #
10
- # Thread-safety: backed by Concurrent::Map, whose per-key reads, writes, and
11
- # deletes are atomic. There is no compound multi-key operation here that
12
- # needs an outer lock — envelope application in ConfigLoader is a sequence
13
- # of independent set/delete calls, and readers tolerate seeing the
14
- # in-progress mix. Eventual consistency across an envelope is acceptable
15
- # and matches sdk-node behavior.
9
+ # Thread-safety (qfg-i86q): the store holds one frozen Hash snapshot behind
10
+ # an AtomicReference. An install builds the next Hash off to the side and
11
+ # swaps the reference once (#replace_all), so a reader sees the old
12
+ # envelope or the new one, never a mix. Matches sdk-go runtime_store.go.
13
+ # #set / #delete / #clear are copy-on-write under a writer mutex, for the
14
+ # test harness and single-key callers.
15
+ #
16
+ # One lookup can read several keys (a flag, then the segments it
17
+ # references, then a decryptWith key). #pinned holds one snapshot for the
18
+ # current thread for the duration of a block, so every read in that
19
+ # lookup comes from the same envelope even if an install swaps the store
20
+ # mid-evaluation.
16
21
  class ConfigStore
22
+ EMPTY = {}.freeze
23
+ private_constant :EMPTY
24
+
17
25
  def initialize(initial_configs = nil)
18
- @configs = Concurrent::Map.new
26
+ @write_mutex = Mutex.new
27
+ @pin_key = :"__quonfig_store_pin_#{object_id}"
28
+ @snapshot = Concurrent::AtomicReference.new(EMPTY)
19
29
  return unless initial_configs
20
30
 
21
- initial_configs.each { |k, v| @configs[k] = v }
31
+ replace_all(initial_configs.to_h)
22
32
  end
23
33
 
24
34
  def get(key)
25
- @configs[key]
35
+ snapshot[key]
26
36
  end
27
37
 
28
38
  def set(key, config)
29
- @configs[key] = config
39
+ @write_mutex.synchronize do
40
+ @snapshot.set(@snapshot.get.merge(key => config).freeze)
41
+ end
42
+ config
30
43
  end
31
44
 
32
45
  def delete(key)
33
- @configs.delete(key)
46
+ @write_mutex.synchronize do
47
+ current = @snapshot.get
48
+ return nil unless current.key?(key)
49
+
50
+ next_map = current.dup
51
+ value = next_map.delete(key)
52
+ @snapshot.set(next_map.freeze)
53
+ value
54
+ end
34
55
  end
35
56
 
36
57
  def clear
37
- @configs.each_key { |k| @configs.delete(k) }
58
+ replace_all(EMPTY)
59
+ end
60
+
61
+ # Atomically replace every config with +configs+ (a Hash of key =>
62
+ # config). Readers see either the previous set or this one.
63
+ def replace_all(configs)
64
+ next_map = configs.frozen? ? configs : configs.dup.freeze
65
+ @write_mutex.synchronize { @snapshot.set(next_map) }
66
+ nil
38
67
  end
39
68
 
40
69
  def keys
41
- @configs.keys
70
+ snapshot.keys
42
71
  end
43
72
 
44
73
  def all_configs
45
- h = {}
46
- @configs.each_pair { |k, v| h[k] = v }
47
- h
74
+ snapshot.dup
75
+ end
76
+
77
+ # Run the block with this thread's reads pinned to the current snapshot.
78
+ # Re-entrant: a nested call keeps the outer pin.
79
+ def pinned
80
+ return yield if Thread.current[@pin_key]
81
+
82
+ Thread.current[@pin_key] = @snapshot.get
83
+ begin
84
+ yield
85
+ ensure
86
+ Thread.current[@pin_key] = nil
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def snapshot
93
+ Thread.current[@pin_key] || @snapshot.get
48
94
  end
49
95
  end
50
96
  end
@@ -50,18 +50,18 @@ module Quonfig
50
50
  end
51
51
  end
52
52
 
53
+ version = "datadir:#{datadir}"
54
+ # Skip one malformed config file, keep the rest (qfg-krjt).
53
55
  Quonfig::ConfigEnvelope.new(
54
- configs: configs,
55
- meta: { 'version' => "datadir:#{datadir}", 'environment' => env_id }
56
+ configs: Quonfig::ConfigShape.filter(configs, version: version),
57
+ meta: { 'version' => version, 'environment' => env_id }
56
58
  )
57
59
  end
58
60
 
59
61
  # Convenience: load the envelope and populate a fresh ConfigStore.
60
62
  def load_store(datadir, environment = nil)
61
63
  envelope = load_envelope(datadir, environment)
62
- store = Quonfig::ConfigStore.new
63
- envelope.configs.each { |cfg| store.set(cfg['key'], cfg) }
64
- store
64
+ Quonfig::ConfigStore.new(envelope.configs.to_h { |cfg| [cfg['key'], cfg] })
65
65
  end
66
66
 
67
67
  def resolve_environment(quonfig_path, environment)
@@ -3,7 +3,9 @@
3
3
  module Quonfig
4
4
  module Errors
5
5
  class EnvVarParseError < Quonfig::Error
6
- def initialize(_env_var, config, env_var_name)
6
+ # +stored: true+ marks a malformed stored value (no env var involved);
7
+ # the message then names the stored value rather than a variable.
8
+ def initialize(_env_var, config, env_var_name, stored: false)
7
9
  key, value_type =
8
10
  if config.is_a?(Hash)
9
11
  [config[:key] || config['key'],
@@ -12,8 +14,13 @@ module Quonfig
12
14
  [config.key, config.value_type]
13
15
  end
14
16
  # +_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}")
17
+ # an env var or stored value can be a secret (qfg-goi1.2.11). The
18
+ # wording is the cross-SDK template pinned by integration-test-data
19
+ # (v2026.10.12): it names the variable, the lowercase type and the
20
+ # config key, never the value or a parser message quoting it.
21
+ type_name = value_type.to_s.downcase
22
+ source = stored ? 'stored value' : %(environment variable "#{env_var_name}")
23
+ super(%(#{source} cannot be converted to #{type_name} for config "#{key}"))
17
24
  end
18
25
  end
19
26
  end