quonfig 1.8.0 → 1.10.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 +38 -0
- data/README.md +44 -3
- data/lib/quonfig/client.rb +382 -33
- data/lib/quonfig/config_loader.rb +123 -38
- data/lib/quonfig/config_shape.rb +134 -0
- data/lib/quonfig/config_store.rb +65 -19
- data/lib/quonfig/datadir.rb +5 -5
- 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 +1 -1
- data/lib/quonfig/internal_logger.rb +21 -14
- data/lib/quonfig/options.rb +50 -2
- data/lib/quonfig/resolver.rb +26 -17
- data/lib/quonfig/sse_config_client.rb +128 -33
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig/weighted_value_resolver.rb +3 -1
- data/lib/quonfig.rb +2 -0
- metadata +4 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
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
|
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -47,7 +47,33 @@ module Quonfig
|
|
|
47
47
|
# the legacy "return nil if absent" shape can rescue and recover (see
|
|
48
48
|
# IntegrationTestHelpers.assert_resolved, which folds a missing-key
|
|
49
49
|
# raise into the test's expected default).
|
|
50
|
+
# The whole lookup reads one store snapshot (qfg-i86q): the flag, the
|
|
51
|
+
# segments it references and any decryptWith key all come from the same
|
|
52
|
+
# envelope even if an install lands mid-evaluation.
|
|
50
53
|
def get(key, context = nil)
|
|
54
|
+
return get_unpinned(key, context) unless @store.respond_to?(:pinned)
|
|
55
|
+
|
|
56
|
+
@store.pinned { get_unpinned(key, context) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
|
|
60
|
+
# and sdk-go resolver.Resolve:
|
|
61
|
+
# - "provided" + ENV_VAR → read ENV[lookup], coerce to config's valueType
|
|
62
|
+
# - confidential + decryptWith → look up the key config, decrypt
|
|
63
|
+
# - everything else passes through unchanged
|
|
64
|
+
def resolve_value(value, config, context = nil, &on_weighted_index)
|
|
65
|
+
resolve_value_on_path(value, config, context, [], &on_weighted_index)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Integration shims for code that expects a ConfigResolver. Keep these
|
|
69
|
+
# narrow; the real ConfigResolver still owns the production hot path.
|
|
70
|
+
def symbolize_json_names?
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def get_unpinned(key, context)
|
|
51
77
|
config = raw(key)
|
|
52
78
|
raise Quonfig::Errors::MissingDefaultError, key if config.nil?
|
|
53
79
|
|
|
@@ -75,23 +101,6 @@ module Quonfig
|
|
|
75
101
|
)
|
|
76
102
|
end
|
|
77
103
|
|
|
78
|
-
# Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
|
|
79
|
-
# and sdk-go resolver.Resolve:
|
|
80
|
-
# - "provided" + ENV_VAR → read ENV[lookup], coerce to config's valueType
|
|
81
|
-
# - confidential + decryptWith → look up the key config, decrypt
|
|
82
|
-
# - everything else passes through unchanged
|
|
83
|
-
def resolve_value(value, config, context = nil, &on_weighted_index)
|
|
84
|
-
resolve_value_on_path(value, config, context, [], &on_weighted_index)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Integration shims for code that expects a ConfigResolver. Keep these
|
|
88
|
-
# narrow; the real ConfigResolver still owns the production hot path.
|
|
89
|
-
def symbolize_json_names?
|
|
90
|
-
false
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
private
|
|
94
|
-
|
|
95
104
|
# resolve_value plus +key_path+: the config keys already being resolved
|
|
96
105
|
# above this one through decryptWith, so a decryptWith cycle raises a
|
|
97
106
|
# DecryptionError instead of recursing until SystemStackError
|
|
@@ -34,10 +34,12 @@ module Quonfig
|
|
|
34
34
|
#
|
|
35
35
|
# sse_initial_reconnect_delay / sse_max_reconnect_delay: backoff bounds.
|
|
36
36
|
# Each failed reconnect doubles the delay (with +/-50% jitter) up to the
|
|
37
|
-
# max. A
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# connections is normal; the
|
|
37
|
+
# max. A connection that delivers its first valid frame (a config
|
|
38
|
+
# envelope or a keepalive) resets the delay to the initial value. A
|
|
39
|
+
# clean server-initiated FIN after such a frame is "not a failure for
|
|
40
|
+
# backoff purposes" because LBs recycling connections is normal; the
|
|
41
|
+
# reconnect counter still increments. A connection that ends before any
|
|
42
|
+
# frame is a failed attempt (qfg-goi1.10.43).
|
|
41
43
|
def initialize(sse_read_timeout: 90,
|
|
42
44
|
sse_connect_timeout: 10,
|
|
43
45
|
sse_initial_reconnect_delay: 1.0,
|
|
@@ -76,13 +78,22 @@ module Quonfig
|
|
|
76
78
|
|
|
77
79
|
# +on_error+: optional callable invoked on every SSE error edge. Parent
|
|
78
80
|
# Quonfig::Client wires this to drive @sse_state -> :error so that
|
|
79
|
-
# +connection_state+ reflects the disconnect (qfg-47c2.27).
|
|
80
|
-
|
|
81
|
+
# +connection_state+ reflects the disconnect (qfg-47c2.27). A connection
|
|
82
|
+
# that ends before its first valid frame, clean EOF included, is an error
|
|
83
|
+
# edge too (qfg-goi1.10.43).
|
|
84
|
+
#
|
|
85
|
+
# +on_connected+: optional callable invoked once per connection, at its
|
|
86
|
+
# first valid frame (a keepalive, or a data block that validates as a
|
|
87
|
+
# config envelope), before that frame's envelope reaches +on_envelope+.
|
|
88
|
+
# Never on the 200 headers alone (supervisor-test-contract.md "Rule — a
|
|
89
|
+
# connection is `connected` at its first valid frame").
|
|
90
|
+
def initialize(prefab_options, config_loader, options = nil, logger = nil, on_error: nil, on_connected: nil)
|
|
81
91
|
@prefab_options = prefab_options
|
|
82
92
|
@options = options || Options.new
|
|
83
93
|
@config_loader = config_loader
|
|
84
94
|
@logger = logger || LOG
|
|
85
95
|
@on_error = on_error
|
|
96
|
+
@on_connected = on_connected
|
|
86
97
|
|
|
87
98
|
@stopped = Concurrent::AtomicBoolean.new(false)
|
|
88
99
|
@restart_total = 0
|
|
@@ -234,10 +245,18 @@ module Quonfig
|
|
|
234
245
|
end
|
|
235
246
|
first_attempt = false
|
|
236
247
|
|
|
237
|
-
|
|
248
|
+
# First-frame rule (qfg-goi1.10.43): this connection counts as
|
|
249
|
+
# connected only at its first valid frame. Raised once, from the
|
|
250
|
+
# parser, before the frame's envelope is installed.
|
|
251
|
+
frame_seen = false
|
|
252
|
+
on_frame = lambda do
|
|
253
|
+
next if frame_seen
|
|
254
|
+
|
|
255
|
+
frame_seen = true
|
|
256
|
+
invoke_on_connected
|
|
257
|
+
end
|
|
238
258
|
begin
|
|
239
|
-
stream_once do |event|
|
|
240
|
-
connected_at_least_once = true
|
|
259
|
+
ended_url = stream_once(on_frame) do |event|
|
|
241
260
|
# Persist the most recent id so the next reconnect resumes
|
|
242
261
|
# from there via Last-Event-Id. Updated *before* the user
|
|
243
262
|
# callback runs so a raising listener still advances the
|
|
@@ -248,22 +267,30 @@ module Quonfig
|
|
|
248
267
|
# listener must not look like a transport error and trigger
|
|
249
268
|
# a reconnect.
|
|
250
269
|
invoke_on_envelope_safely(on_envelope, event)
|
|
251
|
-
# A connection healthy enough to deliver a real envelope
|
|
252
|
-
# earns a reset of the backoff. Sustained outages never
|
|
253
|
-
# reach this branch (no event ever delivered) so the
|
|
254
|
-
# exponential growth still holds.
|
|
255
|
-
delay = @options.sse_initial_reconnect_delay
|
|
256
270
|
end
|
|
271
|
+
# A clean EOF before any frame (200 headers then close) is a
|
|
272
|
+
# failed attempt, exactly like a connect error: report it so
|
|
273
|
+
# the client leaves :connected and arms the fallback poller.
|
|
274
|
+
# This is what bounds ruby's transparent reconnect: connected
|
|
275
|
+
# survives a clean-EOF reconnect only until the next connection
|
|
276
|
+
# ends frameless.
|
|
277
|
+
handle_frameless_end(ended_url) unless frame_seen || @stopped.value
|
|
257
278
|
rescue StandardError => e
|
|
258
279
|
handle_error(e) unless @stopped.value
|
|
259
280
|
end
|
|
260
281
|
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
# recycling)
|
|
264
|
-
#
|
|
265
|
-
#
|
|
266
|
-
|
|
282
|
+
# The backoff resets only for a connection that delivered a
|
|
283
|
+
# frame; it then reconnects at the initial delay even after a
|
|
284
|
+
# clean FIN (normal LB recycling) or an IO error later in the
|
|
285
|
+
# session. Every other attempt (connect error, non-200, or a
|
|
286
|
+
# frameless 200) doubles it. Sustained outages never deliver a
|
|
287
|
+
# frame, so the exponential growth still holds. Matches sdk-go's
|
|
288
|
+
# connectOnce "frame seen" result.
|
|
289
|
+
delay = if frame_seen
|
|
290
|
+
@options.sse_initial_reconnect_delay
|
|
291
|
+
else
|
|
292
|
+
[delay * 2, @options.sse_max_reconnect_delay].min
|
|
293
|
+
end
|
|
267
294
|
rescue SSEReadDeadlineExceeded => e
|
|
268
295
|
# Paranoid backstop (qfg-tj18). A watchdog raise that landed
|
|
269
296
|
# outside +stream_once+ — typically in +interruptible_sleep+
|
|
@@ -279,7 +306,8 @@ module Quonfig
|
|
|
279
306
|
|
|
280
307
|
# Opens one SSE request and yields each parsed event until the stream
|
|
281
308
|
# ends (clean FIN, error, or stop). Raises on transport errors so the
|
|
282
|
-
# caller can apply backoff. Clean FIN returns without raising.
|
|
309
|
+
# caller can apply backoff. Clean FIN returns the URL without raising.
|
|
310
|
+
# +on_frame+ is called at every valid frame (see EventParser#feed).
|
|
283
311
|
#
|
|
284
312
|
# A watchdog thread closes the socket if no bytes arrive within
|
|
285
313
|
# +sse_read_timeout+. Net::HTTP#read_timeout is NOT reliable for the
|
|
@@ -288,7 +316,7 @@ module Quonfig
|
|
|
288
316
|
# against a configured deadline). sdk-go and sdk-node hit the same
|
|
289
317
|
# gotcha and solve it the same way: per-chunk reset, async close on
|
|
290
318
|
# expiry (chaos scenario 02 — sse_silent_stall).
|
|
291
|
-
def stream_once(&block)
|
|
319
|
+
def stream_once(on_frame = nil, &block)
|
|
292
320
|
base = current_url
|
|
293
321
|
# f05 latch: record if this connection attempt targets a non-primary
|
|
294
322
|
# stream leg. With the pin in #current_url this is structurally false;
|
|
@@ -333,9 +361,14 @@ module Quonfig
|
|
|
333
361
|
Thread.handle_interrupt(SSEReadDeadlineExceeded => :immediate) do
|
|
334
362
|
resp.read_body do |chunk|
|
|
335
363
|
watchdog.reset!
|
|
336
|
-
break
|
|
337
|
-
|
|
338
|
-
|
|
364
|
+
# Never +break+ here (qfg-yrly): a break returns into
|
|
365
|
+
# Net::HTTPResponse#reading_body, which then calls +body+ to
|
|
366
|
+
# drain the rest of the response with no block. On an endless
|
|
367
|
+
# SSE stream that blocks the worker forever. Raising skips the
|
|
368
|
+
# drain, and Net::HTTP closes the socket on the way out.
|
|
369
|
+
raise SSEStopRequested if @stopped.value
|
|
370
|
+
|
|
371
|
+
parser.feed(chunk, on_frame: on_frame, &block)
|
|
339
372
|
end
|
|
340
373
|
end
|
|
341
374
|
# read_body returned cleanly — either a server-initiated FIN, or
|
|
@@ -344,6 +377,9 @@ module Quonfig
|
|
|
344
377
|
# next iteration.
|
|
345
378
|
@logger.debug "SSE stream ended for url #{url}"
|
|
346
379
|
end
|
|
380
|
+
url
|
|
381
|
+
rescue SSEStopRequested
|
|
382
|
+
url
|
|
347
383
|
ensure
|
|
348
384
|
watchdog.stop
|
|
349
385
|
register_active(nil)
|
|
@@ -394,6 +430,25 @@ module Quonfig
|
|
|
394
430
|
@restart_mutex.synchronize { @restart_total += 1 }
|
|
395
431
|
end
|
|
396
432
|
|
|
433
|
+
# A connection that returned 200 and ended cleanly before its first valid
|
|
434
|
+
# frame (qfg-goi1.10.43).
|
|
435
|
+
def handle_frameless_end(url)
|
|
436
|
+
err = SSEFramelessSessionError.new
|
|
437
|
+
@logger.warn "SSE Streaming Error: stream for url #{url} ended before its first frame; " \
|
|
438
|
+
'treating it as a failed attempt'
|
|
439
|
+
invoke_on_error(err)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def invoke_on_connected
|
|
443
|
+
return unless @on_connected
|
|
444
|
+
|
|
445
|
+
begin
|
|
446
|
+
@on_connected.call
|
|
447
|
+
rescue StandardError => e
|
|
448
|
+
@logger.error "SSE on_connected callback raised: #{e.inspect}"
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
397
452
|
def handle_error(error)
|
|
398
453
|
if error.is_a?(SSEHTTPAuthError)
|
|
399
454
|
# Already logged once by check_response_status! (R5).
|
|
@@ -478,11 +533,25 @@ module Quonfig
|
|
|
478
533
|
# no longer stops on these statuses.
|
|
479
534
|
SSEHTTPTerminalError = SSEHTTPAuthError # rubocop:disable Lint/UselessConstantScoping
|
|
480
535
|
|
|
536
|
+
# Passed to on_error when a 200 stream ends cleanly before its first valid
|
|
537
|
+
# frame (qfg-goi1.10.43). Not raised.
|
|
538
|
+
class SSEFramelessSessionError < StandardError
|
|
539
|
+
def initialize(msg = 'SSE stream ended before its first frame')
|
|
540
|
+
super
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
481
544
|
# Raised by the watchdog into the worker thread when the per-chunk
|
|
482
545
|
# read deadline elapses. Caught by run_loop's rescue, indistinguishable
|
|
483
546
|
# from any other transport error for backoff/restart purposes.
|
|
484
547
|
class SSEReadDeadlineExceeded < StandardError; end
|
|
485
548
|
|
|
549
|
+
# Private: raised from inside +read_body+ when +close+ has been called,
|
|
550
|
+
# so the worker leaves the stream without Net::HTTP draining the body
|
|
551
|
+
# (qfg-yrly). Rescued in +stream_once+; never reaches callers.
|
|
552
|
+
class SSEStopRequested < StandardError; end
|
|
553
|
+
private_constant :SSEStopRequested
|
|
554
|
+
|
|
486
555
|
# Background watchdog that interrupts the worker thread if no chunk
|
|
487
556
|
# arrives within +deadline_s+ seconds. Uses Thread#raise — the only
|
|
488
557
|
# reliable cross-platform way to unblock a Ruby thread blocked in
|
|
@@ -560,7 +629,7 @@ module Quonfig
|
|
|
560
629
|
# - chunks that split a line mid-way
|
|
561
630
|
# - any of CR / LF / CRLF as line terminators
|
|
562
631
|
# - +data:+, +data: + (optional space per SSE spec)
|
|
563
|
-
# - +:comment+ lines (keepalives
|
|
632
|
+
# - +:comment+ lines (keepalives; a comment-only block is a frame)
|
|
564
633
|
# - multi-line +data:+ (concatenated with +\n+, per spec)
|
|
565
634
|
# Ignores +event:+ and +retry:+ — api-delivery does not emit them and the
|
|
566
635
|
# Quonfig wire contract does not honor reconnect-time directives.
|
|
@@ -573,16 +642,34 @@ module Quonfig
|
|
|
573
642
|
@data = +''
|
|
574
643
|
@have_data = false
|
|
575
644
|
@id = nil
|
|
645
|
+
@saw_comment = false
|
|
646
|
+
@saw_field = false
|
|
576
647
|
end
|
|
577
648
|
|
|
578
|
-
|
|
649
|
+
# Yields each config envelope. +on_frame+ (optional) is called at every
|
|
650
|
+
# valid frame, before that frame's envelope is yielded. A valid frame is
|
|
651
|
+
# a block ended by a blank line that is either comment-only (the
|
|
652
|
+
# +: keepalive+ heartbeat; +:+ alone counts) or carries +data:+ that
|
|
653
|
+
# decodes and validates as a config envelope. A block cut off by EOF, a
|
|
654
|
+
# malformed or non-envelope data block, an +id:+-only block and a bare
|
|
655
|
+
# blank line are not frames (qfg-goi1.10.43).
|
|
656
|
+
def feed(chunk, on_frame: nil)
|
|
579
657
|
@reader.feed(chunk) do |line|
|
|
580
658
|
if line.empty?
|
|
659
|
+
comment_only = @saw_comment && !@saw_field
|
|
660
|
+
@saw_comment = false
|
|
661
|
+
@saw_field = false
|
|
581
662
|
event = flush
|
|
582
|
-
|
|
663
|
+
if event
|
|
664
|
+
on_frame&.call
|
|
665
|
+
yield event
|
|
666
|
+
elsif comment_only
|
|
667
|
+
on_frame&.call
|
|
668
|
+
end
|
|
583
669
|
elsif line.start_with?(':')
|
|
584
|
-
|
|
670
|
+
@saw_comment = true
|
|
585
671
|
else
|
|
672
|
+
@saw_field = true
|
|
586
673
|
process_field(line)
|
|
587
674
|
end
|
|
588
675
|
end
|
|
@@ -637,10 +724,18 @@ module Quonfig
|
|
|
637
724
|
return nil
|
|
638
725
|
end
|
|
639
726
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
727
|
+
meta = parsed['meta'] || {}
|
|
728
|
+
# Skip one malformed config, keep the rest (qfg-krjt); an event with
|
|
729
|
+
# no loadable config is dropped like malformed JSON.
|
|
730
|
+
begin
|
|
731
|
+
configs = Quonfig::ConfigShape.filter(parsed['configs'] || [], logger: @logger || LOG,
|
|
732
|
+
version: meta['version'])
|
|
733
|
+
rescue Quonfig::ConfigShape::AllInvalidError => e
|
|
734
|
+
(@logger || LOG).error "SSE Streaming Error: #{e.message}; event dropped"
|
|
735
|
+
return nil
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
envelope = Quonfig::ConfigEnvelope.new(configs: configs, meta: meta)
|
|
644
739
|
StreamEvent.new(envelope, id, data)
|
|
645
740
|
end
|
|
646
741
|
end
|
data/lib/quonfig/version.rb
CHANGED
|
@@ -43,7 +43,9 @@ module Quonfig
|
|
|
43
43
|
private
|
|
44
44
|
|
|
45
45
|
def weight_of(variant)
|
|
46
|
-
|
|
46
|
+
# A missing weight counts as 0 (qfg-krjt); a present one is checked to
|
|
47
|
+
# be an Integer at decode (ConfigShape).
|
|
48
|
+
variant[:weight] || variant['weight'] || 0
|
|
47
49
|
end
|
|
48
50
|
end
|
|
49
51
|
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'
|
|
@@ -50,6 +51,7 @@ require 'quonfig/config_store'
|
|
|
50
51
|
require 'quonfig/evaluator'
|
|
51
52
|
require 'quonfig/resolver'
|
|
52
53
|
require 'quonfig/config_envelope'
|
|
54
|
+
require 'quonfig/config_shape'
|
|
53
55
|
require 'quonfig/config_loader'
|
|
54
56
|
require 'quonfig/datadir'
|
|
55
57
|
require 'listen'
|
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.10.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-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -90,6 +90,7 @@ files:
|
|
|
90
90
|
- lib/quonfig/client.rb
|
|
91
91
|
- lib/quonfig/config_envelope.rb
|
|
92
92
|
- lib/quonfig/config_loader.rb
|
|
93
|
+
- lib/quonfig/config_shape.rb
|
|
93
94
|
- lib/quonfig/config_store.rb
|
|
94
95
|
- lib/quonfig/context.rb
|
|
95
96
|
- lib/quonfig/datadir.rb
|
|
@@ -100,6 +101,7 @@ files:
|
|
|
100
101
|
- lib/quonfig/error.rb
|
|
101
102
|
- lib/quonfig/errors/decryption_error.rb
|
|
102
103
|
- lib/quonfig/errors/env_var_parse_error.rb
|
|
104
|
+
- lib/quonfig/errors/initialization_error.rb
|
|
103
105
|
- lib/quonfig/errors/initialization_timeout_error.rb
|
|
104
106
|
- lib/quonfig/errors/invalid_environment_error.rb
|
|
105
107
|
- lib/quonfig/errors/invalid_sdk_key_error.rb
|