featureflip 2.2.0 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fdefb980d91435aca01a6f6344804e172dedef867fd826c5ac9a017ca6416ca
4
- data.tar.gz: d98b0cda83055b8b984b2ea370b9717f6800dd44202ddacf21944fa19b0dc194
3
+ metadata.gz: fcc479d4aca550b2c17f8a988df92f7a7406a8712765767311f9f712c56f1fd1
4
+ data.tar.gz: c9281f409c2736e2c6b929cd39fb433e38969b496b59d7387b61de7ba0dc6ce5
5
5
  SHA512:
6
- metadata.gz: 360a193a8d0557ba71c45ce1c3f341d008f34f34996434328f7aa2c1da3eee9ef7cc531953ffbd21b8f83fa2674e750139c456854e59a322c0099fe04f76cc0f
7
- data.tar.gz: f4f292e068e49a8688b94c530d5cce50a060f97c57742e5aaee3f4db02aa469b0623c8b3c95065c6293e0452b71a58f3dc57212b649bc78e66044dad025c24a6
6
+ metadata.gz: 8935a5f95ab48a7ce6c05af6a832aa8eca8cd1b1d5e66c20475f4a7a9fec38ec186313269f98bbd4d766eca22ff36dec814854beee2bfca77229b3e7a62f0067
7
+ data.tar.gz: 222fb9607a98518003f733a9abfcb3bb65143b33b05a5d4e7bf281e875573c33a4a28a83275a286328cb986cbdf622a1155f75e29188b5729a3f32a2f2ce906a
@@ -5,7 +5,38 @@ require "json"
5
5
  module Featureflip
6
6
  module DataSource
7
7
  class StreamingHandler
8
- def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_give_up: nil)
8
+ # Raised into the streaming thread by #stop to interrupt a blocking read
9
+ # (Thread#wakeup only wakes a *sleeping* thread; it can't interrupt an
10
+ # MRI IO read — Thread#raise can). Inherits from Exception, not
11
+ # StandardError, so it bypasses handle_event's/run's generic `rescue
12
+ # StandardError` arms (which would otherwise swallow a stop mid-event and
13
+ # leave the thread blocking) and is only caught by the explicit
14
+ # `rescue StreamStopped`.
15
+ class StreamStopped < Exception; end # rubocop:disable Lint/InheritException
16
+
17
+ # The server sends a keep-alive ping this often; a finite read timeout at
18
+ # or below this interval would sever a healthy stream.
19
+ SERVER_PING_INTERVAL_SECONDS = 30
20
+
21
+ # Client-side liveness watchdog. net/http gives us no separate heartbeat,
22
+ # so the read timeout IS the watchdog: if no data (not even a ping) arrives
23
+ # for this long the socket is treated as dead — a half-open connection
24
+ # (LB/NAT idle-drop or a partition with no FIN/RST) — and the blocking read
25
+ # raises Net::ReadTimeout, which drives reconnect/backoff/polling. Set to
26
+ # 3× the ping (3 missed pings) so it never severs a healthy stream but still
27
+ # detects a dead socket within a bounded time. MUST stay finite and
28
+ # > SERVER_PING_INTERVAL_SECONDS. (The rest of the family runs an infinite
29
+ # read timeout — java readTimeout(0) / python read=None / csharp #1526 —
30
+ # and has the same latent half-open hole; ruby closes it here.)
31
+ STREAM_READ_TIMEOUT = SERVER_PING_INTERVAL_SECONDS * 3
32
+
33
+ # Base reconnect backoff; also the floor applied after a healthy stream
34
+ # closes cleanly, so even an accept-then-immediately-close server is
35
+ # throttled instead of busy-looping.
36
+ RECONNECT_BASE_DELAY_SECONDS = 1
37
+ MAX_BACKOFF_SECONDS = 30
38
+
39
+ def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_sync: nil, on_give_up: nil)
9
40
  @sdk_key = sdk_key
10
41
  @config = config
11
42
  @http_client = http_client
@@ -13,12 +44,17 @@ module Featureflip
13
44
  @on_flag_deleted = on_flag_deleted
14
45
  @on_segment_updated = on_segment_updated
15
46
  @on_error = on_error
47
+ @on_sync = on_sync
16
48
  @on_give_up = on_give_up
17
49
  @stop_flag = false
18
50
  @thread = nil
19
51
  @retry_count = 0
20
52
  @current_event_type = nil
21
53
  @current_data = nil
54
+ @line_buffer = String.new # ASCII-8BIT: raw read_body bytes concatenate safely
55
+ @delivered_frame = false
56
+ @wake_mutex = Mutex.new
57
+ @wake_cond = ConditionVariable.new
22
58
  end
23
59
 
24
60
  def start
@@ -29,9 +65,21 @@ module Featureflip
29
65
 
30
66
  def stop
31
67
  @stop_flag = true
32
- @thread&.wakeup rescue nil
33
- @thread&.join(5)
68
+ # Wake an in-progress backoff wait.
69
+ @wake_mutex.synchronize { @wake_cond.broadcast }
70
+
71
+ thread = @thread
34
72
  @thread = nil
73
+ return unless thread
74
+
75
+ # Interrupt a thread blocked in read_body. Guard the raise: the thread may
76
+ # finish between the alive? check and the raise (ThreadError on a dead one).
77
+ begin
78
+ thread.raise(StreamStopped.new) if thread.alive?
79
+ rescue ThreadError
80
+ # Thread already finished — nothing to interrupt.
81
+ end
82
+ thread.join(5)
35
83
  end
36
84
 
37
85
  private
@@ -40,26 +88,57 @@ module Featureflip
40
88
  until @stop_flag
41
89
  begin
42
90
  connect
91
+ rescue StreamStopped
92
+ break
43
93
  rescue StandardError => e
44
94
  break if @stop_flag
45
95
  @on_error.call(e)
96
+ end
97
+ break if @stop_flag
98
+
99
+ # Consult @delivered_frame (the instance var), NOT connect's return
100
+ # value: connect only *returns* on a clean EOF, but the common stream
101
+ # terminations (the liveness-watchdog Net::ReadTimeout, ECONNRESET,
102
+ # IOError) RAISE — and a session that delivered frames before raising
103
+ # must still count as healthy, or transient blips accumulate and
104
+ # wrongly degrade a good stream to polling. @delivered_frame survives
105
+ # the exception; connect resets it to false at the top of each attempt.
106
+ if @delivered_frame
107
+ # The stream genuinely stayed up (delivered ≥1 frame — the server
108
+ # sends `sync` first). Reset the failure counter.
109
+ @retry_count = 0
110
+ else
111
+ # A clean EOF (no frame) is treated as a failure for backoff/escalation
112
+ # purposes — otherwise an accept-then-close server never accumulates
113
+ # toward max_stream_retries and never degrades to polling.
46
114
  @retry_count += 1
47
115
  if @retry_count > @config.max_stream_retries
48
116
  @on_give_up&.call
49
117
  break
50
118
  end
51
- delay = [2**(@retry_count - 1), 30].min
52
- sleep(delay)
53
119
  end
120
+
121
+ # Back off before every reconnect, including after a clean EOF, so we
122
+ # never zero-delay busy-loop against a flapping endpoint.
123
+ backoff_wait(backoff_delay(@retry_count))
54
124
  end
125
+ rescue StreamStopped
126
+ # stop() interrupted a backoff wait — clean shutdown.
55
127
  end
56
128
 
129
+ # Connect to the SSE stream and process events until the connection ends.
130
+ # Returns true if the stream delivered at least one complete frame (a live
131
+ # stream), false if it returned 200 but closed without delivering one.
57
132
  def connect
133
+ # Reset before anything can raise (a failed handshake / connection error
134
+ # must not let run() read a stale `true` from the previous session).
135
+ @delivered_frame = false
136
+
58
137
  uri = URI("#{@config.base_url}/v1/sdk/stream")
59
138
  http = Net::HTTP.new(uri.host, uri.port)
60
139
  http.use_ssl = uri.scheme == "https"
61
140
  http.open_timeout = @config.connect_timeout
62
- http.read_timeout = 300 # 5 min — detect silent TCP drops
141
+ http.read_timeout = STREAM_READ_TIMEOUT
63
142
 
64
143
  req = Net::HTTP::Get.new(uri.request_uri)
65
144
  req["Authorization"] = @sdk_key
@@ -71,31 +150,67 @@ module Featureflip
71
150
  raise Featureflip::Error, "SSE connection failed: #{response.code}"
72
151
  end
73
152
 
74
- @retry_count = 0
75
- @current_event_type = nil
76
- @current_data = nil
77
-
153
+ reset_stream_parser
78
154
  response.read_body do |chunk|
79
155
  break if @stop_flag
80
- chunk.each_line do |line|
81
- process_sse_line(line.strip)
82
- end
156
+ feed_chunk(chunk)
83
157
  end
84
158
  end
