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 +4 -4
- data/lib/featureflip/data_source/streaming.rb +135 -14
- data/lib/featureflip/http/client.rb +7 -1
- data/lib/featureflip/shared_core.rb +1 -0
- data/lib/featureflip/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcc479d4aca550b2c17f8a988df92f7a7406a8712765767311f9f712c56f1fd1
|
|
4
|
+
data.tar.gz: c9281f409c2736e2c6b929cd39fb433e38969b496b59d7387b61de7ba0dc6ce5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
@
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
)
|
data/lib/featureflip/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-07-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|