quonfig 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +48 -3
- data/lib/quonfig/client.rb +406 -29
- data/lib/quonfig/config_loader.rb +137 -30
- data/lib/quonfig/dev_context.rb +8 -1
- data/lib/quonfig/errors/initialization_error.rb +31 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +9 -4
- data/lib/quonfig/evaluation_details.rb +3 -0
- data/lib/quonfig/http_connection.rb +118 -3
- data/lib/quonfig/internal_logger.rb +21 -14
- data/lib/quonfig/options.rb +50 -2
- data/lib/quonfig/sse_config_client.rb +62 -3
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
data/lib/quonfig/options.rb
CHANGED
|
@@ -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::
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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
|
|
@@ -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
|