quonfig 1.7.1 → 1.8.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 +39 -0
- data/README.md +59 -23
- data/lib/quonfig/client.rb +147 -43
- data/lib/quonfig/config_loader.rb +129 -26
- data/lib/quonfig/datadir.rb +5 -2
- data/lib/quonfig/dev_context.rb +2 -1
- data/lib/quonfig/errors/env_var_parse_error.rb +11 -2
- data/lib/quonfig/errors/type_mismatch_error.rb +5 -1
- data/lib/quonfig/evaluator.rb +81 -15
- data/lib/quonfig/quonfig.rb +8 -0
- data/lib/quonfig/resolver.rb +22 -7
- data/lib/quonfig/semantic_logger_filter.rb +6 -0
- data/lib/quonfig/semver.rb +11 -1
- data/lib/quonfig/sse_config_client.rb +101 -54
- data/lib/quonfig/stdlib_formatter.rb +12 -1
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +55 -1
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig/waits.rb +30 -0
- data/lib/quonfig/worker_supervisor.rb +5 -0
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
|
@@ -51,10 +51,17 @@ module Quonfig
|
|
|
51
51
|
|
|
52
52
|
LOG = Quonfig::InternalLogger.new(self)
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
54
|
+
# Auth-class statuses (rule R1, qfg-goi1.10.26). On the stream they keep the
|
|
55
|
+
# normal reconnect ladder: SSE alone never stops retrying and never enters
|
|
56
|
+
# auth-failed (R2); the client's fallback poller decides that from a whole
|
|
57
|
+
# fetch cycle. They only get their own log line: WARN once per status,
|
|
58
|
+
# re-armed by a 200, DEBUG otherwise (R5).
|
|
59
|
+
AUTH_HTTP_CODES = [401, 403, 404, 410].freeze
|
|
60
|
+
|
|
61
|
+
# @deprecated since 1.8.0. qfg-i5xv's "terminal" codes. Nothing is terminal
|
|
62
|
+
# any more (the latch is gone, qfg-goi1.10.26); kept as an alias of
|
|
63
|
+
# AUTH_HTTP_CODES for code that references the constant.
|
|
64
|
+
TERMINAL_HTTP_CODES = AUTH_HTTP_CODES
|
|
58
65
|
|
|
59
66
|
# qfg-6y44: headroom added to +sse_read_timeout+ when configuring
|
|
60
67
|
# Net::HTTP#read_timeout. The ReadDeadlineWatchdog already covers both the
|
|
@@ -88,6 +95,8 @@ module Quonfig
|
|
|
88
95
|
@active_http = nil
|
|
89
96
|
|
|
90
97
|
@last_event_id = nil
|
|
98
|
+
# Auth-class statuses already logged at WARN since the last 200 (R5).
|
|
99
|
+
@auth_warned = Set.new
|
|
91
100
|
# qfg-7h5d.1.9 / qfg-41nh.6: latches true if a connection attempt ever
|
|
92
101
|
# targets a non-primary sse_api_urls leg. The failover epic asserts SSE
|
|
93
102
|
# does NOT fail over to the secondary (f05) — the stream is PINNED to
|
|
@@ -220,7 +229,7 @@ module Quonfig
|
|
|
220
229
|
begin
|
|
221
230
|
unless first_attempt
|
|
222
231
|
increment_restart!
|
|
223
|
-
interruptible_sleep(jittered(delay))
|
|
232
|
+
interruptible_sleep(jittered(Quonfig::Waits.duration(:sse, delay)))
|
|
224
233
|
break if @stopped.value
|
|
225
234
|
end
|
|
226
235
|
first_attempt = false
|
|
@@ -314,26 +323,7 @@ module Quonfig
|
|
|
314
323
|
|
|
315
324
|
begin
|
|
316
325
|
http.request(req) do |resp|
|
|
317
|
-
|
|
318
|
-
if TERMINAL_HTTP_CODES.include?(code)
|
|
319
|
-
# qfg-i5xv: 401/403/404 will not heal by retrying — bad key,
|
|
320
|
-
# revoked permission, or wrong endpoint. Mark stopped *before*
|
|
321
|
-
# invoking on_error so the loop's terminal-error branch is
|
|
322
|
-
# already locked in if the parent callback inspects state, and
|
|
323
|
-
# so the inner rescue's `handle_error(e) unless @stopped.value`
|
|
324
|
-
# guard suppresses a second on_error edge.
|
|
325
|
-
err = SSEHTTPTerminalError.new(code)
|
|
326
|
-
@logger.error "SSE Streaming Terminal Error: HTTP #{code} for url #{url}; will not retry"
|
|
327
|
-
@stopped.make_true
|
|
328
|
-
invoke_on_error(err)
|
|
329
|
-
raise err
|
|
330
|
-
end
|
|
331
|
-
if code != 200
|
|
332
|
-
err = SSEHTTPStatusError.new(code)
|
|
333
|
-
@logger.error "SSE Streaming Error: HTTP #{code} for url #{url}"
|
|
334
|
-
invoke_on_error(err)
|
|
335
|
-
raise err
|
|
336
|
-
end
|
|
326
|
+
check_response_status!(resp.code.to_i, url)
|
|
337
327
|
|
|
338
328
|
parser = EventParser.new
|
|
339
329
|
# qfg-tj18: run_loop wraps the body in +:on_blocking+ which
|
|
@@ -365,6 +355,35 @@ module Quonfig
|
|
|
365
355
|
end
|
|
366
356
|
end
|
|
367
357
|
|
|
358
|
+
# Classify the stream response. Raises for any non-200 so run_loop applies
|
|
359
|
+
# its backoff.
|
|
360
|
+
#
|
|
361
|
+
# Auth class (401/403/404/410, qfg-goi1.10.26): no latch. The normal
|
|
362
|
+
# ladder applies and run_loop's handle_error fires on_error once, so the
|
|
363
|
+
# client schedules the fallback poller exactly as for any drop; that
|
|
364
|
+
# poller's fetch cycle is what decides auth-failed (R2). Logged WARN once
|
|
365
|
+
# per status until a 200 re-arms it, DEBUG otherwise (R5), so a revoked
|
|
366
|
+
# key does not log two ERRORs per attempt for the ~2 min before entry.
|
|
367
|
+
def check_response_status!(code, url)
|
|
368
|
+
if AUTH_HTTP_CODES.include?(code)
|
|
369
|
+
if @auth_warned.add?(code)
|
|
370
|
+
@logger.warn "SSE Streaming Error: HTTP #{code} for url #{url}; " \
|
|
371
|
+
'the SDK key may be invalid or revoked (check the SDK key); retrying'
|
|
372
|
+
else
|
|
373
|
+
@logger.debug "SSE Streaming Error: HTTP #{code} for url #{url}; retrying"
|
|
374
|
+
end
|
|
375
|
+
raise SSEHTTPAuthError, code
|
|
376
|
+
end
|
|
377
|
+
if code != 200
|
|
378
|
+
err = SSEHTTPStatusError.new(code)
|
|
379
|
+
@logger.error "SSE Streaming Error: HTTP #{code} for url #{url}"
|
|
380
|
+
invoke_on_error(err)
|
|
381
|
+
raise err
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
@auth_warned.clear
|
|
385
|
+
end
|
|
386
|
+
|
|
368
387
|
# Track the active connection so close() can interrupt a blocked
|
|
369
388
|
# read_body from another thread. Guarded by @conn_mutex.
|
|
370
389
|
def register_active(http)
|
|
@@ -376,7 +395,12 @@ module Quonfig
|
|
|
376
395
|
end
|
|
377
396
|
|
|
378
397
|
def handle_error(error)
|
|
379
|
-
|
|
398
|
+
if error.is_a?(SSEHTTPAuthError)
|
|
399
|
+
# Already logged once by check_response_status! (R5).
|
|
400
|
+
@logger.debug "SSE Streaming Error: #{error.inspect}"
|
|
401
|
+
else
|
|
402
|
+
@logger.error "SSE Streaming Error: #{error.inspect}"
|
|
403
|
+
end
|
|
380
404
|
invoke_on_error(error)
|
|
381
405
|
end
|
|
382
406
|
|
|
@@ -445,13 +469,14 @@ module Quonfig
|
|
|
445
469
|
end
|
|
446
470
|
end
|
|
447
471
|
|
|
448
|
-
#
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
#
|
|
454
|
-
|
|
472
|
+
# An auth-class (401/403/404/410) stream response (qfg-goi1.10.26). A
|
|
473
|
+
# subclass of SSEHTTPStatusError so on_error callbacks that only check
|
|
474
|
+
# `is_a?(SSEHTTPStatusError)` keep working. The stream keeps retrying.
|
|
475
|
+
class SSEHTTPAuthError < SSEHTTPStatusError; end
|
|
476
|
+
|
|
477
|
+
# @deprecated since 1.8.0: qfg-i5xv's name for SSEHTTPAuthError. The stream
|
|
478
|
+
# no longer stops on these statuses.
|
|
479
|
+
SSEHTTPTerminalError = SSEHTTPAuthError # rubocop:disable Lint/UselessConstantScoping
|
|
455
480
|
|
|
456
481
|
# Raised by the watchdog into the worker thread when the per-chunk
|
|
457
482
|
# read deadline elapses. Caught by run_loop's rescue, indistinguishable
|
|
@@ -630,37 +655,59 @@ module Quonfig
|
|
|
630
655
|
def initialize
|
|
631
656
|
@buffer = +''.b
|
|
632
657
|
@last_was_cr = false
|
|
658
|
+
# Bytes of @buffer already scanned without finding a terminator, so
|
|
659
|
+
# the next feed resumes there instead of rescanning from byte 0
|
|
660
|
+
# (qfg-goi1.10.2; O(n^2) on a long line in small chunks).
|
|
661
|
+
@scanned = 0
|
|
633
662
|
end
|
|
634
663
|
|
|
664
|
+
# Lines are sliced out by position and the consumed prefix is dropped
|
|
665
|
+
# once per feed (in +ensure+, so a raising block does not see a line
|
|
666
|
+
# twice), not once per line.
|
|
635
667
|
def feed(chunk)
|
|
636
668
|
@buffer << chunk.b
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
669
|
+
pos = 0
|
|
670
|
+
begin
|
|
671
|
+
loop do
|
|
672
|
+
idx = @buffer.index(/[\r\n]/, [pos, @scanned].max)
|
|
673
|
+
if idx.nil?
|
|
674
|
+
@scanned = @buffer.bytesize
|
|
675
|
+
break
|
|
676
|
+
end
|
|
640
677
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
678
|
+
ch = @buffer.getbyte(idx)
|
|
679
|
+
if idx == pos && ch == LF && @last_was_cr
|
|
680
|
+
# Dangling LF of a CRLF pair split across chunks — consume and skip.
|
|
681
|
+
@last_was_cr = false
|
|
682
|
+
pos += 1
|
|
683
|
+
next
|
|
684
|
+
end
|
|
648
685
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
686
|
+
line = @buffer.byteslice(pos, idx - pos).force_encoding('UTF-8')
|
|
687
|
+
consume_to = idx + 1
|
|
688
|
+
@last_was_cr = false
|
|
689
|
+
if ch == CR
|
|
690
|
+
if consume_to == @buffer.bytesize
|
|
691
|
+
# CR at end of buffer — could be CRLF split across feeds.
|
|
692
|
+
@last_was_cr = true
|
|
693
|
+
elsif @buffer.getbyte(consume_to) == LF
|
|
694
|
+
consume_to += 1
|
|
695
|
+
end
|
|
658
696
|
end
|
|
697
|
+
pos = consume_to
|
|
698
|
+
yield line
|
|
699
|
+
end
|
|
700
|
+
ensure
|
|
701
|
+
if pos.positive?
|
|
702
|
+
@buffer = @buffer.byteslice(pos, @buffer.bytesize - pos)
|
|
703
|
+
@scanned = [@scanned - pos, 0].max
|
|
659
704
|
end
|
|
660
|
-
@buffer.slice!(0, consume)
|
|
661
|
-
yield line
|
|
662
705
|
end
|
|
663
706
|
end
|
|
707
|
+
|
|
708
|
+
CR = 13
|
|
709
|
+
LF = 10
|
|
710
|
+
private_constant :CR, :LF
|
|
664
711
|
end
|
|
665
712
|
end
|
|
666
713
|
end
|
|
@@ -74,7 +74,7 @@ module Quonfig
|
|
|
74
74
|
path = logger_name || progname
|
|
75
75
|
level = SEVERITY_TO_LEVEL[severity.to_s.upcase] || :info
|
|
76
76
|
|
|
77
|
-
if
|
|
77
|
+
if emit?(client, path, level)
|
|
78
78
|
format_record(severity, datetime, progname, msg)
|
|
79
79
|
else
|
|
80
80
|
''
|
|
@@ -82,6 +82,17 @@ module Quonfig
|
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# The formatter runs inside the host app's Logger calls and must never
|
|
86
|
+
# raise out of them. A resolution error (unset ENV_VAR, decryption
|
|
87
|
+
# failure, ...) emits the line, matching SemanticLoggerFilter
|
|
88
|
+
# (qfg-goi1.2.11, qfg-goi1.2.28). Nothing is logged here, since logging
|
|
89
|
+
# from inside a formatter can recurse.
|
|
90
|
+
def self.emit?(client, path, level)
|
|
91
|
+
client.should_log?(logger_path: path, desired_level: level)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
85
96
|
# Default record formatter. Matches ::Logger::Formatter's general shape
|
|
86
97
|
# ("I, [timestamp pid] SEVERITY -- progname: message") but without the
|
|
87
98
|
# process-id first-letter noise so output is readable in tests and
|
|
@@ -15,6 +15,10 @@ module Quonfig
|
|
|
15
15
|
# prunes expired entries, and is not recorded if it is still full.
|
|
16
16
|
SEEN_CAP = 100_000
|
|
17
17
|
|
|
18
|
+
# Max nesting depth copied from a context value; deeper levels are left
|
|
19
|
+
# out. Guards the recursive copy against stack exhaustion.
|
|
20
|
+
MAX_SNAPSHOT_DEPTH = 32
|
|
21
|
+
|
|
18
22
|
attr_reader :data, :cache
|
|
19
23
|
|
|
20
24
|
def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS, seen_cap: SEEN_CAP)
|
|
@@ -38,7 +42,7 @@ module Quonfig
|
|
|
38
42
|
return unless room_in_cache?
|
|
39
43
|
|
|
40
44
|
@cache.set(key)
|
|
41
|
-
@data.push([Quonfig::TimeHelpers.now_in_ms, context])
|
|
45
|
+
@data.push([Quonfig::TimeHelpers.now_in_ms, snapshot(context)])
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def prepare_data
|
|
@@ -65,6 +69,19 @@ module Quonfig
|
|
|
65
69
|
|
|
66
70
|
private
|
|
67
71
|
|
|
72
|
+
# Copy +context+ into plain JSON-safe data at record time (qfg-goi1.2.35).
|
|
73
|
+
# The caller owns its nested Hashes/Arrays and may mutate them after
|
|
74
|
+
# get() returns, while the window is serialized later on the reporter
|
|
75
|
+
# thread. NaN/Infinity become nil, and an edge that closes a cycle is
|
|
76
|
+
# left out, so one bad value no longer makes JSON.generate raise and drop
|
|
77
|
+
# the whole window. Any other object is rendered with #to_s, which is
|
|
78
|
+
# what JSON.generate already sent for it (a UUID-like key keeps the
|
|
79
|
+
# example reportable); one whose #to_s raises is left out.
|
|
80
|
+
def snapshot(context)
|
|
81
|
+
named = contexts_to_list(context)
|
|
82
|
+
named.to_h { |entry| [entry['type'], snapshot_value(entry['values'], 0, {})] }
|
|
83
|
+
end
|
|
84
|
+
|
|
68
85
|
def room_in_cache?
|
|
69
86
|
return true if @cache.data.size < @seen_cap
|
|
70
87
|
|
|
@@ -102,6 +119,43 @@ module Quonfig
|
|
|
102
119
|
end
|
|
103
120
|
end
|
|
104
121
|
|
|
122
|
+
OMIT = Object.new.freeze
|
|
123
|
+
private_constant :OMIT
|
|
124
|
+
|
|
125
|
+
def snapshot_value(value, depth, ancestors)
|
|
126
|
+
case value
|
|
127
|
+
when nil, true, false, Integer then value
|
|
128
|
+
when Float then value.finite? ? value : nil
|
|
129
|
+
when String then value.frozen? ? value : value.dup
|
|
130
|
+
when Symbol then value.to_s
|
|
131
|
+
when Hash, Array then snapshot_container(value, depth, ancestors)
|
|
132
|
+
else value.to_s
|
|
133
|
+
end
|
|
134
|
+
rescue StandardError
|
|
135
|
+
OMIT
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# +ancestors+ holds the containers on the current path only, so the same
|
|
139
|
+
# value reached twice without a cycle is still copied both times.
|
|
140
|
+
def snapshot_container(value, depth, ancestors)
|
|
141
|
+
id = value.object_id
|
|
142
|
+
return OMIT if depth >= MAX_SNAPSHOT_DEPTH || ancestors.key?(id)
|
|
143
|
+
|
|
144
|
+
ancestors[id] = true
|
|
145
|
+
begin
|
|
146
|
+
if value.is_a?(Hash)
|
|
147
|
+
value.each_with_object({}) do |(k, v), acc|
|
|
148
|
+
copied = snapshot_value(v, depth + 1, ancestors)
|
|
149
|
+
acc[k.to_s] = copied unless copied.equal?(OMIT)
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
value.map { |v| snapshot_value(v, depth + 1, ancestors) }.reject { |v| v.equal?(OMIT) }
|
|
153
|
+
end
|
|
154
|
+
ensure
|
|
155
|
+
ancestors.delete(id)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
105
159
|
def stringify_values(hash)
|
|
106
160
|
return {} unless hash.is_a?(Hash)
|
|
107
161
|
|
data/lib/quonfig/version.rb
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Internal test seam for the SDK's background waits (qfg-goi1.10.26).
|
|
5
|
+
#
|
|
6
|
+
# Every long background wait goes through here with a kind:
|
|
7
|
+
# :sse - SSE reconnect backoff (the unjittered base delay)
|
|
8
|
+
# :engage - fallback-poller grace after a mid-life SSE drop
|
|
9
|
+
# :poll - fallback / polling-mode poll interval
|
|
10
|
+
# :probe - auth-failed probe period (rule R3)
|
|
11
|
+
#
|
|
12
|
+
# A hook, when set, sees the REQUESTED seconds and returns the seconds to
|
|
13
|
+
# actually wait. Tests use it to record the requested values and compress
|
|
14
|
+
# time (supervisor-test-contract.md Test 8). It is not a customer option and
|
|
15
|
+
# is nil in production.
|
|
16
|
+
module Waits
|
|
17
|
+
class << self
|
|
18
|
+
attr_accessor :hook
|
|
19
|
+
|
|
20
|
+
def duration(kind, seconds)
|
|
21
|
+
h = hook
|
|
22
|
+
h ? h.call(kind, seconds) : seconds
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sleep(kind, seconds)
|
|
26
|
+
Kernel.sleep(duration(kind, seconds))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -95,6 +95,11 @@ module Quonfig
|
|
|
95
95
|
t
|
|
96
96
|
end
|
|
97
97
|
return if thread.nil?
|
|
98
|
+
# Called from the worker itself (the poller leaving auth-failed hands
|
|
99
|
+
# back to SSE, qfg-goi1.10.26): the flag is enough. The worker returns,
|
|
100
|
+
# run_loop sees stop? and exits without a restart. Raising into or
|
|
101
|
+
# joining the current thread would kill it mid-handoff.
|
|
102
|
+
return if thread == Thread.current
|
|
98
103
|
|
|
99
104
|
raise_shutdown(thread)
|
|
100
105
|
thread.join(SHUTDOWN_TIMEOUT_SEC)
|
data/lib/quonfig.rb
CHANGED
|
@@ -29,6 +29,7 @@ require 'quonfig/evaluation'
|
|
|
29
29
|
require 'quonfig/evaluation_details'
|
|
30
30
|
require 'quonfig/encryption'
|
|
31
31
|
require 'quonfig/exponential_backoff'
|
|
32
|
+
require 'quonfig/waits'
|
|
32
33
|
require 'quonfig/worker_supervisor'
|
|
33
34
|
require 'quonfig/periodic_sync'
|
|
34
35
|
require 'quonfig/errors/initialization_timeout_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.8.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-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -136,6 +136,7 @@ files:
|
|
|
136
136
|
- lib/quonfig/time_helpers.rb
|
|
137
137
|
- lib/quonfig/types.rb
|
|
138
138
|
- lib/quonfig/version.rb
|
|
139
|
+
- lib/quonfig/waits.rb
|
|
139
140
|
- lib/quonfig/weighted_value_resolver.rb
|
|
140
141
|
- lib/quonfig/worker_supervisor.rb
|
|
141
142
|
- quonfig.gemspec
|