featureflip 2.1.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: 74310b5e1a7545b5d289a4f09d32c7e396f3f03bcb5af9265fe970667f2aedfd
4
- data.tar.gz: 6f0fd700429f63dbba04b3311c88700e9281113f0b114a6c635797745ca1cddc
3
+ metadata.gz: fcc479d4aca550b2c17f8a988df92f7a7406a8712765767311f9f712c56f1fd1
4
+ data.tar.gz: c9281f409c2736e2c6b929cd39fb433e38969b496b59d7387b61de7ba0dc6ce5
5
5
  SHA512:
6
- metadata.gz: 6eb646f7ba550b44639e2d294ad8b171d2a9931f5004afcdb7621db46abc18dfa047732da544e74a9167a509da1fd5018e71e49f7126e0753ee87f8ac9e2bccf
7
- data.tar.gz: ca59f44a28682ac0fcc5924d4a19e3bb9df8ae76809dfa133f34a57d4bf24243e3cf4b2b7a7f82a54efc0249653a6d54a1fd6ca4c5d0b685fbe2438282e27776
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
@@ -1,13 +1,35 @@
1
+ require "time"
2
+
1
3
  module Featureflip
2
4
  module Evaluation
3
5
  class ConditionEvaluator
6
+ # Per-regex match timeout (seconds) for MatchesRegex, mirroring the
7
+ # engine's 100ms RegexMatchTimeout guard against catastrophic
8
+ # backtracking / ReDoS (#1460). Requires Ruby >= 3.2 (gemspec floor),
9
+ # which added the Regexp `timeout:` keyword.
10
+ REGEX_TIMEOUT_SECONDS = 0.1
11
+
4
12
  def evaluate_condition(condition, context)
5
13
  attr_value = context[condition.attribute]
6
14
 
7
15
  return condition.negate if attr_value.nil?
8
16
 
9
- str_value = attr_value.to_s.downcase
10
- targets = condition.values.map { |v| v.to_s.downcase }
17
+ # Issue #1458: when the attribute is a native numeric (Integer/Float —
18
+ # Ruby's `true`/`false` are NOT Numeric, so booleans are naturally
19
+ # excluded), the equality-family operators coerce the condition values to
20
+ # numbers and compare numerically, so 1.0 matches "1". This mirrors the
21
+ # engine's type-aware path and runs BEFORE stringification — a String
22
+ # attribute (even "1.0") stays on the string path below.
23
+ if attr_value.is_a?(Numeric) && NUMERIC_EQUALITY_OPERATORS.include?(condition.operator)
24
+ return evaluate_numeric_equality(condition, attr_value)
25
+ end
26
+
27
+ # Pass the raw (case-preserved) strings to the operator dispatcher.
28
+ # Most operators compare case-insensitively and downcase internally, but
29
+ # the semver operators rely on case-sensitive prerelease precedence
30
+ # (semver §11), so the original casing must survive to that point.
31
+ str_value = attr_value.to_s
32
+ targets = condition.values.map(&:to_s)
11
33
 
12
34
  result = evaluate_operator(condition.operator, str_value, targets)
13
35
  condition.negate ? !result : result
@@ -33,49 +55,136 @@ module Featureflip
33
55
 
34
56
  private
35
57
 
58
+ # The equality-family operators that get type-aware numeric coercion when
59
+ # the attribute is a native Numeric (Issue #1458). Relational/string ops
60
+ # are deliberately excluded — only these four coerce.
61
+ NUMERIC_EQUALITY_OPERATORS = %w[Equals NotEquals In NotIn].freeze
62
+ private_constant :NUMERIC_EQUALITY_OPERATORS
63
+
64
+ # A parsed semantic version: the release core as dot-separated numeric
65
+ # segments plus an optional dot-separated prerelease identifier list.
66
+ SemverVersion = Struct.new(:release, :prerelease)
67
+ private_constant :SemverVersion
68
+
69
+ # An ISO-8601 date-time with no timezone offset (no trailing "Z"/±hh:mm),
70
+ # so it must be assumed UTC before parsing.
71
+ ISO_NO_OFFSET = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?\z/
72
+ private_constant :ISO_NO_OFFSET
73
+
74
+ # A bare ISO-8601 calendar date ("2024-01-01") with no time component. The
75
+ # engine's DateTimeOffset.TryParse accepts these (midnight, assumed UTC),
76
+ # but Time.iso8601 rejects them, so they need their own midnight-UTC path.
77
+ ISO_DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
78
+ private_constant :ISO_DATE_ONLY
79
+
36
80
  def evaluate_operator(operator, value, targets)
81
+ # Case-insensitive views for the string/relational/date operators.
82
+ ci_value = value.downcase
83
+ ci_targets = targets.map(&:downcase)
84
+
37
85
  case operator
38
86
  when "Equals"
39
- targets.any? { |t| value == t }
87
+ ci_targets.any? { |t| ci_value == t }
40
88
  when "NotEquals"
41
- targets.all? { |t| value != t }
89
+ ci_targets.all? { |t| ci_value != t }
42
90
  when "Contains"
43
- targets.any? { |t| value.include?(t) }
91
+ ci_targets.any? { |t| ci_value.include?(t) }
44
92
  when "NotContains"
45
- targets.all? { |t| !value.include?(t) }
93
+ ci_targets.all? { |t| !ci_value.include?(t) }
46
94
  when "StartsWith"
47
- targets.any? { |t| value.start_with?(t) }
95
+ ci_targets.any? { |t| ci_value.start_with?(t) }
48
96
  when "EndsWith"
49
- targets.any? { |t| value.end_with?(t) }
97
+ ci_targets.any? { |t| ci_value.end_with?(t) }
50
98
  when "In"
51
- targets.include?(value)
99
+ ci_targets.include?(ci_value)
52
100
  when "NotIn"
53
- !targets.include?(value)
101
+ !ci_targets.include?(ci_value)
54
102
  when "MatchesRegex"
103
+ # Case-sensitive matching on the original-case value and pattern,
104
+ # mirroring the engine (RegexOptions.None). Case-insensitivity is
105
+ # opt-in via the (?i) inline flag in the pattern.
106
+ #
107
+ # A per-regex timeout bounds catastrophic backtracking / ReDoS like
108
+ # the engine's 100ms guard (#1460). rescue RegexpError covers BOTH an
109
+ # invalid pattern AND Regexp::TimeoutError (a RegexpError subclass),
110
+ # so either fails safe to no-match.
55
111
  targets.any? do |t|
56
- Regexp.new(t, Regexp::IGNORECASE).match?(value)
112
+ Regexp.new(t, timeout: REGEX_TIMEOUT_SECONDS).match?(value)
57
113
  rescue RegexpError
58
114
  false
59
115
  end
116
+ # Relational operators match if the value satisfies the comparison
117
+ # against ANY condition value (mirroring the server engine), not just
118
+ # values[0]. `.any?` over an empty array is false, so empty values
119
+ # returns false without error.
60
120
  when "GreaterThan"
61
- compare_numeric(value, targets[0], :>)
121
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
62
122
  when "GreaterThanOrEqual"
63
- compare_numeric(value, targets[0], :>=)
123
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
64
124
  when "LessThan"
65
- compare_numeric(value, targets[0], :<)
125
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
66
126
  when "LessThanOrEqual"
67
- compare_numeric(value, targets[0], :<=)
127
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :<=) }
128
+ # Date operators compare against the RAW value/targets, not the
129
+ # lowercased copies: downcasing breaks ISO-8601 parsing (the "Z" UTC
130
+ # designator becomes "z", which is not valid). Both operands are parsed
131
+ # to UTC instants — offsets are honored, no-offset strings are assumed
132
+ # UTC, and a bare integer is treated as Unix seconds — so an unparseable
133
+ # operand matches nothing instead of falling back to a string compare.
68
134
  when "Before"
69
- return false if targets.empty?
70
- value < targets[0]
135
+ targets.any? { |t| compare_datetime(value, t, :<) }
71
136
  when "After"
