quonfig 1.9.0 → 1.11.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 +36 -0
- data/README.md +48 -3
- data/lib/quonfig/client.rb +406 -29
- data/lib/quonfig/config_loader.rb +137 -30
- data/lib/quonfig/dev_context.rb +8 -1
- 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 +118 -3
- data/lib/quonfig/internal_logger.rb +21 -14
- data/lib/quonfig/options.rb +50 -2
- data/lib/quonfig/sse_config_client.rb +62 -3
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
|
@@ -19,22 +19,66 @@ module Quonfig
|
|
|
19
19
|
|
|
20
20
|
CONFIGS_PATH = '/api/v2/configs'
|
|
21
21
|
|
|
22
|
+
# Size-caps rule (qfg-6vno.8, supervisor-test-contract.md "Rule — size
|
|
23
|
+
# caps"): a 200 body from /api/v2/configs over 16 MiB is a failed leg,
|
|
24
|
+
# exactly like a 5xx. 4x the SSE event cap, so a workspace that outgrows
|
|
25
|
+
# the stream still stays fresh through the fallback poller. A constant,
|
|
26
|
+
# not an option.
|
|
27
|
+
MAX_CONFIG_BODY_BYTES = 16 * 1024 * 1024
|
|
28
|
+
|
|
22
29
|
# Rule R1 (qfg-goi1.10.26): the auth class. A fetch cycle with no 200/304
|
|
23
30
|
# on any leg and one of these on the primary leg is rejected; the client's
|
|
24
31
|
# poller then enters auth-failed (Client#start_polling).
|
|
25
32
|
AUTH_STATUSES = [401, 403, 404, 410].freeze
|
|
26
33
|
|
|
34
|
+
# The leg error recorded when a leg ended on a timeout (its per-leg
|
|
35
|
+
# deadline or the hedge abort). The init loop reads it to tell a hung
|
|
36
|
+
# attempt from one that failed fast (init-failure design, decision 7).
|
|
37
|
+
LEG_TIMED_OUT = 'timed out'
|
|
38
|
+
|
|
39
|
+
# host:port of +url+ (scheme kept when present), never its userinfo, path
|
|
40
|
+
# or query. For log lines and leg errors (init-failure design, decision 9).
|
|
41
|
+
def self.display_url(url)
|
|
42
|
+
return url.to_s unless url.is_a?(String) || url.is_a?(URI::Generic)
|
|
43
|
+
|
|
44
|
+
uri = URI.parse(url.to_s)
|
|
45
|
+
return redact_userinfo(url.to_s) unless uri.host
|
|
46
|
+
|
|
47
|
+
"#{"#{uri.scheme}://" if uri.scheme}#{uri.host}:#{uri.port}"
|
|
48
|
+
rescue URI::InvalidURIError
|
|
49
|
+
redact_userinfo(url.to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Strip "user:pass@" from every URL in +text+.
|
|
53
|
+
def self.redact_userinfo(text)
|
|
54
|
+
text.to_s.gsub(%r{(//)[^/@\s]+@}, '\\1')
|
|
55
|
+
end
|
|
56
|
+
|
|
27
57
|
# Result of one fetch cycle across the configured legs. +statuses+ maps a
|
|
28
58
|
# config_api_urls index to the HTTP status that leg returned (0 = no
|
|
29
59
|
# response: timeout, connection failure). +primary_host+ is host:port of
|
|
30
60
|
# the primary leg, for the entry ERROR (never the key).
|
|
31
61
|
class FetchCycle
|
|
32
|
-
attr_reader :result, :statuses, :primary_host
|
|
62
|
+
attr_reader :result, :statuses, :primary_host, :leg_errors
|
|
33
63
|
|
|
34
|
-
|
|
64
|
+
# +leg_errors+ maps a config_api_urls index to a one-line description of
|
|
65
|
+
# why that leg failed (host-free, key-free), for the init log contract.
|
|
66
|
+
def initialize(result, statuses, primary_host, leg_errors = {})
|
|
35
67
|
@result = result
|
|
36
68
|
@statuses = statuses
|
|
37
69
|
@primary_host = primary_host
|
|
70
|
+
@leg_errors = leg_errors
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Every leg answered and every answer was in the auth class (401/403,
|
|
74
|
+
# plus 404/410 per rule R1): the key is rejected (init-failure design
|
|
75
|
+
# B.3, "401/403 from both legs"). Stricter than #auth_rejected? (the
|
|
76
|
+
# poller's rule R2, primary leg only), because init short-circuits on it.
|
|
77
|
+
def all_legs_unauthorized?(leg_count)
|
|
78
|
+
return false unless @result == :failed
|
|
79
|
+
|
|
80
|
+
values = @statuses.values
|
|
81
|
+
values.size >= leg_count && values.all? { |s| AUTH_STATUSES.include?(s) }
|
|
38
82
|
end
|
|
39
83
|
|
|
40
84
|
def primary_status
|
|
@@ -66,8 +110,16 @@ module Quonfig
|
|
|
66
110
|
@mutex = Mutex.new
|
|
67
111
|
end
|
|
68
112
|
|
|
113
|
+
# Send this cycle's remaining lines to DEBUG. The init loop calls it when
|
|
114
|
+
# it gives up on an attempt that is still in flight: its legs keep running
|
|
115
|
+
# on their own threads up to the hedge abort, and their late failures are
|
|
116
|
+
# already summarized in the init ERROR (qfg-9p4i.10).
|
|
117
|
+
def silence!
|
|
118
|
+
@silenced = true
|
|
119
|
+
end
|
|
120
|
+
|
|
69
121
|
def log(level, msg, auth: false)
|
|
70
|
-
return @logger.debug(msg) if @mode == :probe
|
|
122
|
+
return @logger.debug(msg) if @mode == :probe || @silenced
|
|
71
123
|
|
|
72
124
|
if @mode == :poll && auth
|
|
73
125
|
hold = @mutex.synchronize do
|
|
@@ -141,6 +193,9 @@ module Quonfig
|
|
|
141
193
|
# request, write-back after — the network wait happens with no lock held).
|
|
142
194
|
@etags = {}
|
|
143
195
|
@etag_mutex = Mutex.new
|
|
196
|
+
# Leg indexes in an oversized-body episode (WARN once, then DEBUG until
|
|
197
|
+
# that leg installs an envelope). Guarded by @etag_mutex.
|
|
198
|
+
@oversized_legs = Set.new
|
|
144
199
|
@version = nil
|
|
145
200
|
@environment_id = nil
|
|
146
201
|
@logger = logger || LOG
|
|
@@ -208,27 +263,39 @@ module Quonfig
|
|
|
208
263
|
# leg's status (rule R2, qfg-goi1.10.26). +log_mode+ is one of CycleLog's
|
|
209
264
|
# modes; the poller passes :poll for an ordinary cycle and :probe while
|
|
210
265
|
# auth-failed.
|
|
211
|
-
|
|
266
|
+
#
|
|
267
|
+
# +leg_errors+ lets a caller pass in the map the legs record their errors
|
|
268
|
+
# into, so it can still read them if it abandons the cycle mid-flight (the
|
|
269
|
+
# init loop at init_timeout_ms).
|
|
270
|
+
def fetch_cycle!(log_mode: :normal, cycle_log: nil, leg_errors: nil)
|
|
212
271
|
urls = Array(@options.config_api_urls)
|
|
213
272
|
statuses = Concurrent::Map.new
|
|
214
|
-
|
|
273
|
+
leg_errors ||= Concurrent::Map.new
|
|
274
|
+
return FetchCycle.new(:failed, statuses, '', leg_errors) if urls.empty?
|
|
215
275
|
|
|
216
|
-
cycle_log
|
|
276
|
+
cycle_log ||= new_cycle_log(log_mode)
|
|
217
277
|
# Single leg (or no secondary configured): no hedge, just fetch on the
|
|
218
278
|
# calling thread under the SEQUENTIAL per-URL timeout (config_fetch_timeout_ms
|
|
219
279
|
# is unchanged and still governs any non-hedged path). Preserves the
|
|
220
280
|
# synchronous, single-request-per-call shape the legacy/mock callers depend
|
|
221
281
|
# on.
|
|
222
282
|
result = if urls.length < 2
|
|
223
|
-
fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses,
|
|
283
|
+
fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses,
|
|
284
|
+
cycle_log: cycle_log, leg_errors: leg_errors)
|
|
224
285
|
else
|
|
225
|
-
fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log)
|
|
286
|
+
fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log, leg_errors: leg_errors)
|
|
226
287
|
end
|
|
227
|
-
cycle = FetchCycle.new(result, statuses, host_of(urls[0]))
|
|
288
|
+
cycle = FetchCycle.new(result, statuses, host_of(urls[0]), leg_errors)
|
|
228
289
|
cycle_log.release(drop: cycle.auth_rejected?)
|
|
229
290
|
cycle
|
|
230
291
|
end
|
|
231
292
|
|
|
293
|
+
# A CycleLog over this loader's logger, for a caller that wants to hold on
|
|
294
|
+
# to it (see CycleLog#silence!).
|
|
295
|
+
def new_cycle_log(mode = :normal)
|
|
296
|
+
CycleLog.new(@logger, mode)
|
|
297
|
+
end
|
|
298
|
+
|
|
232
299
|
# Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
|
|
233
300
|
# when a new event arrives. SSE is a single-leg live stream, so it carries no
|
|
234
301
|
# config_api_urls index and does not change #resolved_from — but it IS
|
|
@@ -265,7 +332,7 @@ module Quonfig
|
|
|
265
332
|
# the hedge abort inside fetch_from, and heals forward through the
|
|
266
333
|
# reject-older guard. We never join the slow leg — a hung primary must not
|
|
267
334
|
# block a successful secondary install.
|
|
268
|
-
def fetch_hedged(urls, statuses: nil, cycle_log: nil)
|
|
335
|
+
def fetch_hedged(urls, statuses: nil, cycle_log: nil, leg_errors: nil)
|
|
269
336
|
hedge_delay_s = hedge_delay_ms / 1000.0
|
|
270
337
|
abort_ms = hedge_abort_ms
|
|
271
338
|
|
|
@@ -293,7 +360,8 @@ module Quonfig
|
|
|
293
360
|
Thread.new do
|
|
294
361
|
result = :failed
|
|
295
362
|
begin
|
|
296
|
-
result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log
|
|
363
|
+
result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log,
|
|
364
|
+
leg_errors: leg_errors)
|
|
297
365
|
rescue StandardError => e
|
|
298
366
|
@logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
|
|
299
367
|
ensure
|
|
@@ -402,13 +470,16 @@ module Quonfig
|
|
|
402
470
|
''
|
|
403
471
|
end
|
|
404
472
|
|
|
405
|
-
def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil)
|
|
473
|
+
def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil, leg_errors: nil)
|
|
406
474
|
cycle_log ||= CycleLog.new(@logger, :normal)
|
|
475
|
+
where = self.class.display_url(source)
|
|
476
|
+
note = ->(msg) { leg_errors[index || 0] = self.class.redact_userinfo(msg)[0, 200] if leg_errors }
|
|
407
477
|
# qfg-7h5d.1.9 / .1.14: bound this single per-leg attempt so a hung upstream
|
|
408
478
|
# aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
|
|
409
479
|
# path the caller passes config_fetch_hedge_abort_ms; on the sequential /
|
|
410
480
|
# single-URL path it passes config_fetch_timeout_ms.
|
|
411
|
-
conn = Quonfig::HttpConnection.new(source, @options.sdk_key, timeout_ms: timeout_ms
|
|
481
|
+
conn = Quonfig::HttpConnection.new(source, @options.sdk_key, timeout_ms: timeout_ms,
|
|
482
|
+
max_body_bytes: MAX_CONFIG_BODY_BYTES)
|
|
412
483
|
headers = {}
|
|
413
484
|
# Per-leg ETag: snapshot this leg's slot before the request (no lock held
|
|
414
485
|
# during the network wait).
|
|
@@ -428,39 +499,82 @@ module Quonfig
|
|
|
428
499
|
# generation for the SAME sha; remembering it would 304 that repair.
|
|
429
500
|
ignored_unversioned = result == :not_modified && extract_generation(envelope.meta || {}) <= 0
|
|
430
501
|
set_etag_for(index, new_etag) unless ignored_unversioned
|
|
502
|
+
end_oversized_episode(index) if result == :updated
|
|
431
503
|
# install_envelope returns :not_modified when the reject-older guard drops
|
|
432
504
|
# an equal/older payload — surface that so the caller doesn't double-count.
|
|
433
505
|
result == :not_modified ? :not_modified : :updated
|
|
434
506
|
when 304
|
|
435
|
-
@logger.debug "Configs not modified (304) from #{
|
|
507
|
+
@logger.debug "Configs not modified (304) from #{where}"
|
|
436
508
|
:not_modified
|
|
437
509
|
when 401, 403
|
|
438
|
-
|
|
510
|
+
note.call("HTTP #{response.status} (SDK key rejected)")
|
|
511
|
+
# Never the response body: it is upstream content (decision 9).
|
|
512
|
+
cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{where}", auth: true)
|
|
439
513
|
:failed
|
|
440
514
|
else
|
|
441
|
-
|
|
515
|
+
note.call("HTTP #{response.status}")
|
|
516
|
+
cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{where}",
|
|
442
517
|
auth: AUTH_STATUSES.include?(response.status.to_i))
|
|
443
518
|
:failed
|
|
444
519
|
end
|
|
520
|
+
rescue Quonfig::HttpConnection::BodyTooLargeError => e
|
|
521
|
+
# Size caps (qfg-6vno.20.5): a failed leg, like a 5xx. Reading stopped at
|
|
522
|
+
# the cap; nothing is installed and this leg's ETag is not stored.
|
|
523
|
+
note.call("200 body over the #{e.max_bytes}-byte cap")
|
|
524
|
+
log_oversized_body(index, where, e.max_bytes)
|
|
525
|
+
:failed
|
|
445
526
|
rescue NonEnvelopeError => e
|
|
527
|
+
note.call("non-envelope 200 (#{e.message})")
|
|
446
528
|
# qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
|
|
447
529
|
# hedge/failover proceeds and this leg's ETag is never stored (so a junk
|
|
448
530
|
# 200 cannot pin itself through later 304s).
|
|
449
|
-
cycle_log.log(:warn, "Config fetch from #{
|
|
531
|
+
cycle_log.log(:warn, "Config fetch from #{where} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
|
|
450
532
|
:failed
|
|
451
533
|
rescue Quonfig::ConfigShape::AllInvalidError => e
|
|
534
|
+
note.call(e.message)
|
|
452
535
|
# qfg-krjt: a payload with no loadable config is a leg error, so it can
|
|
453
536
|
# never install as an empty workspace.
|
|
454
|
-
cycle_log.log(:warn, "Config fetch from #{
|
|
537
|
+
cycle_log.log(:warn, "Config fetch from #{where}: #{e.message}; treating as a failed leg")
|
|
538
|
+
:failed
|
|
539
|
+
rescue JSON::ParserError
|
|
540
|
+
# The parser's message quotes the body; never pass it on (decision 9).
|
|
541
|
+
note.call('non-JSON 200 body')
|
|
542
|
+
cycle_log.log(:warn, "Config fetch from #{where} returned a non-JSON 200; treating as a failed leg")
|
|
543
|
+
:failed
|
|
544
|
+
rescue Faraday::TimeoutError
|
|
545
|
+
note.call(LEG_TIMED_OUT)
|
|
546
|
+
cycle_log.log(:warn, "Timeout error fetching configs from #{where}")
|
|
455
547
|
:failed
|
|
456
548
|
rescue Faraday::ConnectionFailed => e
|
|
457
|
-
|
|
549
|
+
# The Net::HTTP adapter wraps a connect timeout (Net::OpenTimeout) in
|
|
550
|
+
# ConnectionFailed. That is a hang, not a fast failure (decision 7).
|
|
551
|
+
note.call(connect_timeout?(e) ? LEG_TIMED_OUT : e.message)
|
|
552
|
+
@logger.debug "Connection failure fetching configs from #{where}: #{self.class.redact_userinfo(e.message)}"
|
|
458
553
|
:failed
|
|
459
554
|
rescue StandardError => e
|
|
460
|
-
|
|
555
|
+
note.call("#{e.class}: #{e.message}")
|
|
556
|
+
cycle_log.log(:warn, "Unexpected error fetching configs from #{where}: #{self.class.redact_userinfo(e.message)}")
|
|
461
557
|
:failed
|
|
462
558
|
end
|
|
463
559
|
|
|
560
|
+
def connect_timeout?(err)
|
|
561
|
+
cause = err.respond_to?(:wrapped_exception) ? err.wrapped_exception : nil
|
|
562
|
+
cause ||= err.cause
|
|
563
|
+
cause.is_a?(Timeout::Error) || cause.is_a?(Errno::ETIMEDOUT)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# One WARN per oversized episode per leg; repeats at DEBUG (size-caps R4).
|
|
567
|
+
def log_oversized_body(index, where, max_bytes)
|
|
568
|
+
first = @etag_mutex.synchronize { @oversized_legs.add?(index || 0) }
|
|
569
|
+
msg = "Config fetch from #{where} returned a body over the maximum size of #{max_bytes} bytes; " \
|
|
570
|
+
'treating it as a failed leg'
|
|
571
|
+
first ? @logger.warn(msg) : @logger.debug(msg)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def end_oversized_episode(index)
|
|
575
|
+
@etag_mutex.synchronize { @oversized_legs.delete(index || 0) }
|
|
576
|
+
end
|
|
577
|
+
|
|
464
578
|
def etag_for(index)
|
|
465
579
|
@etag_mutex.synchronize { @etags[index || 0] }
|
|
466
580
|
end
|
|
@@ -484,13 +598,6 @@ module Quonfig
|
|
|
484
598
|
Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
|
|
485
599
|
end
|
|
486
600
|
|
|
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
601
|
def install_envelope(envelope, source:, source_index: nil)
|
|
495
602
|
meta = envelope.meta || {}
|
|
496
603
|
incoming_gen = extract_generation(meta)
|
|
@@ -523,12 +630,12 @@ module Quonfig
|
|
|
523
630
|
# Unversioned payload while a real generation is held: not provably
|
|
524
631
|
# older (it carries no ordering info), so a silent no-op — NOT
|
|
525
632
|
# counted as guardRejected.
|
|
526
|
-
@logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{source})"
|
|
633
|
+
@logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{self.class.display_url(source)})"
|
|
527
634
|
return :not_modified
|
|
528
635
|
end
|
|
529
636
|
|
|
530
637
|
if incoming_gen < @held_generation
|
|
531
|
-
@logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{source})"
|
|
638
|
+
@logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{self.class.display_url(source)})"
|
|
532
639
|
# Failover observability (qfg-41nh.18): count the guard rejection.
|
|
533
640
|
# This single guard covers BOTH the HTTP config-fetch path and the
|
|
534
641
|
# SSE message path (apply_envelope) — every network install funnels
|
|
@@ -548,7 +655,7 @@ module Quonfig
|
|
|
548
655
|
# `guardRejected` made a healthy client report failover activity from
|
|
549
656
|
# init alone. Not installed, not counted — but still :not_modified, so
|
|
550
657
|
# the caller's liveness stamp is unchanged.
|
|
551
|
-
@logger.debug "Same-generation re-delivery: ignoring incoming generation #{incoming_gen} (source=#{source})"
|
|
658
|
+
@logger.debug "Same-generation re-delivery: ignoring incoming generation #{incoming_gen} (source=#{self.class.display_url(source)})"
|
|
552
659
|
end
|
|
553
660
|
return :not_modified
|
|
554
661
|
end
|
data/lib/quonfig/dev_context.rb
CHANGED
|
@@ -17,7 +17,14 @@ module Quonfig
|
|
|
17
17
|
TOKENS_BASENAME = File.join('.quonfig', 'tokens.json')
|
|
18
18
|
|
|
19
19
|
def self.load_quonfig_user_context
|
|
20
|
-
|
|
20
|
+
home = begin
|
|
21
|
+
Dir.home
|
|
22
|
+
rescue ArgumentError
|
|
23
|
+
# HOME unset and no passwd entry (some containers): no dev context,
|
|
24
|
+
# as in sdk-go (qfg-7xlj).
|
|
25
|
+
return nil
|
|
26
|
+
end
|
|
27
|
+
path = File.join(home, TOKENS_BASENAME)
|
|
21
28
|
return nil unless File.exist?(path)
|
|
22
29
|
|
|
23
30
|
raw = begin
|
|
@@ -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
|
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require 'base64'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'zlib'
|
|
5
8
|
require 'timeout'
|
|
6
9
|
|
|
7
10
|
module Quonfig
|
|
@@ -26,6 +29,31 @@ module Quonfig
|
|
|
26
29
|
# structurally cannot catch.
|
|
27
30
|
WALL_CLOCK_HEADROOM_S = 0.25
|
|
28
31
|
|
|
32
|
+
# Raised by #get when +max_body_bytes+ is set and the response body grows
|
|
33
|
+
# past it (size-caps rule, qfg-6vno.20.5). Reading stops at the cap: the
|
|
34
|
+
# body is streamed through a byte counter, never read whole and measured
|
|
35
|
+
# afterwards. Not a Faraday error, so callers handle it explicitly.
|
|
36
|
+
class BodyTooLargeError < StandardError
|
|
37
|
+
attr_reader :max_bytes
|
|
38
|
+
|
|
39
|
+
def initialize(max_bytes)
|
|
40
|
+
@max_bytes = max_bytes
|
|
41
|
+
super("response body exceeds the maximum size of #{max_bytes} bytes")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Size caps (qfg-6vno.20.5): a non-2xx body is read to at most this many
|
|
46
|
+
# bytes, then reading stops and the connection is dropped. Callers never
|
|
47
|
+
# use a non-2xx body (it is never logged), so a large one (a proxy error
|
|
48
|
+
# page, a 401 with 17 MiB of junk) must not be read toward the 16 MiB cap.
|
|
49
|
+
NON_2XX_BODY_READ_LIMIT = 1024
|
|
50
|
+
|
|
51
|
+
# Raised from inside the body read to stop it early; never escapes.
|
|
52
|
+
class StopReading < StandardError; end
|
|
53
|
+
|
|
54
|
+
# Minimal response for the bounded path: the fields callers use.
|
|
55
|
+
BoundedResponse = Struct.new(:status, :headers, :body)
|
|
56
|
+
|
|
29
57
|
# +timeout_ms+ (qfg-7h5d.1.9): per-request bound applied to BOTH the connect
|
|
30
58
|
# (open) and read phases of every request made through this connection, AND
|
|
31
59
|
# (qfg-41nh.6) enforced as a wall-clock ceiling over the whole request. nil
|
|
@@ -39,17 +67,25 @@ module Quonfig
|
|
|
39
67
|
# connect (open) phase — TCP connect plus TLS handshake. nil uses
|
|
40
68
|
# +timeout_ms+ for it, as before. The telemetry reporter passes 5s here and
|
|
41
69
|
# 15s as +timeout_ms+.
|
|
42
|
-
|
|
70
|
+
#
|
|
71
|
+
# +max_body_bytes+ (qfg-6vno.20.5): when set, #get streams the body through
|
|
72
|
+
# a counter and raises BodyTooLargeError as soon as it passes this many
|
|
73
|
+
# bytes, so memory stays bounded near the cap. nil reads the body whole,
|
|
74
|
+
# as before. The config fetch passes ConfigLoader::MAX_CONFIG_BODY_BYTES.
|
|
75
|
+
def initialize(uri, sdk_key, timeout_ms: nil, open_timeout_ms: nil, max_body_bytes: nil)
|
|
43
76
|
@uri = uri
|
|
44
77
|
@sdk_key = sdk_key
|
|
45
78
|
@timeout_ms = timeout_ms
|
|
46
79
|
@open_timeout_ms = open_timeout_ms
|
|
80
|
+
@max_body_bytes = max_body_bytes
|
|
47
81
|
end
|
|
48
82
|
|
|
49
83
|
attr_reader :uri
|
|
50
84
|
|
|
51
85
|
def get(path, headers = {})
|
|
52
|
-
with_wall_clock_deadline { connection(headers).get(path) }
|
|
86
|
+
return with_wall_clock_deadline { connection(headers).get(path) } unless @max_body_bytes
|
|
87
|
+
|
|
88
|
+
get_bounded(path, headers)
|
|
53
89
|
end
|
|
54
90
|
|
|
55
91
|
# A String +body+ is sent verbatim (the telemetry reporter resends a
|
|
@@ -78,6 +114,85 @@ module Quonfig
|
|
|
78
114
|
|
|
79
115
|
private
|
|
80
116
|
|
|
117
|
+
# The bounded GET runs on Net::HTTP directly, because it needs the status
|
|
118
|
+
# BEFORE the body is read: a 2xx body is streamed through a counter that
|
|
119
|
+
# raises BodyTooLargeError past +max_body_bytes+; a non-2xx body is read to
|
|
120
|
+
# at most NON_2XX_BODY_READ_LIMIT bytes. Faraday 1.x's +on_data+ gives no
|
|
121
|
+
# status to the callback, so it cannot tell the two apart. URL, headers and
|
|
122
|
+
# proxy still come from the Faraday connection, so the request is the same.
|
|
123
|
+
# Transport errors are re-raised as the Faraday errors callers rescue.
|
|
124
|
+
def get_bounded(path, headers)
|
|
125
|
+
with_wall_clock_deadline { net_http_get_bounded(path, headers) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def net_http_get_bounded(path, headers)
|
|
129
|
+
conn = connection(headers)
|
|
130
|
+
url = conn.build_exclusive_url(path)
|
|
131
|
+
req = Net::HTTP::Get.new(url.request_uri)
|
|
132
|
+
conn.headers.each { |k, v| req[k] = v }
|
|
133
|
+
result = nil
|
|
134
|
+
begin
|
|
135
|
+
bounded_http(conn, url).start do |http|
|
|
136
|
+
http.request(req) do |res|
|
|
137
|
+
result = BoundedResponse.new(res.code.to_i, response_headers(res), +''.b)
|
|
138
|
+
read_bounded_body(res, result)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
rescue StopReading
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
result.body.force_encoding(Encoding::UTF_8)
|
|
145
|
+
result
|
|
146
|
+
rescue BodyTooLargeError, Faraday::Error
|
|
147
|
+
raise
|
|
148
|
+
rescue Timeout::Error, Errno::ETIMEDOUT => e
|
|
149
|
+
raise Faraday::TimeoutError, e
|
|
150
|
+
rescue IOError, SystemCallError, SocketError, Net::ProtocolError, Net::HTTPBadResponse,
|
|
151
|
+
Net::HTTPHeaderSyntaxError, Zlib::Error, OpenSSL::SSL::SSLError => e
|
|
152
|
+
raise Faraday::ConnectionFailed, e
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def bounded_http(conn, url)
|
|
156
|
+
proxy = conn.proxy
|
|
157
|
+
http = if proxy
|
|
158
|
+
Net::HTTP.new(url.hostname, url.port, proxy.uri.hostname, proxy.uri.port, proxy.user, proxy.password)
|
|
159
|
+
else
|
|
160
|
+
Net::HTTP.new(url.hostname, url.port, nil)
|
|
161
|
+
end
|
|
162
|
+
http.use_ssl = (url.scheme == 'https')
|
|
163
|
+
if @timeout_ms
|
|
164
|
+
http.open_timeout = @timeout_ms / 1000.0
|
|
165
|
+
http.read_timeout = @timeout_ms / 1000.0
|
|
166
|
+
end
|
|
167
|
+
http.open_timeout = @open_timeout_ms / 1000.0 if @open_timeout_ms
|
|
168
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
169
|
+
http
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def response_headers(res)
|
|
173
|
+
headers = Faraday::Utils::Headers.new
|
|
174
|
+
res.each_header { |k, v| headers[k] = v }
|
|
175
|
+
headers
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Reading stops at the limit: the chunk that would cross it is not kept.
|
|
179
|
+
def read_bounded_body(res, result)
|
|
180
|
+
buf = result.body
|
|
181
|
+
if (200..299).cover?(result.status)
|
|
182
|
+
max = @max_body_bytes
|
|
183
|
+
res.read_body do |chunk|
|
|
184
|
+
raise BodyTooLargeError, max if buf.bytesize + chunk.bytesize > max
|
|
185
|
+
|
|
186
|
+
buf << chunk.b
|
|
187
|
+
end
|
|
188
|
+
else
|
|
189
|
+
res.read_body do |chunk|
|
|
190
|
+
buf << chunk.b.byteslice(0, NON_2XX_BODY_READ_LIMIT - buf.bytesize)
|
|
191
|
+
raise StopReading if buf.bytesize >= NON_2XX_BODY_READ_LIMIT
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
81
196
|
# Enforce +timeout_ms+ (+ headroom) as a wall-clock deadline over the whole
|
|
82
197
|
# request. Timeout.timeout is the codebase's accepted backstop pattern (the
|
|
83
198
|
# init path wraps fetch! the same way in Client#perform_initial_fetch); a
|
|
@@ -90,7 +205,7 @@ module Quonfig
|
|
|
90
205
|
|
|
91
206
|
deadline_s = (@timeout_ms / 1000.0) + WALL_CLOCK_HEADROOM_S
|
|
92
207
|
Timeout.timeout(deadline_s, Faraday::TimeoutError,
|
|
93
|
-
"wall-clock per-request deadline #{deadline_s}s exceeded for #{@uri}") do
|
|
208
|
+
"wall-clock per-request deadline #{deadline_s}s exceeded for #{Quonfig::ConfigLoader.redact_userinfo(@uri)}") do
|
|
94
209
|
block.call
|
|
95
210
|
end
|
|
96
211
|
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
|