quonfig 1.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 774d181106fd045eddb916eb177c89744a97f014d7168a4e89d4d51ca2b3d6e2
4
- data.tar.gz: c34620585b746c0f013ec473870306593e45fd617b62187ebc31a3d44e14d709
3
+ metadata.gz: 6d1016bb1930249b2a74a4f670e25cb2143ff0e664edd54062c585437a85527a
4
+ data.tar.gz: 8e2e0844f5853cf5c1d27f45054e71dc6cda6f8f5aa398468c982a485659d1b8
5
5
  SHA512:
6
- metadata.gz: 1e56307ab528445682d284fc48b651dd40224ba007f245e6abc4816bbb0b329e1d8fc0f5d6eff6f73137d375b8abbaf47488f074c803fe1d2944d197c3911910
7
- data.tar.gz: 8dcfd007ada4cd44417a1b57928a90bf1e47a33e609b2cc14cb4484460911730f00d9e60cc0ef15fb150de61eab245d8e26e19e0c0f51e9ad0e50132afccbc03
6
+ metadata.gz: 705580a318dd7d773b6b0b01236f0509b6d60f440e44e21d07228de7ce6915d71c9ffe9c9c60a6a4c50ed95ed39018727bbb2ed89389610d4df8c9058b196f48
7
+ data.tar.gz: a9e69afa6be50db19b7508e9224203dda7c62ef522d1b591d2d7050ffdd69cbef217433791330fead5435c9ebe9fcbd364c245be5127ff9fbb1443e74be6a586
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.0 - 2026-10-23
4
+
5
+ - **Changed (minor): SSE events and config bodies have size caps (qfg-6vno.20.5, cross-SDK rule qfg-6vno.8).** One SSE event (any single line, and the joined lines of a multi-line `data:`) is capped at 4 MiB (4,194,304 bytes), and a 200 body from `/api/v2/configs` at 16 MiB (16,777,216 bytes). Before, both were read with no limit (1.8.0 noted "there is still no size cap"), so a broken proxy or a stream with no newline could grow memory without bound.
6
+ - The SDK stops reading at the cap. It never reads the whole event or body and then measures it.
7
+ - An oversized SSE event is not a frame. The connection counts as a failed attempt: the reconnect delay grows, the client reports not connected, and the fallback poller keeps config fresh over HTTP. Until an envelope arrives on the stream, a later connection that only sends keepalives does not report connected.
8
+ - An oversized config body is a failed leg, handled like a 5xx. Nothing is installed, its ETag is not stored, and `last_successful_refresh` does not advance.
9
+ - A non-2xx response from `/api/v2/configs` has its body read to at most 1 KiB, then the SDK stops reading. Before, a large error body (a proxy error page, or a 401 carrying many MiB) was read up to the 16 MiB cap, and a 401/403/404/410 with a body over 16 MiB was logged as an oversized 200 and missed by auth-class detection. The status now comes first, so that response counts as the auth status it is. The bounded config fetch runs on Net::HTTP directly so it can see the status before it reads the body; the URL, headers, proxy and timeouts are the same as before.
10
+ - Each oversized episode logs one WARN that names the source and the cap in bytes. Repeats log at DEBUG. The caps are constants, not options. Contract: integration-test-data `v2026.10.22.2`, supervisor contract "Rule — size caps" and Test 10 cases 1-5, in `test/test_size_caps.rb`.
11
+ - **Added (minor): `Client#callback_errors_total(callback: nil)` (qfg-6vno.6.1, supervisor contract Test 5).** It counts each call of your `on_update` block that raised a `StandardError` (`quonfig_sdk_callback_errors_total{callback="on_config_update"}`). Pass `callback: 'on_config_update'` or `'on_connection_state_change'` for one label; with no argument it returns the sum. sdk-ruby has no connection-state callback, so `on_connection_state_change` is always 0. An unknown name raises `ArgumentError`. A callback raise is still caught and does not count in `worker_restart_total`; `connection_state` and `last_successful_refresh` do not change. The ERROR line (`[quonfig] onConfigUpdate callback raised: ...`) now includes the first 10 lines of the stack trace. Tests: `test/test_supervisor_callback_throw.rb`, `test/test_on_update_callback_recovery.rb`.
12
+ - **Fix (patch): `Client.new` no longer raises when `Dir.home` fails (qfg-7xlj).** With HOME unset and no passwd entry (some containers), `Dir.home` raises `ArgumentError`, which escaped client construction while dev context was enabled (the default). Dev context is now skipped instead, as in sdk-go. Test: `test/test_dev_context.rb`.
13
+ - **Tests: all workflows run integration-test-data `v2026.10.22.2`.** It adds the regex v1 `enabled` cases (`test/integration/test_enabled.rb` regenerated), the size-caps rule and Test 10, and changes chaos scenario 10 exp[0] to `callback_errors_total`. The chaos probe answers `sdkMetric('quonfig_sdk_callback_errors_total', callback=...)` from the SDK counter, and the scenario 10 `CHAOS_KNOWN_SKIPS` entry is gone, so that row is evaluated instead of SKIPPED.
14
+ - **Tests: the chaos harness reads every asserted value from the SDK, and both chaos suites run integration-test-data `v2026.10.21.1` (qfg-6vno.6, qfg-6vno.7).** The default-suite probe now reports `Client#connection_state` as-is (it used to invent `reconnecting`), the per-layer `worker_restart_total` counters and the SSE dial count straight from the SDK, and no longer counts a callback log line as a restart. A chaos step the rig cannot apply fails the scenario instead of printing a no-op. `scenarios-http-proxy/08` and the caught-`fail` init test are collected and reported SKIPPED with their reason, and scenario 00 (init-failure modes) runs. A new CI step (`scripts/check-itd-refs.sh`) fails when the workflows pin different integration-test-data refs. No SDK change.
15
+
3
16
  ## 1.10.0 - 2026-10-21
4
17
 
5
18
  - **Fix (patch): the init deadline reason follows sdk-go (qfg-9p4i.17.14, design decision 10).** A new init attempt now starts whenever time remains before `init_timeout_ms`, however little. Before, a retry was skipped with under 250ms left and the boot was decided on the previous attempt, so an attempt that fails fast followed by a delivery that blackholes until the deadline raised `reason: :unavailable` where sdk-go raises `InitializationTimeoutError` (`reason: :timeout`). An attempt still in flight at the deadline decides the reason (`:timeout` only if none of its legs answered); a deadline that lands in the backoff is decided by the last completed attempt (`:timeout` only if every leg timed out). Test: `test/test_init_failure.rb`.
data/README.md CHANGED
@@ -708,6 +708,10 @@ reuse the parent's client object in a child process without it.
708
708
  polling). Returns `nil` before the first install. Preserved across `stop`.
709
709
  - `client.connection_state` — a `Symbol` describing the aggregate state:
710
710
  `:initializing`, `:connected`, `:disconnected`, or `:falling_back`.
711
+ - `client.callback_errors_total(callback: 'on_config_update')` — how many
712
+ times your `on_update` block raised. The SDK catches the error, logs it at
713
+ ERROR with its stack, and keeps delivering updates; a raise is not a worker
714
+ restart.
711
715
 
712
716
  > Do not wire `last_successful_refresh` or `connection_state` directly into a Kubernetes liveness probe. These signals are diagnostic, not pass/fail. A liveness probe based on SDK freshness will amplify transient network blips into restart cascades.
713
717
 
@@ -21,6 +21,12 @@ module Quonfig
21
21
  class Client
22
22
  LOG = Quonfig::InternalLogger.new(self)
23
23
 
24
+ # The +callback+ label values of quonfig_sdk_callback_errors_total
25
+ # (supervisor contract). sdk-ruby has no public connection-state callback,
26
+ # so +on_connection_state_change+ stays 0; it is listed so a reader of
27
+ # either name gets the contract's answer.
28
+ CALLBACK_NAMES = %w[on_config_update on_connection_state_change].freeze
29
+
24
30
  # Init fetch loop cadence (qfg-9p4i.10; init-failure design B.3, C).
25
31
  INIT_PROGRESS_EVERY_S = 5
26
32
  INIT_WAIT_PROGRESS_EVERY_S = 30
@@ -97,6 +103,9 @@ module Quonfig
97
103
  @sse_state = :idle
98
104
  @sse_ever_connected = false
99
105
  @fallback_engage_timer = nil
106
+ # quonfig_sdk_callback_errors_total{callback} (supervisor contract Test 5,
107
+ # qfg-6vno.6.1). Guarded by @state_mutex. See #callback_errors_total.
108
+ @callback_errors = CALLBACK_NAMES.to_h { |name| [name, 0] }
100
109
  # Auth-failed regime (rule R2-R4, qfg-goi1.10.26). Set by the poller's