72
- return false if targets.empty?
73
- value > targets[0]
137
+ targets.any? { |t| compare_datetime(value, t, :>) }
138
+ # Semantic-version operators compare against the RAW value/targets:
139
+ # prerelease precedence is case-sensitive (semver §11), so the casing
140
+ # preserved by `evaluate_condition` must not be folded here. An
141
+ # unparseable version matches nothing, like the numeric/date operators.
142
+ when "SemverEquals"
143
+ targets.any? { |t| compare_semver(value, t, :==) }
144
+ when "SemverGreaterThan"
145
+ targets.any? { |t| compare_semver(value, t, :>) }
146
+ when "SemverGreaterThanOrEqual"
147
+ targets.any? { |t| compare_semver(value, t, :>=) }
148
+ when "SemverLessThan"
149
+ targets.any? { |t| compare_semver(value, t, :<) }
150
+ when "SemverLessThanOrEqual"
151
+ targets.any? { |t| compare_semver(value, t, :<=) }
74
152
  else
75
153
  false
76
154
  end
77
155
  end
78
156
 
157
+ # Type-aware numeric equality for a native-Numeric attribute (Issue #1458).
158
+ # Coerces each condition value with a strict literal parse and compares it
159
+ # numerically against the attribute. Equals/In match if ANY value is equal;
160
+ # NotEquals/NotIn are their negation. The `negate` flag is then applied,
161
+ # mirroring `evaluate_condition`'s tail.
162
+ def evaluate_numeric_equality(condition, attr_value)
163
+ target = attr_value.to_f
164
+ any_equal = condition.values.any? do |v|
165
+ n = parse_numeric(v)
166
+ n && n == target
167
+ end
168
+
169
+ positive = NUMERIC_EQUALITY_POSITIVE_OPERATORS.include?(condition.operator)
170
+ result = positive ? any_equal : !any_equal
171
+ condition.negate ? !result : result
172
+ end
173
+
174
+ # Equals/In are the "positive" members of the equality family (match on
175
+ # equality); NotEquals/NotIn negate the same any-equal test.
176
+ NUMERIC_EQUALITY_POSITIVE_OPERATORS = %w[Equals In].freeze
177
+ private_constant :NUMERIC_EQUALITY_POSITIVE_OPERATORS
178
+
179
+ # Strict literal parse of a condition value to a Float, reusing the same
180
+ # `Float()` approach as `compare_numeric`: a partial number like "1abc"
181
+ # raises and yields nil (no match), matching the engine's strict parse.
182
+ def parse_numeric(value)
183
+ Float(value)
184
+ rescue ArgumentError, TypeError
185
+ nil
186
+ end
187
+
79
188
  def compare_numeric(value, target, op)
80
189
  val = Float(value)
81
190
  tgt = Float(target)
@@ -83,6 +192,164 @@ module Featureflip
83
192
  rescue ArgumentError, TypeError
84
193
  false
85
194
  end
195
+
196
+ # Compares `value` and `target` as UTC date-time instants and tests the
197
+ # ordering against `op` (:< for Before, :> for After). Returns false when
198
+ # either side is not parseable, mirroring how `compare_numeric` treats
199
+ # non-numeric input (no lexical-string fallback).
200
+ def compare_datetime(value, target, op)
201
+ left = parse_datetime(value)
202
+ right = parse_datetime(target)
203
+ return false if left.nil? || right.nil?
204
+
205
+ left.send(op, right)
206
+ end
207
+
208
+ # Parses a date-time to a UTC `Time`, mirroring the engine's
209
+ # TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
210
+ # without an offset is assumed UTC. A bare integer is treated as Unix time
211
+ # in seconds. Returns nil when the input parses as neither.
212
+ def parse_datetime(value)
213
+ s = value.to_s.strip
214
+
215
+ begin
216
+ # `Time.iso8601` honors offsets/"Z" but raises on a no-offset string;
217
+ # append the missing time/offset to assume midnight UTC for bare dates
218
+ # and UTC for offset-less date-times (mirroring DateTimeOffset.TryParse
219
+ # with AssumeUniversal).
220
+ iso =
221
+ if s.match?(ISO_DATE_ONLY)
222
+ "#{s}T00:00:00Z"
223
+ elsif s.match?(ISO_NO_OFFSET)
224
+ "#{s}Z"
225
+ else
226
+ s
227
+ end
228
+ return Time.iso8601(iso).utc
229
+ rescue ArgumentError
230
+ # Fall through to the Unix-seconds fallback.
231
+ end
232
+
233
+ # Integer fallback: treat a bare integer as Unix time in seconds.
234
+ if s.match?(/\A-?\d+\z/)
235
+ begin
236
+ return Time.at(Integer(s)).utc
237
+ rescue RangeError, ArgumentError
238
+ return nil
239
+ end
240
+ end
241
+
242
+ nil
243
+ end
244
+
245
+ # Compares `value` and `target` as semantic versions and tests the
246
+ # resulting precedence sign (-1/0/1) against `op`. Returns false when
247
+ # either side is not a parseable version, mirroring how `compare_numeric`
248
+ # treats non-numeric input.
249
+ def compare_semver(value, target, op)
250
+ left = parse_semver(value)
251
+ right = parse_semver(target)
252
+ return false if left.nil? || right.nil?
253
+
254
+ compare_semver_parts(left, right).send(op, 0)
255
+ end
256
+
257
+ # Parses a semantic version (https://semver.org). Returns a SemverVersion,
258
+ # or nil when the release core is missing or any release segment is
259
+ # non-numeric. Tolerant of an optional leading "v"/"V", "+build" metadata
260
+ # (ignored for precedence), and an optional "-prerelease" suffix.
261
+ def parse_semver(value)
262
+ s = value.strip
263
+ return nil if s.empty?
264
+
265
+ # Optional leading "v"/"V".
266
+ s = s[1..] if s.start_with?("v", "V")
267
+
268
+ # Build metadata ("+...") does not affect precedence.
269
+ plus = s.index("+")
270
+ s = s[0...plus] if plus
271
+
272
+ # Split the release core from the optional "-prerelease" suffix.
273
+ core = s
274
+ prerelease = []
275
+ dash = s.index("-")
276
+ if dash
277
+ core = s[0...dash]
278
+ pre = s[(dash + 1)..]
279
+ return nil if pre.empty? # trailing "-" with no identifiers is malformed
280
+
281
+ # `split(".", -1)` keeps trailing empty fields so "rc." / "1.0.-x" are
282
+ # rejected; the default `split` would silently drop them.
283
+ prerelease = pre.split(".", -1)
284
+ return nil if prerelease.any?(&:empty?)
285
+ end
286
+
287
+ return nil if core.empty?
288
+
289
+ release = core.split(".", -1)
290
+ return nil unless release.all? { |seg| all_digits?(seg) }
291
+
292
+ SemverVersion.new(release, prerelease)
293
+ end
294
+
295
+ # Compares two parsed versions by semantic-version precedence: release
296
+ # segments first (missing trailing segments treated as 0), then
297
+ # prerelease. Returns -1, 0, or 1.
298
+ def compare_semver_parts(a, b)
299
+ [a.release.length, b.release.length].max.times do |i|
300
+ seg_a = a.release[i] || "0"
301
+ seg_b = b.release[i] || "0"
302
+ cmp = compare_numeric_string(seg_a, seg_b)
303
+ return cmp unless cmp.zero?
304
+ end
305
+
306
+ compare_prerelease(a.prerelease, b.prerelease)
307
+ end
308
+
309
+ # Compares two prerelease identifier lists per semver §11. A version with
310
+ # no prerelease ranks above one that has a prerelease.
311
+ def compare_prerelease(a, b)
312
+ return 0 if a.empty? && b.empty?
313
+ return 1 if a.empty?
314
+ return -1 if b.empty?
315
+
316
+ [a.length, b.length].min.times do |i|
317
+ cmp = compare_prerelease_id(a[i], b[i])
318
+ return cmp unless cmp.zero?
319
+ end
320
+
321
+ # All shared identifiers equal: the longer prerelease has higher precedence.
322
+ a.length <=> b.length
323
+ end
324
+
325
+ # Compares two prerelease identifiers: numeric identifiers compare
326
+ # numerically and rank below alphanumeric ones; alphanumeric identifiers
327
+ # compare in ASCII sort order (case-sensitive) per semver §11.
328
+ def compare_prerelease_id(a, b)
329
+ a_num = all_digits?(a)
330
+ b_num = all_digits?(b)
331
+ return compare_numeric_string(a, b) if a_num && b_num
332
+ return -1 if a_num
333
+ return 1 if b_num
334
+
335
+ a <=> b
336
+ end
337
+
338
+ # Compares two all-digit strings as non-negative integers without parsing
339
+ # (overflow-free): strip leading zeros, then the longer string is the
340
+ # larger number; equal lengths compare ordinally. Returns -1, 0, or 1.
341
+ def compare_numeric_string(a, b)
342
+ a = a.sub(/\A0+/, "")
343
+ b = b.sub(/\A0+/, "")
344
+ return a.length <=> b.length unless a.length == b.length
345
+
346
+ a <=> b
347
+ end
348
+
349
+ # Reports whether `s` is non-empty and contains only ASCII digits.
350
+ def all_digits?(s)
351
+ s.match?(/\A[0-9]+\z/)
352
+ end
86
353
  end
