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.
@@ -51,10 +51,17 @@ module Quonfig
51
51
 
52
52
  LOG = Quonfig::InternalLogger.new(self)
53
53
 
54
- # qfg-i5xv: HTTP status codes the SDK classifies as terminal — these will
55
- # not heal by retrying (bad key, revoked permission, missing endpoint).
56
- # Anything else (5xx, 429, network errors) stays on the transient path.
57
- TERMINAL_HTTP_CODES = [401, 403, 404].freeze
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
- code = resp.code.to_i
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
- @logger.error "SSE Streaming Error: #{error.inspect}"
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
- # qfg-i5xv: terminal HTTP failures the SDK will not retry. 401 = bad key,
449
- # 403 = revoked workspace permission, 404 = wrong endpoint / missing
450
- # workspace. A subclass of SSEHTTPStatusError so existing on_error
451
- # callbacks that only check `is_a?(SSEHTTPStatusError)` keep working,
452
- # while customers that want to distinguish (alerting, OpenFeature
453
- # provider error events) can dispatch on the subclass.
454
- class SSEHTTPTerminalError < SSEHTTPStatusError; end
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
- loop do
638
- idx = @buffer.index(/[\r\n]/)
639
- break if idx.nil?
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
- ch = @buffer[idx]
642
- if idx.zero? && ch == "\n" && @last_was_cr
643
- # Dangling LF of a CRLF pair split across chunks — consume and skip.
644
- @last_was_cr = false
645
- @buffer.slice!(0, 1)
646
- next
647
- end
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
- line = @buffer[0, idx].force_encoding('UTF-8')
650
- consume = idx + 1
651
- @last_was_cr = false
652
- if ch == "\r"
653
- if consume == @buffer.bytesize
654
- # CR at end of buffer — could be CRLF split across feeds.
655
- @last_was_cr = true
656
- elsif @buffer[consume] == "\n"
657
- consume += 1
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 client.should_log?(logger_path: path, desired_level: level)
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.7.1'
4
+ VERSION = '1.8.0'
5
5
  end
@@ -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.7.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-03 00:00:00.000000000 Z
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