101
110
  # fetch cycle on entry, cleared by the first accepted probe. While set:
102
111
  # no SSE, no polling, one probe per [150s, 300s]. Read by
@@ -561,6 +570,29 @@ module Quonfig
561
570
  end
562
571
  end
563
572
 
573
+ # quonfig_sdk_callback_errors_total counter (supervisor contract Test 5,
574
+ # qfg-6vno.6.1). Counts each call of your +on_update+ block that raised a
575
+ # StandardError the SDK caught and logged at ERROR. Such a raise is not a
576
+ # worker restart: #worker_restart_total does not change, the stream keeps
577
+ # running, and the next update still reaches your block.
578
+ #
579
+ # Pass +callback:+ ('on_config_update' or 'on_connection_state_change',
580
+ # String or Symbol) for one label; the default returns the sum. sdk-ruby
581
+ # has no connection-state callback, so 'on_connection_state_change' is
582
+ # always 0. An unknown name raises ArgumentError. Starts at 0 when the
583
+ # client is built.
584
+ def callback_errors_total(callback: nil)
585
+ @state_mutex.synchronize do
586
+ next @callback_errors.values.sum if callback.nil?
587
+
588
+ name = callback.to_s
589
+ raise ArgumentError, "unknown callback #{callback.inspect}; expected one of #{CALLBACK_NAMES.join(', ')}" unless
590
+ @callback_errors.key?(name)
591
+
592
+ @callback_errors[name]
593
+ end
594
+ end
595
+
564
596
  # Wall-clock time of the last installed envelope (any source: datadir,
565
597
  # initial HTTP fetch, SSE, or polling fallback). +nil+ before the first
566
598
  # install. Preserved after +stop+.
@@ -2007,7 +2039,10 @@ module Quonfig
2007
2039
  begin
2008
2040
  cb.call
2009
2041
  rescue StandardError => e
2010
- LOG.error "[quonfig] onConfigUpdate callback raised: #{e.class}: #{e.message}"
2042
+ # Counted, not a worker restart (supervisor contract Test 5).
2043
+ @state_mutex.synchronize { @callback_errors['on_config_update'] += 1 }
2044
+ bt = (e.backtrace || []).first(10).join("\n ")
2045
+ LOG.error "[quonfig] onConfigUpdate callback raised: #{e.class}: #{e.message}\n #{bt}"
2011
2046
  end
2012
2047
  end
2013
2048
 
@@ -19,6 +19,13 @@ 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).
@@ -186,6 +193,9 @@ module Quonfig
186
193
  # request, write-back after — the network wait happens with no lock held).
187
194
  @etags = {}
188
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
189
199
  @version = nil
190
200
  @environment_id = nil
191
201
  @logger = logger || LOG
@@ -468,7 +478,8 @@ module Quonfig
468
478
  # aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
469
479
  # path the caller passes config_fetch_hedge_abort_ms; on the sequential /
470
480
  # single-URL path it passes config_fetch_timeout_ms.
471
- 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)
472
483
  headers = {}
473
484
  # Per-leg ETag: snapshot this leg's slot before the request (no lock held
474
485
  # during the network wait).
@@ -488,6 +499,7 @@ module Quonfig
488
499
  # generation for the SAME sha; remembering it would 304 that repair.
489
500
  ignored_unversioned = result == :not_modified && extract_generation(envelope.meta || {}) <= 0
490
501
  set_etag_for(index, new_etag) unless ignored_unversioned
502
+ end_oversized_episode(index) if result == :updated
491
503
  # install_envelope returns :not_modified when the reject-older guard drops
492
504
  # an equal/older payload — surface that so the caller doesn't double-count.
493
505
  result == :not_modified ? :not_modified : :updated
@@ -505,6 +517,12 @@ module Quonfig
505
517
  auth: AUTH_STATUSES.include?(response.status.to_i))
506
518
  :failed
507
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
508
526
  rescue NonEnvelopeError => e
509
527
  note.call("non-envelope 200 (#{e.message})")
510
528
  # qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
@@ -545,6 +563,18 @@ module Quonfig
545
563
  cause.is_a?(Timeout::Error) || cause.is_a?(Errno::ETIMEDOUT)
546
564
  end
547
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
+
548
578
  def etag_for(index)
549
579
  @etag_mutex.synchronize { @etags[index || 0] }
550
580
  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
@@ -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
@@ -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.10.0'
4
+ VERSION = '1.11.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.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-21 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