87
354
  end
88
355
  end
@@ -37,12 +37,21 @@ module Featureflip
37
37
 
38
38
  sorted_rules = flag.rules.sort_by(&:priority)
39
39
  sorted_rules.each do |rule|
40
- conditions_match = if rule.segment_key && get_segment
41
- segment = get_segment.call(rule.segment_key)
42
- if segment
43
- @condition_evaluator.evaluate_conditions(
44
- segment.conditions, segment.condition_logic, context
45
- )
40
+ conditions_match = if rule.segment_key && !rule.segment_key.empty?
41
+ # A segment-keyed rule must resolve its segment to match. If the
42
+ # segment source isn't wired (get_segment is nil), or the segment
43
+ # can't be found, fail closed (no match) -- mirroring the engine +
44
+ # C# SDK -- rather than falling through to the rule's condition
45
+ # groups (which match unconditionally when empty). See #1459.
46
+ if get_segment
47
+ segment = get_segment.call(rule.segment_key)
48
+ if segment
49
+ @condition_evaluator.evaluate_conditions(
50
+ segment.conditions, segment.condition_logic, context
51
+ )
52
+ else
53
+ false
54
+ end
46
55
  else
47
56
  false
48
57
  end
@@ -138,6 +147,21 @@ module Featureflip
138
147
  bucket_value = context["userId"] if bucket_value.nil? && bucket_by == "user_id"
139
148
  bucket_value_str = bucket_value.nil? ? "" : bucket_value.to_s
140
149
 
150
+ # A Rollout serve can arrive with no weighted variations -- env-level PercentageRollout
151
+ # has nowhere to store per-variation weights, so the mapper emits type=Rollout with no
152
+ # variations (#1469). Degrade to the default fixed variation instead of returning an
153
+ # empty key. Mirrors the engine + C#/Java SDK evaluators.
154
+ return serve.variation || "" if (serve.variations || []).empty?
155
+
156
+ # Keyless user contexts can't be bucketed. Rather than hashing the empty value
157
+ # into an arbitrary salt-dependent bucket, serve the control (first) variation
158
+ # deterministically. The engine assigns a random GUID per eval (spreading
159
+ # anonymous users over HTTP); local SDK eval is deterministic, so parity is
160
+ # guaranteed only for keyed contexts (#1457).
161
+ if bucket_value_str == "" && %w[userId user_id].include?(bucket_by) && !(serve.variations || []).empty?
162
+ return serve.variations[0].key
163
+ end
164
+
141
165
  bucket = Bucketing.compute_bucket(serve.salt || "", bucket_value_str)
142
166
 
143
167
  cumulative = 0
@@ -146,7 +170,8 @@ module Featureflip
146
170
  return wv.key if bucket < cumulative
147
171
  end
148
172
 
149
- serve.variations&.last&.key || ""
173
+ # Guaranteed non-empty: the no-variations case returned the default above.
174
+ serve.variations.last.key
150
175
  end
151
176
  end
152
177
  end
@@ -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.1.0"
2
+ VERSION = "2.3.0"
3
3
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureflip
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.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-05-27 00:00:00.000000000 Z
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: logger
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: rspec
15
29
  requirement: !ruby/object:Gem::Requirement