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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +4 -0
- data/lib/quonfig/client.rb +36 -1
- data/lib/quonfig/config_loader.rb +31 -1
- data/lib/quonfig/dev_context.rb +8 -1
- data/lib/quonfig/http_connection.rb +117 -2
- data/lib/quonfig/sse_config_client.rb +62 -3
- data/lib/quonfig/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d1016bb1930249b2a74a4f670e25cb2143ff0e664edd54062c585437a85527a
|
|
4
|
+
data.tar.gz: 8e2e0844f5853cf5c1d27f45054e71dc6cda6f8f5aa398468c982a485659d1b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
data/lib/quonfig/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-10-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|