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.
@@ -13,6 +13,11 @@ module Quonfig
13
13
  :telemetry_max_retained_bytes, :telemetry_max_retained_age_ms
14
14
  attr_accessor :is_fork
15
15
 
16
+ # The deprecated on_init_failure spelling passed (:raise / :return), or nil.
17
+ attr_reader :on_init_failure_alias
18
+ # An unrecognized on_init_failure value (treated as :fail), or nil.
19
+ attr_reader :on_init_failure_unknown
20
+
16
21
  # Default fallback poll interval, in milliseconds. The SDK polls api-delivery
17
22
  # at this cadence only when SSE is unavailable for >= 2x this value.
18
23
  DEFAULT_FALLBACK_POLL_INTERVAL_MS = 60_000
@@ -76,9 +81,42 @@ module Quonfig
76
81
  ms == ms.to_i ? ms.to_i : ms
77
82
  end
78
83
 
84
+ # What Client.new does when no config has loaded by +init_timeout_ms+
85
+ # (qfg-9p4i.10; project/plans/2026-10-05-init-failure-design.md B.1-B.3):
86
+ #
87
+ # - +:fail+ (default): raise Quonfig::Errors::InitializationError. Every
88
+ # delivery leg answering 401/403 raises at once, without waiting out the
89
+ # timeout.
90
+ # - +:wait+: keep blocking and retrying with no upper bound.
91
+ # - +:continue+: return a client over an empty store (getters answer with
92
+ # the caller's default) that keeps retrying in the background.
93
+ #
94
+ # +:raise+ and +:return+ are the deprecated earlier spellings of +:fail+ and
95
+ # +:continue+. They are still accepted and log a one-time WARN.
79
96
  module ON_INITIALIZATION_FAILURE
97
+ FAIL = :fail
98
+ WAIT = :wait
99
+ CONTINUE = :continue
100
+ # @deprecated Use FAIL.
80
101
  RAISE = :raise
102
+ # @deprecated Use CONTINUE.
81
103
  RETURN = :return
104
+
105
+ MODES = [FAIL, WAIT, CONTINUE].freeze
106
+ ALIASES = { RAISE => FAIL, RETURN => CONTINUE }.freeze
107
+
108
+ # [canonical mode, deprecated alias given or nil, unknown value or nil].
109
+ # Strings are accepted ('fail', ':raise'). An unknown value resolves to
110
+ # FAIL, the safe default, and is reported so the client can warn.
111
+ def self.resolve(value)
112
+ return [FAIL, nil, nil] if value.nil?
113
+
114
+ sym = value.to_s.delete_prefix(':').to_sym
115
+ return [sym, nil, nil] if MODES.include?(sym)
116
+ return [ALIASES[sym], sym, nil] if ALIASES.key?(sym)
117
+
118
+ [FAIL, nil, value]
119
+ end
82
120
  end
83
121
 
84
122
  module ON_NO_DEFAULT
@@ -217,6 +255,13 @@ module Quonfig
217
255
  private
218
256
 
219
257
  # @!method initialize(options = {})
258
+ # @option options [Integer] :init_timeout_ms (10000)
259
+ # How long +Client.new+ retries the initial config fetch before
260
+ # +:on_init_failure+ decides. A value <= 0 runs exactly one fetch
261
+ # attempt, lets it finish (it is bounded by the per-leg fetch timeouts,
262
+ # not by this option), then applies +:on_init_failure+: +:fail+ raises,
263
+ # +:continue+ starts on code defaults and keeps retrying in the
264
+ # background, +:wait+ keeps retrying. It never means "no deadline".
220
265
  # @option options [Boolean] :data_dir_auto_reload (false)
221
266
  # Datadir mode only. When +true+, the SDK watches the workspace
222
267
  # directory and re-reads the envelope whenever files inside it
@@ -271,7 +316,7 @@ module Quonfig
271
316
  on_no_default: ON_NO_DEFAULT::RAISE,
272
317
  init_timeout_ms: nil,
273
318
  initialization_timeout_sec: nil,
274
- on_init_failure: ON_INITIALIZATION_FAILURE::RAISE,
319
+ on_init_failure: ON_INITIALIZATION_FAILURE::FAIL,
275
320
  config_fetch_timeout_ms: nil,
276
321
  config_fetch_hedge_delay_ms: nil,
277
322
  config_fetch_hedge_abort_ms: nil,
@@ -334,7 +379,10 @@ module Quonfig
334
379
  else
335
380
  DEFAULT_INIT_TIMEOUT_MS
336
381
  end
337
- @on_init_failure = on_init_failure
382
+ # Stored canonical (:fail / :wait / :continue); the deprecated spelling
383
+ # the caller used, if any, is kept for the client's one-time WARN.
384
+ @on_init_failure, @on_init_failure_alias, @on_init_failure_unknown =
385
+ ON_INITIALIZATION_FAILURE.resolve(on_init_failure)
338
386
  # qfg-7h5d.1.9: per-URL config-fetch timeout. nil → DEFAULT_CONFIG_FETCH_TIMEOUT_MS.
339
387
  @config_fetch_timeout_ms = config_fetch_timeout_ms || DEFAULT_CONFIG_FETCH_TIMEOUT_MS
340
388
  # qfg-7h5d.1.14: parallel-failover hedge knobs. nil → defaults. The hedge
@@ -76,6 +76,14 @@ module Quonfig
76
76
  # letting it win the race.
77
77
  READ_TIMEOUT_HEADROOM = 30
78
78
 
79
+ # Size-caps rule (qfg-6vno.8, supervisor-test-contract.md "Rule — size
80
+ # caps"; sdk-go's sseMaxLine): one SSE event, any single line and the sum
81
+ # of a multi-line data:, is capped at 4 MiB. The parser stops reading at
82
+ # the cap, so memory per stream stays near the cap plus one read chunk
83
+ # even for an event with no terminator. Over the cap is not a frame: the
84
+ # connection is a failed attempt. A constant, not an option.
85
+ MAX_EVENT_BYTES = 4 * 1024 * 1024
86
+
79
87
  # +on_error+: optional callable invoked on every SSE error edge. Parent
80
88
  # Quonfig::Client wires this to drive @sse_state -> :error so that
81
89
  # +connection_state+ reflects the disconnect (qfg-47c2.27). A connection
@@ -118,6 +126,13 @@ module Quonfig
118
126
  # that reintroduces stream-URL rotation is observable to the failover
119
127
  # chaos probe (f05) and to operators.
120
128
  @failed_over_to_secondary = false
129
+ # Oversized gate (size caps, sdk-go's +oversized+): set when a stream
130
+ # ended on an event over MAX_EVENT_BYTES. While set, a keepalive is not
131
+ # a frame, so a connection that only re-delivers the oversized snapshot
132
+ # never reports connected and the fallback poller keeps config fresh.
133
+ # The first envelope clears it (and ends the WARN episode). Touched only
134
+ # by the worker thread.
135
+ @oversized = false
121
136
  end
122
137
 
123
138
  # True if the live SSE stream has ever connected to a non-primary leg. Read
@@ -250,6 +265,9 @@ module Quonfig
250
265
  # parser, before the frame's envelope is installed.
251
266
  frame_seen = false
252
267
  on_frame = lambda do
268
+ # Gated, the parser reports only envelopes as frames; one ends
269
+ # the oversized episode.
270
+ @oversized = false
253
271
  next if frame_seen
254
272
 
255
273
  frame_seen = true
@@ -275,6 +293,11 @@ module Quonfig
275
293
  # survives a clean-EOF reconnect only until the next connection
276
294
  # ends frameless.
277
295
  handle_frameless_end(ended_url) unless frame_seen || @stopped.value
296
+ rescue SSEEventTooLargeError => e
297
+ # A failed attempt even if an earlier frame arrived on this
298
+ # connection (sdk-go returns frameSeen=false here).
299
+ frame_seen = false
300
+ handle_oversized_event(e) unless @stopped.value
278
301
  rescue StandardError => e
279
302
  handle_error(e) unless @stopped.value
280
303
  end
@@ -353,7 +376,7 @@ module Quonfig
353
376
  http.request(req) do |resp|
354
377
  check_response_status!(resp.code.to_i, url)
355
378
 
356
- parser = EventParser.new
379
+ parser = EventParser.new(keepalive_is_frame: !@oversized)
357
380
  # qfg-tj18: run_loop wraps the body in +:on_blocking+ which
358
381
  # *would* still deliver during read_body (read_body is a
359
382
  # blocking IO call), but be explicit: we want the watchdog raise
@@ -449,6 +472,18 @@ module Quonfig
449
472
  end
450
473
  end
451
474
 
475
+ # Size caps R4: one WARN per oversized episode (redacted source + cap in
476
+ # bytes), repeats at DEBUG. The episode lasts until an envelope gets
477
+ # through (@oversized cleared in run_loop's on_frame).
478
+ def handle_oversized_event(error)
479
+ where = Quonfig::ConfigLoader.redact_userinfo("#{current_url}/api/v2/sse/config")
480
+ msg = "SSE event from #{where} exceeds the maximum size of #{MAX_EVENT_BYTES} bytes; " \
481
+ 'dropping it and backing off (fallback polling will take over)'
482
+ @oversized ? @logger.debug(msg) : @logger.warn(msg)
483
+ @oversized = true
484
+ invoke_on_error(error)
485
+ end
486
+
452
487
  def handle_error(error)
