featureflip 2.8.0 → 2.9.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: f3d59e135c8bde6e1955c965c186ade207bbb58961bbb95ad949497940efbd8d
4
- data.tar.gz: 9a25588388685e6e08f206b4ed623b491e949df2fc448797ba4305cdfdedfd4f
3
+ metadata.gz: 4dfdc2e9de3795a36a917d2a748184853bbd1c30362dd313ac70e386a4d0758c
4
+ data.tar.gz: e878ef6a553b2948435fc004b905f84015433b42625721d41311c5cb55e87654
5
5
  SHA512:
6
- metadata.gz: ad445faee786f603e3702df154e78afe5ae8d3ca6868be6c4d5dcc97f03fc4006f364cf40d9c2a73d5e0d43744f5074228186b6b3f69a1a97fd03ca678aa037a
7
- data.tar.gz: 5bdd2687ecec69386d9cde9f8b0c5190bd4bee4e49f3187f3762263be21e28e6d3dd09517c4b44451f4ddcc969fa70543a7177f91ba6f2b39fd7a785bf590147
6
+ metadata.gz: 871ae6a42bfcc7663a805a70935e2ac6b5a746a879df937343239f5f833c00be0990890f0ec47684a1beca0507160439274e26a606ef957510a3aa400d442929
7
+ data.tar.gz: d793281fb6b00d5cfeb5226c89c035fa17d6fad5d7af1876eac1c7d8c4c1290452c2067b6b9c8106795e4511451e43ef4debc399e660b245b3f7685507c494af
@@ -36,7 +36,7 @@ module Featureflip
36
36
  RECONNECT_BASE_DELAY_SECONDS = 1
37
37
  MAX_BACKOFF_SECONDS = 30
38
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)
39
+ def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_sync: nil, on_fallback_to_polling: nil, on_recovered: nil)
40
40
  @sdk_key = sdk_key
41
41
  @config = config
42
42
  @http_client = http_client
@@ -45,10 +45,14 @@ module Featureflip
45
45
  @on_segment_updated = on_segment_updated
46
46
  @on_error = on_error
47
47
  @on_sync = on_sync
48
- @on_give_up = on_give_up
48
+ @on_fallback_to_polling = on_fallback_to_polling
49
+ @on_recovered = on_recovered
49
50
  @stop_flag = false
50
51
  @thread = nil
51
52
  @retry_count = 0
53
+ # True between arming the polling fallback and the next delivered frame.
54
+ # Only ever touched from the streaming thread.
55
+ @fallback_active = false
52
56
  @current_event_type = nil
53
57
  @current_data = nil
54
58
  @line_buffer = String.new # ASCII-8BIT: raw read_body bytes concatenate safely
@@ -60,6 +64,7 @@ module Featureflip
60
64
  def start
61
65
  @stop_flag = false
62
66
  @retry_count = 0
67
+ @fallback_active = false
63
68
  @thread = Thread.new { run }
64
69
  end
65
70
 
@@ -112,9 +117,14 @@ module Featureflip
112
117
  # purposes — otherwise an accept-then-close server never accumulates
113
118
  # toward max_stream_retries and never degrades to polling.
114
119
  @retry_count += 1
115
- if @retry_count > @config.max_stream_retries
116
- @on_give_up&.call
117
- break
120
+ # The fallback is ADDITIVE, never terminal (#3071). Polling covers the
121
+ # outage; this loop keeps retrying the stream underneath at the capped
122
+ # backoff, and the next delivered frame retires the poller. Breaking out
123
+ # here left the process polling — and blind to real-time updates — until
124
+ # it restarted, after only ~31s of unreachability.
125
+ if @retry_count > @config.max_stream_retries && !@fallback_active
126
+ @fallback_active = true
127
+ @on_fallback_to_polling&.call
118
128
  end
119
129
  end
120
130
 
@@ -189,6 +199,14 @@ module Featureflip
189
199
  @current_data = @current_data.nil? ? fragment : "#{@current_data}\n#{fragment}"
190
200
  elsif line.empty? && @current_event_type && @current_data
191
201
  @delivered_frame = true
202
+ # Retire the fallback poller HERE rather than when run() next comes round:
203
+ # connect() blocks in read_body for the whole lifetime of a healthy stream,
204
+ # so a reap on return would leave the poller alive that entire time, and its
205
+ # periodic whole-store replaces would revert deltas applied by this stream.
206
+ if @fallback_active
207
+ @fallback_active = false
208
+ @on_recovered&.call
209
+ end
192
210
  handle_event(@current_event_type, @current_data)
193
211
  @current_event_type = nil
194
212
  @current_data = nil
@@ -276,6 +276,11 @@ module Featureflip
276
276
  @streaming_handler&.stop
277
277
  @polling_handler&.stop
278
278
  @event_processor&.stop
279
+ # Clear both before restarting: start_polling refuses to start a second
280
+ # poller while one is referenced, so a stopped-but-referenced handler here
281
+ # would leave a restarted non-streaming core with no data source at all.
282
+ @streaming_handler = nil
283
+ @polling_handler = nil
279
284
 
280
285
  if @config.streaming
281
286
  start_streaming
@@ -361,18 +366,35 @@ module Featureflip
361
366
  on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
362
367
  on_sync: ->(flags, segments) { @store.init(flags, segments) },
363
368
  on_error: ->(_err) { },
364
- on_give_up: -> { fallback_to_polling }
369
+ on_fallback_to_polling: -> { fallback_to_polling },
370
+ on_recovered: -> { stop_fallback_polling }
365
371
  )
366
372
  @streaming_handler.start
367
373
  end
368
374
 
369
375
  def fallback_to_polling
370
- @config.logger&.warn("Featureflip: streaming retries exhausted, falling back to polling")
371
- @streaming_handler = nil
376
+ @config.logger&.warn(
377
+ "Featureflip: streaming retries exhausted, falling back to polling while the stream keeps retrying"
378
+ )
379
+ # The handler is NOT cleared: it is still running and still trying to reopen
380
+ # the stream (#3071). Clearing it here is what used to make the fallback
381
+ # permanent — nothing would have restarted streaming.
372
382
  start_polling
373
383
  end
374
384
 
385
+ def stop_fallback_polling
386
+ return if @polling_handler.nil?
387
+
388
+ @config.logger&.info("Featureflip: stream recovered, stopping polling fallback")
389
+ @polling_handler.stop
390
+ @polling_handler = nil
391
+ end
392
+
375
393
  def start_polling
394
+ # Reachable from both start_data_source and the streaming fallback; a second
395
+ # call must not leak a second polling thread.
396
+ return if @polling_handler
397
+
376
398
  @polling_handler = DataSource::PollingHandler.new(
377
399
  http_client: @http_client,
378
400
  config: @config,
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.8.0"
2
+ VERSION = "2.9.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.8.0
4
+ version: 2.9.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-09-08 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger