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.
@@ -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
- def initialize(result, statuses, primary_host)
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
- def fetch_cycle!(log_mode: :normal)
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
- return FetchCycle.new(:failed, statuses, '') if urls.empty?
273
+ leg_errors ||= Concurrent::Map.new
274
+ return FetchCycle.new(:failed, statuses, '', leg_errors) if urls.empty?
215
275
 
216
- cycle_log = CycleLog.new(@logger, log_mode)
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, cycle_log: cycle_log)
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 #{source}"
507
+ @logger.debug "Configs not modified (304) from #{where}"
436
508
  :not_modified
437
509
  when 401, 403
438
- cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}", auth: true)
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
- cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{source}",
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 #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
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 #{source}: #{e.message}; treating as a failed leg")
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
- @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
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
- cycle_log.log(:warn, "Unexpected error fetching configs from #{source}: #{e.message}")
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
@@ -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
- path = File.join(Dir.home, TOKENS_BASENAME)
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
- class InitializationTimeoutError < Quonfig::Error
6
- def initialize(timeout_sec, key)
7
- message = "Quonfig couldn't initialize in #{timeout_sec} second timeout. Trying to fetch key `#{key}`."
8
- super(message)
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
- def initialize(uri, sdk_key, timeout_ms: nil, open_timeout_ms: nil)
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
- def log_message(level, message, &block)
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
- @logger.send(level, message, &block)
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