453
488
  if error.is_a?(SSEHTTPAuthError)
454
489
  # Already logged once by check_response_status! (R5).
@@ -546,6 +581,14 @@ module Quonfig
546
581
  # from any other transport error for backoff/restart purposes.
547
582
  class SSEReadDeadlineExceeded < StandardError; end
548
583
 
584
+ # Raised by the parser when one event passes MAX_EVENT_BYTES (size caps).
585
+ # Ends the connection as a failed attempt; passed to on_error.
586
+ class SSEEventTooLargeError < StandardError
587
+ def initialize(msg = "SSE event exceeds the maximum size of #{MAX_EVENT_BYTES} bytes")
588
+ super
589
+ end
590
+ end
591
+
549
592
  # Private: raised from inside +read_body+ when +close+ has been called,
550
593
  # so the worker leaves the stream without Net::HTTP draining the body
551
594
  # (qfg-yrly). Rescued in +stream_once+; never reaches callers.
@@ -636,8 +679,11 @@ module Quonfig
636
679
  # Malformed +data:+ JSON is logged and skipped; one bad event does not
637
680
  # tear down the stream.
638
681
  class EventParser
639
- def initialize(logger: nil)
682
+ # +keepalive_is_frame+: false while the client's oversized gate is set,
683
+ # so only an envelope counts as a frame (size caps).
684
+ def initialize(logger: nil, keepalive_is_frame: true)
640
685
  @logger = logger
686
+ @keepalive_is_frame = keepalive_is_frame
641
687
  @reader = LineReader.new
642
688
  @data = +''
643
689
  @have_data = false
@@ -663,7 +709,7 @@ module Quonfig
663
709
  if event
664
710
  on_frame&.call
665
711
  yield event
666
- elsif comment_only
712
+ elsif comment_only && @keepalive_is_frame
667
713
  on_frame&.call
668
714
  end
669
715
  elsif line.start_with?(':')
@@ -687,6 +733,14 @@ module Quonfig
687
733
 
688
734
  case name
689
735
  when 'data'
736
+ # Cap the joined multi-line data (each line is already capped by
737
+ # LineReader).
738
+ if @have_data && @data.bytesize + 1 + rest.bytesize > MAX_EVENT_BYTES
739
+ @data = +''
740
+ @have_data = false
741
+ raise SSEEventTooLargeError
742
+ end
743
+
690
744
  if @have_data
691
745
  @data << "\n" << rest
692
746
  else
@@ -766,9 +820,14 @@ module Quonfig
766
820
  loop do
767
821
  idx = @buffer.index(/[\r\n]/, [pos, @scanned].max)
768
822
  if idx.nil?
823
+ # Size caps: an unterminated line past the cap can never be a
824
+ # valid event; stop here instead of buffering the rest.
825
+ raise SSEEventTooLargeError if @buffer.bytesize - pos > MAX_EVENT_BYTES
826
+
769
827
  @scanned = @buffer.bytesize
770
828
  break
771
829
  end
830
+ raise SSEEventTooLargeError if idx - pos > MAX_EVENT_BYTES
772
831
 
773
832
  ch = @buffer.getbyte(idx)
774
833
  if idx == pos && ch == LF && @last_was_cr
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.9.0'
4
+ VERSION = '1.11.0'
5
5
  end
data/lib/quonfig.rb CHANGED
@@ -32,6 +32,7 @@ require 'quonfig/exponential_backoff'
32
32
  require 'quonfig/waits'
33
33
  require 'quonfig/worker_supervisor'
34
34
  require 'quonfig/periodic_sync'
35
+ require 'quonfig/errors/initialization_error'
35
36
  require 'quonfig/errors/initialization_timeout_error'
36
37
  require 'quonfig/errors/invalid_sdk_key_error'
37
38
  require 'quonfig/errors/missing_default_error'
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.9.0
4
+ version: 1.11.0
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-20 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -101,6 +101,7 @@ files:
101
101
  - lib/quonfig/error.rb
102
102
  - lib/quonfig/errors/decryption_error.rb
103
103
  - lib/quonfig/errors/env_var_parse_error.rb
104
+ - lib/quonfig/errors/initialization_error.rb
104
105
  - lib/quonfig/errors/initialization_timeout_error.rb
105
106
  - lib/quonfig/errors/invalid_environment_error.rb
106
107
  - lib/quonfig/errors/invalid_sdk_key_error.rb