159
+
160
+ @delivered_frame
161
+ end
162
+
163
+ def reset_stream_parser
164
+ @current_event_type = nil
165
+ @current_data = nil
166
+ @line_buffer = String.new # ASCII-8BIT: raw read_body bytes concatenate safely
167
+ end
168
+
169
+ # Append a raw SSE body chunk and dispatch every *complete* line it
170
+ # completes. Net::HTTP#read_body yields arbitrary byte fragments with no
171
+ # line alignment, so a line (or a `data:` payload) can span chunk
172
+ # boundaries; buffer until a newline before parsing.
173
+ def feed_chunk(chunk)
174
+ @line_buffer << chunk
175
+ while (newline_index = @line_buffer.index("\n"))
176
+ line = @line_buffer.slice!(0, newline_index + 1)
177
+ process_sse_line(line.chomp.force_encoding(Encoding::UTF_8))
178
+ end
85
179
  end
86
180
 
87
181
  def process_sse_line(line)
88
182
  if line.start_with?("event: ")
89
183
  @current_event_type = line[7..]
90
184
  elsif line.start_with?("data: ")
91
- @current_data = line[6..]
185
+ # Per the SSE spec multiple data: lines join with "\n" — concatenate,
186
+ # never overwrite, or a chunked/multi-line payload loses everything but
187
+ # its last fragment.
188
+ fragment = line[6..]
189
+ @current_data = @current_data.nil? ? fragment : "#{@current_data}\n#{fragment}"
92
190
  elsif line.empty? && @current_event_type && @current_data
191
+ @delivered_frame = true
93
192
  handle_event(@current_event_type, @current_data)
94
193
  @current_event_type = nil
95
194
  @current_data = nil
96
195
  end
97
196
  end
98
197
 
198
+ # Capped exponential backoff. failures == 0 means a healthy stream just
199
+ # closed cleanly; still apply the base floor so we don't busy-loop.
200
+ def backoff_delay(failures)
201
+ exponent = failures <= 0 ? 0 : failures - 1
202
+ [RECONNECT_BASE_DELAY_SECONDS * (2**exponent), MAX_BACKOFF_SECONDS].min
203
+ end
204
+
205
+ # Sleep for `seconds`, but return immediately if stop() fires — so a pending
206
+ # shutdown isn't blocked behind a long backoff.
207
+ def backoff_wait(seconds)
208
+ @wake_mutex.synchronize do
209
+ return if @stop_flag
210
+ @wake_cond.wait(@wake_mutex, seconds)
211
+ end
212
+ end
213
+
99
214
  def handle_event(event_type, data)
100
215
  case event_type
101
216
  when "flag.created", "flag.updated"
@@ -112,6 +227,12 @@ module Featureflip
112
227
  when "segment.updated"
113
228
  flags, segments = @http_client.get_flags
114
229
  @on_segment_updated.call(flags, segments)
230
+ when "sync"
231
+ # Full config snapshot the server sends on (re)connect. Replace the
232
+ # whole store so flags changed OR deleted during a disconnect are
233
+ # re-synced. Full replace, never a per-key merge.
234
+ flags, segments = @http_client.parse_flags_response(JSON.parse(data))
235
+ @on_sync&.call(flags, segments)
115
236
  end
116
237
  rescue StandardError
117
238
  # Swallow event processing errors
@@ -13,7 +13,13 @@ module Featureflip
13
13
 
14
14
  def get_flags
15
15
  response = request(:get, "/v1/sdk/flags")
16
- data = JSON.parse(response.body)
16
+ parse_flags_response(JSON.parse(response.body))
17
+ end
18
+
19
+ # Parse a GET /v1/sdk/flags-shaped snapshot into models. Reused for the
20
+ # connect-time `sync` SSE snapshot, which carries the identical payload
21
+ # shape inline (no extra HTTP round-trip).
22
+ def parse_flags_response(data)
17
23
  flags = (data["flags"] || []).map { |f| parse_flag(f) }
18
24
  segments = (data["segments"] || []).map { |s| parse_segment(s) }
19
25
  [flags, segments]
@@ -278,6 +278,7 @@ module Featureflip
278
278
  on_flag_updated: ->(flag) { @store.upsert(flag) },
279
279
  on_flag_deleted: ->(key) { @store.remove_flag(key) },
280
280
  on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
281
+ on_sync: ->(flags, segments) { @store.init(flags, segments) },
281
282
  on_error: ->(_err) { },
282
283
  on_give_up: -> { fallback_to_polling }
283
284
  )
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.2.0"
2
+ VERSION = "2.3.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureflip
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Featureflip
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-19 00:00:00.000000000 Z
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger