featureflip 2.5.0 → 2.6.1

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: f3e88364434f9dced8db82624524bf1ffe905b35b5ae8a062896bfb6c02dc5e6
4
- data.tar.gz: 87d5bdaa62bb9f6e8eb0685f235315b31adfb5cd0dbdaac478965144a09709ff
3
+ metadata.gz: edea6a4841539f13194e1e03e76bd7db298e713a0d79ec66327838cc56ed8408
4
+ data.tar.gz: 5a2d3f0946e19fb1734bc7db35dc1006f5d39f22626cbd147f527c31aa8dbddd
5
5
  SHA512:
6
- metadata.gz: 7b9c0cbfce3e7451f4218dc5a0274a28181d770a5689427d5fba20899702c5a580413b6adcb99482c717708cc2b60cccd5e9ee6dfb423351ff82024befae0477
7
- data.tar.gz: d4c066e87770edfa6881cf000d36cc8e7f9f476cc3afebc8b44e5842f2c5946805af79a47dbceb575bd4e50496e56d024b3485fa083d414fb52bf8809b25eff2
6
+ metadata.gz: 894e77cfd032b59b62a8b1f9832ad5370d042c20290604b7f836bf9f35c427503f1b29eb3495c80fa02f816ae60e9d6c45c2d9f2074a9621aeb2cdaf1dd9d235
7
+ data.tar.gz: 472b4f2dbeb2dc1806a7e2dba1128b26bbe52a63833fcdd0c4571abc7b300b7d61ee153fe80cd35462c3782bbedee3b76695093c617a14600972e296f8c3ea53
@@ -234,6 +234,15 @@ module Featureflip
234
234
  flags, segments = @http_client.parse_flags_response(JSON.parse(data))
235
235
  @on_sync&.call(flags, segments)
236
236
  end
237
+ rescue UnevaluableEntityError => e
238
+ # Not a malformed payload: the frame was well-formed and simply described
239
+ # behaviour this build cannot evaluate, so the entity was dropped rather than
240
+ # the payload discarded (#2402). Logged at the same volume — a flag that
241
+ # silently stopped updating is exactly as confusing as one that never arrived.
242
+ @config.logger&.warn(
243
+ "Featureflip: dropping #{event_type} update: #{e.message}. This SDK version " \
244
+ "may be older than the flag configuration."
245
+ )
237
246
  rescue MalformedPayloadError => e
238
247
  # A payload that violates the wire contract is discarded WHOLESALE rather
239
248
  # than partially applied — a half-parsed snapshot silently mis-evaluates
@@ -11,4 +11,40 @@ module Featureflip
11
11
  # operator to no-match. Only a TYPE violation is rejected, because that can never
12
12
  # be a legitimate newer-server payload. See #2285.
13
13
  class MalformedPayloadError < Error; end
14
+
15
+ # Raised when the API answers a request with a non-success status. Carries the status
16
+ # so a caller can decide what to do about it.
17
+ #
18
+ # The events flush is the caller that has to: it re-queues a batch a later attempt could
19
+ # plausibly deliver (5xx, 429) and drops one the server will reject identically forever
20
+ # (401/403 = key rejected, 400 = malformed body). Before this the status only existed
21
+ # inside the message string, so every failure looked alike and the batch was dropped
22
+ # either way (#2456).
23
+ #
24
+ # Subclasses Error and keeps the historical "HTTP <code>: <path>" message, so callers
25
+ # rescuing Featureflip::Error — or matching on that message — are unaffected.
26
+ class HttpStatusError < Error
27
+ attr_reader :status
28
+
29
+ def initialize(status, path)
30
+ @status = status
31
+ super("HTTP #{status}: #{path}")
32
+ end
33
+ end
34
+
35
+ # Raised when a flag or segment carries an enum value this SDK build cannot
36
+ # EVALUATE — an unrecognised `serve.type` or `conditionLogic` (#2402).
37
+ #
38
+ # Distinct from MalformedPayloadError, and the distinction is the whole point. That
39
+ # one means the payload is the wrong SHAPE and is discarded wholesale. This one means
40
+ # the payload is perfectly well-formed and simply describes behaviour a newer server
41
+ # understands and this build does not — so only the containing ENTITY is dropped and
42
+ # the rest of the configuration still applies.
43
+ #
44
+ # Not raised for an unrecognised condition OPERATOR: the evaluator already fails an
45
+ # unknown operator closed (#2262), so the condition just does not match and the flag
46
+ # remains perfectly evaluable. `serve.type` and `conditionLogic` have no such
47
+ # fail-closed arm — each dispatches on a two-way branch and an unrecognised value
48
+ # takes the ELSE arm, silently serving a rollout or matching ANY condition.
49
+ class UnevaluableEntityError < Error; end
14
50
  end
@@ -32,6 +32,14 @@ module Featureflip
32
32
  targets = condition.values.map(&:to_s)
33
33
 
34
34
  result = evaluate_operator(condition.operator, str_value, targets)
35
+
36
+ # Issue #2262: an unrecognised operator fails CLOSED. `!nil` is `true`
37
+ # in Ruby, so without this guard a negated unknown operator would match
38
+ # every user and roll the flag out to 100% of traffic. The realistic
39
+ # trigger is a new operator shipped server-side reaching an SDK pinned
40
+ # to an older version.
41
+ return false if result.nil?
42
+
35
43
  condition.negate ? !result : result
36
44
  end
37
45
 
@@ -66,16 +74,32 @@ module Featureflip
66
74
  SemverVersion = Struct.new(:release, :prerelease)
67
75
  private_constant :SemverVersion
68
76
 
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
77
+ # The ONLY characters trimmed from a date operand, and the whole of the
78
+ # operand's permitted whitespace: U+0009..U+000D plus U+0020 -- exactly the
79
+ # class the engine's NumberStyles.Integer accepts via AllowLeadingWhite |
80
+ # AllowTrailingWhite.
81
+ #
82
+ # String#strip is deliberately NOT used: it also strips NUL, so "\0005" was
83
+ # trimmed to "5" and matched here while the engine rejected it (#2468).
84
+ OPERAND_WHITESPACE = "\t\n\v\f\r "
85
+ private_constant :OPERAND_WHITESPACE
86
+
87
+ # Characters no date operand may contain: a NUL or other control character, or
88
+ # a non-ASCII whitespace/format character. An interior ASCII space is allowed
89
+ # -- it is the ISO-8601 date/time separator.
90
+ FORBIDDEN_OPERAND_CHAR =
91
+ /[\u0000-\u001f\u007f-\u009f\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/
92
+ private_constant :FORBIDDEN_OPERAND_CHAR
93
+
94
+ # The ISO-8601 grammar a date operand may use: a calendar date, optionally
95
+ # followed by a time (seconds and fractional seconds optional) and an optional
96
+ # offset in either extended (+05:00 / Z) or basic (+0500) form. The separator
97
+ # may be "T" or a space -- the engine accepts both, but Time.iso8601 rejects
98
+ # the space, which is why ruby alone read "2024-01-01 00:00:00" as no-match
99
+ # (#2468).
100
+ ISO_OPERAND =
101
+ /\A(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
102
+ private_constant :ISO_OPERAND
79
103
 
80
104
  def evaluate_operator(operator, value, targets)
81
105
  # Case-insensitive views for the string/relational/date operators.
@@ -150,7 +174,10 @@ module Featureflip
150
174
  when "SemverLessThanOrEqual"
151
175
  targets.any? { |t| compare_semver(value, t, :<=) }
152
176
  else
153
- false
177
+ # Unrecognised operator. `nil` — NOT `false` — so the caller can tell
178
+ # "cannot evaluate" apart from "evaluated, did not match"; only the
179
+ # latter may be inverted by `negate` (#2262).
180
+ nil
154
181
  end
155
182
  end
156
183
 
@@ -209,31 +236,87 @@ module Featureflip
209
236
  # TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
210
237
  # without an offset is assumed UTC. A bare integer is treated as Unix time
211
238
  # in seconds. Returns nil when the input parses as neither.
239
+ # DateTimeOffset.MinValue / MaxValue as unix seconds -- the exact bounds the
240
+ # engine's FromUnixTimeSeconds accepts before throwing (#2432).
241
+ MIN_UNIX_SECONDS = -62_135_596_800
242
+ MAX_UNIX_SECONDS = 253_402_300_799
243
+
244
+ # Rewrites an accepted ISO operand into the strict extended form Time.iso8601
245
+ # parses: "T" separator, seconds present, offset spelled "+HH:MM" or "Z".
246
+ # Returns nil when the operand is not an accepted ISO shape.
247
+ def canonicalize_iso(s)
248
+ m = ISO_OPERAND.match(s)
249
+ return nil if m.nil?
250
+
251
+ date, hh, mm, ss, frac, off = m.captures
252
+ return "#{date}T00:00:00Z" if hh.nil?
253
+
254
+ # The engine's DateTimeOffset.TryParse rejects hour 24 outright rather than
255
+ # rolling it over to 00:00 the next day, which is what Time.iso8601 does.
256
+ return nil if hh >= "24"
257
+
258
+ ss ||= "00"
259
+ off =
260
+ if off.nil? then "Z"
261
+ elsif off.length == 5 && off != "Z" then "#{off[0, 3]}:#{off[3, 2]}"
262
+ else off
263
+ end
264
+ "#{date}T#{hh}:#{mm}:#{ss}#{frac}#{off}"
265
+ end
266
+
212
267
  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.
268
+ s = value.to_s
269
+ # Trim exactly the engine's whitespace class, then reject anything still
270
+ # carrying a character no operand may contain.
271
+ s = s.gsub(/\A[#{Regexp.escape(OPERAND_WHITESPACE)}]+|[#{Regexp.escape(OPERAND_WHITESPACE)}]+\z/, "")
272
+ return nil if s.empty? || s.match?(FORBIDDEN_OPERAND_CHAR)
273
+
274
+ iso = canonicalize_iso(s)
275
+ if iso
276
+ begin
277
+ # Offset-less forms were canonicalized to an explicit "Z", mirroring
278
+ # DateTimeOffset.TryParse with AssumeUniversal.
279
+ return Time.iso8601(iso).utc
280
+ rescue ArgumentError
281
+ # A syntactically-valid but non-existent date (e.g. 2024-02-31) --
282
+ # fall through to the Unix-seconds fallback, which will also reject it.
283
+ end
231
284
  end
232
285
 
233
286
  # Integer fallback: treat a bare integer as Unix time in seconds.
234
- if s.match?(/\A-?\d+\z/)
287
+ #
288
+ # Out-of-range seconds match NOTHING rather than resolving to a far-future
289
+ # instant: the engine's FromUnixTimeSeconds throws outside DateTimeOffset's
290
+ # range and TryParseDateTime returns false. Ruby's Time has a far wider range
291
+ # and would happily accept the value, so the bound has to be explicit. The
292
+ # case that matters in practice is a MILLISECONDS timestamp pasted where
293
+ # seconds belong, which would otherwise land in the year 55829 and satisfy
294
+ # every `After` comparison (#2432).
295
+ #
296
+ # The sign class matches the engine's `long.TryParse` with
297
+ # `NumberStyles.Integer` (`AllowLeadingWhite | AllowTrailingWhite |
298
+ # AllowLeadingSign`), so a leading "+" is accepted deliberately rather than
299
+ # incidentally, and `Integer()` reads it the same way. Omitting it made "+5"
300
+ # an unparseable string matching NOTHING here while the engine and four other
301
+ # SDKs read it as five seconds past the epoch (#2458).
302
+ #
303
+ # The whitespace flags now match too: the trim above is exactly
304
+ # `AllowLeadingWhite`/`AllowTrailingWhite`'s class, and anything outside it
305
+ # was already rejected by FORBIDDEN_OPERAND_CHAR (#2468).
306
+ if s.match?(/\A[+-]?\d+\z/)
235
307
  begin
236
- return Time.at(Integer(s)).utc
308
+ # Base 10 EXPLICITLY. Bare `Integer(s)` honours Ruby's literal base
309
+ # prefixes, so a leading zero means OCTAL: "0500" became 320 rather than
310
+ # 500, and "0800" raised ArgumentError (8 is not an octal digit) and
311
+ # matched nothing at all. Every other implementation parses base 10 --
312
+ # the engine's `long.TryParse`, go's `ParseInt(s, 10, 64)`, java's
313
+ # `Long.parseLong`, python's `int()`, php's `(int)` cast and js's
314
+ # `Number()` -- so ruby was alone in reading a zero-padded unix timestamp
315
+ # as a different instant. Pinned by `c-date-unix-leading-zero-*` (#2458).
316
+ seconds = Integer(s, 10)
317
+ return nil if seconds < MIN_UNIX_SECONDS || seconds > MAX_UNIX_SECONDS
318
+
319
+ return Time.at(seconds).utc
237
320
  rescue RangeError, ArgumentError
238
321
  return nil
239
322
  end
@@ -1,38 +1,75 @@
1
1
  module Featureflip
2
2
  module Events
3
3
  class EventProcessor
4
- def initialize(http_client, flush_interval: 30, flush_batch_size: 100)
4
+ # Upper bound on buffered events.
5
+ #
6
+ # Only reachable once #flush starts putting batches back faster than they drain --
7
+ # i.e. a sustained outage of the events endpoint. Past the bound the OLDEST events
8
+ # are shed, which caps memory and keeps the freshest analytics. It also means a long
9
+ # outage sheds the stale re-queued batches rather than starving new events, so the
10
+ # SDK degrades to the old drop-on-failure behaviour instead of hoarding data it
11
+ # cannot send.
12
+ DEFAULT_MAX_QUEUE_SIZE = 10_000
13
+
14
+ def initialize(http_client, flush_interval: 30, flush_batch_size: 100,
15
+ max_queue_size: DEFAULT_MAX_QUEUE_SIZE, logger: nil)
5
16
  @http_client = http_client
6
17
  @flush_interval = flush_interval
7
- @flush_batch_size = flush_batch_size
18
+ # Clamped to at least 1: #flush drains @flush_batch_size events per pass and stops
19
+ # when the queue is empty, so a non-positive size would shift nothing off a
20
+ # non-empty queue and spin forever. Config#validate! already rejects such values,
21
+ # but this class is constructed directly too.
22
+ @flush_batch_size = flush_batch_size.to_i.positive? ? flush_batch_size.to_i : 1
23
+ @max_queue_size = max_queue_size.to_i.positive? ? max_queue_size.to_i : DEFAULT_MAX_QUEUE_SIZE
24
+ @logger = logger
8
25
  @queue = []
9
26
  @mutex = Mutex.new
10
27
  @stop_flag = false
28
+ @stopped = false
11
29
  @thread = nil
30
+
31
+ # Monotonic instant before which the batch-size trigger must not start another
32
+ # flush, and a latch held while a size-triggered flush is in flight. Both exist to
33
+ # stop a re-queued batch from turning every subsequent event into another request
34
+ # — see #auto_flush.
35
+ @next_auto_flush_at = 0.0
36
+ @auto_flush_in_flight = false
12
37
  end
13
38
 
14
39
  def queue_event(event)
15
- should_flush = false
40
+ dropped = 0
16
41
  @mutex.synchronize do
42
+ # After #stop nothing will flush again, so buffering here would only leak.
43
+ return if @stopped
44
+
17
45
  @queue << event
18
- should_flush = @queue.length >= @flush_batch_size
46
+ dropped = trim_to_bound
19
47
  end
20
- flush if should_flush
48
+
49
+ warn_overflow(dropped)
50
+ auto_flush
21
51
  end
22
52
 
53
+ # Drains the queue a batch at a time, one request per batch.
54
+ #
55
+ # This used to post the WHOLE queue in a single request, which was harmless while a
56
+ # failure emptied the queue: it never grew far past @flush_batch_size. Re-queuing
57
+ # failures (#2456) is what changed that — after a sustained outage the queue can sit
58
+ # at its 10,000-event bound, and posting all of that at once risks a body the server
59
+ # rejects outright. A 413 is non-retryable, so the entire backlog would be dropped by
60
+ # the very path added to preserve it.
23
61
  def flush
24
- events_to_send = nil
25
- @mutex.synchronize do
26
- return if @queue.empty?
27
- events_to_send = @queue.dup
28
- @queue.clear
29
- end
62
+ loop do
63
+ batch = drain_batch
64
+ return if batch.empty?
30
65
 
31
- return unless events_to_send&.any?
32
-
33
- @http_client.post_events(events_to_send)
34
- rescue StandardError
35
- # Events are best-effort drop on failure
66
+ # False means the batch went back on the queue, and it is at the HEAD of the very
67
+ # queue this loop drains — carrying on would re-send it immediately and spin for
68
+ # as long as the endpoint stayed down. A dropped batch returns true instead: the
69
+ # queue has shrunk, so the loop still terminates, and one poison batch must not
70
+ # block the backlog behind it.
71
+ return unless send_batch(batch)
72
+ end
36
73
  end
37
74
 
38
75
  def start
@@ -42,10 +79,15 @@ module Featureflip
42
79
  until @stop_flag
43
80
  sleep(1)
44
81
  elapsed += 1
45
- queue_size = @mutex.synchronize { @queue.length }
46
- if elapsed >= @flush_interval || queue_size >= @flush_batch_size
82
+ next if @stop_flag
83
+
84
+ if elapsed >= @flush_interval
85
+ # The interval tick is the retry vehicle for a re-queued batch, so it is
86
+ # deliberately NOT subject to the size trigger's backoff gate.
87
+ elapsed = 0
88
+ flush
89
+ elsif auto_flush
47
90
  elapsed = 0
48
- flush unless @stop_flag
49
91
  end
50
92
  end
51
93
  end
@@ -56,7 +98,162 @@ module Featureflip
56
98
  @thread&.wakeup rescue nil
57
99
  @thread&.join(5)
58
100
  @thread = nil
101
+
102
+ # Closed BEFORE the final flush so a failure there is dropped rather than
103
+ # re-queued: nothing will flush again, and retrying until the queue drains would
104
+ # hang shutdown for as long as the endpoint stayed down. One attempt, then let go.
105
+ @mutex.synchronize { @stopped = true }
59
106
  flush
107
+ @mutex.synchronize { @queue.clear }
108
+ end
109
+
110
+ private
111
+
112
+ # Batch-size-triggered flush. Returns true only if it actually sent.
113
+ #
114
+ # A re-queued batch leaves the queue at or above @flush_batch_size, so without a gate
115
+ # every subsequent event would trigger another flush — turning a failing endpoint
116
+ # into one request per evaluation, which is worse for the server than the dropping
117
+ # this replaced. Two guards, and both are load-bearing:
118
+ #
119
+ # * the backoff gate suppresses the size trigger for one flush interval after a
120
+ # retryable failure, leaving the background thread's interval tick as the retry
121
+ # vehicle;
122
+ # * the in-flight latch covers what the gate cannot, because the gate is only armed
123
+ # once a flush has already FAILED and the size trigger fires again long before
124
+ # the first round-trip returns. Without it a tight loop of events starts a pile
125
+ # of concurrent flushes.
126
+ #
127
+ # An explicit #flush (the public API, and the interval tick) bypasses both: the
128
+ # caller asked for a send.
129
+ def auto_flush
130
+ return false unless @mutex.synchronize { claim_size_trigger }
131
+
132
+ begin
133
+ flush
134
+ ensure
135
+ @mutex.synchronize { @auto_flush_in_flight = false }
136
+ end
137
+ true
138
+ end
139
+
140
+ # Whether a size-triggered flush may start, claiming the latch if so.
141
+ # Caller holds @mutex.
142
+ def claim_size_trigger
143
+ return false if @stopped
144
+ return false if @auto_flush_in_flight
145
+ return false if @queue.length < @flush_batch_size
146
+ return false if monotonic_now < @next_auto_flush_at
147
+
148
+ @auto_flush_in_flight = true
149
+ end
150
+
151
+ # Never called with @mutex held: the POST blocks for as long as the endpoint takes to
152
+ # answer — and Http::Client sleeps a second before its own single inline retry — so
153
+ # the background thread and every queue_event caller would block behind it.
154
+ # Sends one batch. Returns true if #flush may go on to the next one — see the comment
155
+ # at its call site for why a re-queued batch must stop the drain.
156
+ def send_batch(events)
157
+ @http_client.post_events(events)
158
+ @mutex.synchronize { @next_auto_flush_at = 0.0 }
159
+ true
160
+ rescue StandardError => e
161
+ unless retryable_failure?(e)
162
+ @logger&.warn(
163
+ "Featureflip: dropped #{events.length} analytics event(s) the events endpoint " \
164
+ "rejected (#{e.class}: #{e.message}); the failure is not retryable"
165
+ )
166
+ return true
167
+ end
168
+
169
+ dropped = requeue(events)
170
+ # Armed here rather than at the first attempt, so the interval is counted from the
171
+ # moment post_events' inline retry finally gave up.
172
+ @mutex.synchronize { @next_auto_flush_at = monotonic_now + @flush_interval }
173
+
174
+ if dropped.nil?
175
+ @logger&.warn(
176
+ "Featureflip: dropped #{events.length} analytics event(s) " \
177
+ "(#{e.class}: #{e.message}); the processor is shutting down and will not flush again"
178
+ )
179
+ else
180
+ @logger&.warn(
181
+ "Featureflip: failed to send #{events.length} analytics event(s) " \
182
+ "(#{e.class}: #{e.message}); re-queued for the next flush"
183
+ )
184
+ warn_overflow(dropped)
185
+ end
186
+
187
+ false
188
+ end
189
+
190
+ # Whether the same batch could succeed if it were sent again.
191
+ #
192
+ # For an HTTP answer the status decides: any 5xx — the production edge answers this
193
+ # endpoint with a 503 at a low but constant rate (#2456) — and 429, where the server
194
+ # is explicitly asking us to come back later. Anything else will fail identically next
195
+ # time: 401/403 means the SDK key was rejected, 400 means the body is malformed, and
196
+ # retrying either forever would pin the queue at its bound and starve every later
197
+ # event.
198
+ #
199
+ # Everything that is NOT an HTTP answer is treated as transient. A transport fault
200
+ # (connection reset, DNS, TLS) or a timeout carries no status at all and is exactly
201
+ # the kind of failure a later flush gets past, so the default has to be "keep it"
202
+ # rather than "not a 5xx, therefore permanent".
203
+ def retryable_failure?(error)
204
+ return error.status >= 500 || error.status == 429 if error.is_a?(Featureflip::HttpStatusError)
205
+
206
+ true
207
+ end
208
+
209
+ # Takes up to @flush_batch_size of the OLDEST events, leaving the rest queued.
210
+ def drain_batch
211
+ @mutex.synchronize do
212
+ return [] if @queue.empty?
213
+
214
+ @queue.shift([@flush_batch_size, @queue.length].min)
215
+ end
216
+ end
217
+
218
+ # Puts a batch that failed to send back at the FRONT of the queue, so the next flush
219
+ # retries it ahead of newer events and rough chronological order survives.
220
+ #
221
+ # Returns how many events were shed to stay within the bound — or nil if it refused
222
+ # the batch entirely because #stop has closed the queue. The caller needs to tell
223
+ # those apart: "re-queued for the next flush" is a lie once there will be no next
224
+ # flush, and this is the branch a shutdown during an outage takes.
225
+ def requeue(events)
226
+ @mutex.synchronize do
227
+ return nil if @stopped
228
+
229
+ @queue.unshift(*events)
230
+ trim_to_bound
231
+ end
232
+ end
233
+
234
+ # Sheds oldest-first until the queue fits the bound, returning how many went.
235
+ # Caller holds @mutex.
236
+ def trim_to_bound
237
+ overflow = @queue.length - @max_queue_size
238
+ return 0 if overflow <= 0
239
+
240
+ @queue.shift(overflow)
241
+ overflow
242
+ end
243
+
244
+ def warn_overflow(dropped)
245
+ return if dropped.zero?
246
+
247
+ @logger&.warn(
248
+ "Featureflip: event queue is full (#{@max_queue_size}); " \
249
+ "dropped #{dropped} of the oldest analytics event(s)"
250
+ )
251
+ end
252
+
253
+ # Wall-clock time can jump backwards (NTP, a suspended host); the backoff gate must
254
+ # not be extended or skipped by that.
255
+ def monotonic_now
256
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
60
257
  end
61
258
  end
62
259
  end
@@ -20,18 +20,37 @@ module Featureflip
20
20
  # connect-time `sync` SSE snapshot, which carries the identical payload
21
21
  # shape inline (no extra HTTP round-trip).
22
22
  def parse_flags_response(data)
23
- flags = (data["flags"] || []).map { |f| parse_flag(f) }
24
- segments = (data["segments"] || []).map { |s| parse_segment(s) }
23
+ flags = drop_unevaluable((data["flags"] || []).map { |f| parse_flag(f) }, "flag") do |flag|
24
+ unevaluable_flag_reason(flag)
25
+ end
26
+ segments = drop_unevaluable((data["segments"] || []).map { |s| parse_segment(s) }, "segment") do |segment|
27
+ unevaluable_segment_reason(segment)
28
+ end
25
29
  [flags, segments]
26
30
  end
27
31
 
28
32
  def get_flag(key)
29
33
  response = request(:get, "/v1/sdk/flags/#{key}")
30
- parse_flag(JSON.parse(response.body))
34
+ flag = parse_flag(JSON.parse(response.body))
35
+
36
+ # An unevaluable enum drops the flag rather than upserting it (#2402). For a
37
+ # delta whose whole scope is one flag that means leaving the store's previous
38
+ # copy alone: replacing it with one this build would mis-evaluate is the outcome
39
+ # the drop exists to prevent, and FLAG_NOT_FOUND is the honest answer if there
40
+ # was no previous copy.
41
+ reason = unevaluable_flag_reason(flag)
42
+ raise UnevaluableEntityError, "flag #{key.inspect}: #{reason}" if reason
43
+
44
+ flag
31
45
  end
32
46
 
33
47
  def post_events(events)
34
- request(:post, "/v1/sdk/events", { events: events })
48
+ # The one caller that retries inline. EventProcessor#flush drains the queue before
49
+ # sending, so a batch only survives a failure because the processor puts it back --
50
+ # this absorbs a single transient 5xx before that machinery is needed, which keeps
51
+ # the common blip off the re-queue path entirely. The processor's backoff gate is
52
+ # measured from the moment this finally gives up, not from the first attempt.
53
+ request(:post, "/v1/sdk/events", { events: events }, retry_server_errors: true)
35
54
  end
36
55
 
37
56
  def close
@@ -40,7 +59,13 @@ module Featureflip
40
59
 
41
60
  private
42
61
 
43
- def request(method, path, body = nil, retries: 1)
62
+ # retry_server_errors: retry once on a 5xx. Off by default — the poller re-fetches
63
+ # every poll_interval and the streaming source reconnects with backoff, so for flag
64
+ # reads an inner retry buys nothing and doubles request volume against a dependency
65
+ # that is already failing (it also blocked for a second inside the init_timeout
66
+ # budget on cold start). eval-api answers 503 when it cannot reach the Management
67
+ # API, which is exactly the status this used to trip on.
68
+ def request(method, path, body = nil, retries: 1, retry_server_errors: false)
44
69
  uri = URI("#{@base_url}#{path}")
45
70
  http = Net::HTTP.new(uri.host, uri.port)
46
71
  http.use_ssl = uri.scheme == "https"
@@ -62,13 +87,16 @@ module Featureflip
62
87
 
63
88
  response = http.request(req)
64
89
 
65
- if response.is_a?(Net::HTTPServerError) && retries > 0
90
+ if retry_server_errors && response.is_a?(Net::HTTPServerError) && retries > 0
66
91
  sleep(1)
67
- return request(method, path, body, retries: retries - 1)
92
+ return request(method, path, body, retries: retries - 1, retry_server_errors: retry_server_errors)
68
93
  end
69
94
 
70
95
  unless response.is_a?(Net::HTTPSuccess)
71
- raise Featureflip::Error, "HTTP #{response.code}: #{path}"
96
+ # HttpStatusError, not a bare Error: the events flush branches on the status to
97
+ # decide whether the batch is worth keeping (#2456). Same message and same
98
+ # ancestry, so nothing that rescues Featureflip::Error changes behaviour.
99
+ raise HttpStatusError.new(response.code.to_i, path)
72
100
  end
73
101
 
74
102
  response
@@ -76,7 +104,7 @@ module Featureflip
76
104
  Net::OpenTimeout, Net::ReadTimeout => e
77
105
  raise if retries <= 0
78
106
  sleep(1)
79
- request(method, path, body, retries: retries - 1)
107
+ request(method, path, body, retries: retries - 1, retry_server_errors: retry_server_errors)
80
108
  end
81
109
 
82
110
  # Enum fields are strings on the wire. Ruby keeps whatever it is handed and the
@@ -165,6 +193,88 @@ module Featureflip
165
193
  condition_logic: require_enum_string!(data["conditionLogic"], "segment.conditionLogic") || "And"
166
194
  )
167
195
  end
196
+
197
+ # Entity-level drop for enum values this SDK build cannot evaluate (#2402).
198
+ #
199
+ # `serve.type` and `conditionLogic` are the two enums that are BOTH carried on the
200
+ # wire as strings AND consulted by the evaluator, and each dispatches on a two-way
201
+ # branch with no third arm:
202
+ #
203
+ # serve.type == "Fixed" ... else ROLLOUT
204
+ # logic == "And" ... else ANY (OR)
205
+ #
206
+ # So an unrecognised value does not fail — it takes the ELSE arm. A segment
207
+ # carrying conditionLogic "Xor" evaluates as OR, so a segment meant to require ALL
208
+ # of its conditions matches ANY of them: the rule fails OPEN and over-targets.
209
+ #
210
+ # Neither obvious fix works. Tolerating the value — as an unknown flag.type is
211
+ # tolerated — IS that silent mis-evaluation; flag.type is safe to tolerate only
212
+ # because nothing evaluates it. Raising MalformedPayloadError would discard the
213
+ # whole payload, so one additive server change takes down every flag on a pinned
214
+ # client (the #2372/#2395 outage shape).
215
+ #
216
+ # So the containing entity goes instead. Dropping a segment leaves rules pointing
217
+ # at it dangling, which is safe: Evaluation::Evaluator already treats an
218
+ # unresolvable segment_key as no-match (#1459), so the cascade fails CLOSED. The
219
+ # engine-generated `f-segment-unresolvable` golden vector pins that.
220
+ #
221
+ # Scoped deliberately to a NON-EMPTY unrecognised value. An absent field already
222
+ # defaults to "And" above, and the missing-required-field axis is a separate
223
+ # concern that the SDKs deliberately disagree on; checking only values that are
224
+ # present and unrecognised keeps this change purely additive.
225
+ SERVE_TYPES = ["Fixed", "Rollout"].freeze
226
+ CONDITION_LOGIC = ["And", "Or"].freeze
227
+
228
+ def drop_unevaluable(entities, kind)
229
+ entities.reject do |entity|
230
+ reason = yield(entity)
231
+ next false unless reason
232
+
233
+ @config.logger&.warn(
234
+ "Featureflip: dropping #{kind} #{entity.key.inspect}: #{reason}. This SDK " \
235
+ "version may be older than the flag configuration; the rest of the " \
236
+ "configuration was applied."
237
+ )
238
+ true
239
+ end
240
+ end
241
+
242
+ # Why this flag cannot be evaluated, or nil if it can. A reason rather than a
243
+ # boolean so the diagnostic can name the field and the value actually received.
244
+ def unevaluable_flag_reason(flag)
245
+ reason = unevaluable_serve_reason(flag.fallthrough, "fallthrough")
246
+ return reason if reason
247
+
248
+ (flag.rules || []).each do |rule|
249
+ reason = unevaluable_serve_reason(rule.serve, "rule[#{rule.id}].serve")
250
+ return reason if reason
251
+
252
+ (rule.condition_groups || []).each do |group|
253
+ next if group.operator.nil? || group.operator.empty?
254
+ next if CONDITION_LOGIC.include?(group.operator)
255
+
256
+ return "rule[#{rule.id}].conditionGroup.operator #{group.operator.inspect} " \
257
+ "is not a condition logic this SDK version understands"
258
+ end
259
+ end
260
+
261
+ nil
262
+ end
263
+
264
+ # Why this segment cannot be evaluated, or nil if it can.
265
+ def unevaluable_segment_reason(segment)
266
+ logic = segment.condition_logic
267
+ return nil if logic.nil? || logic.empty? || CONDITION_LOGIC.include?(logic)
268
+
269
+ "conditionLogic #{logic.inspect} is not a condition logic this SDK version understands"
270
+ end
271
+
272
+ def unevaluable_serve_reason(serve, path)
273
+ type = serve&.type
274
+ return nil if type.nil? || type.empty? || SERVE_TYPES.include?(type)
275
+
276
+ "#{path}.type #{type.inspect} is not a serve type this SDK version understands"
277
+ end
168
278
  end
169
279
  end
170
280
  end
@@ -241,25 +241,29 @@ module Featureflip
241
241
  return unless @event_processor
242
242
 
243
243
  context = normalize_context(context)
244
- @event_processor.queue_event({
244
+ @event_processor.queue_event(compact_event({
245
245
  type: "Custom",
246
246
  flagKey: event_key,
247
- userId: context["user_id"]&.to_s,
248
- metadata: metadata || {},
247
+ userId: resolve_event_user_id(context),
248
+ metadata: metadata,
249
249
  timestamp: Time.now.utc.iso8601
250
- })
250
+ }))
251
251
  end
252
252
 
253
253
  def identify(context)
254
254
  return unless @event_processor
255
255
 
256
256
  context = normalize_context(context)
257
- @event_processor.queue_event({
257
+ # Strip both identity spellings so the id is carried once, at the top
258
+ # level, and not duplicated inside the attribute bag.
259
+ attributes = context.reject { |k, _| IDENTITY_KEYS.include?(k) }
260
+ @event_processor.queue_event(compact_event({
258
261
  type: "Identify",
259
262
  flagKey: "$identify",
260
- userId: context["user_id"]&.to_s,
263
+ userId: resolve_event_user_id(context),
264
+ metadata: attributes,
261
265
  timestamp: Time.now.utc.iso8601
262
- })
266
+ }))
263
267
  end
264
268
 
265
269
  def flush
@@ -382,7 +386,10 @@ module Featureflip
382
386
  @event_processor = Events::EventProcessor.new(
383
387
  @http_client,
384
388
  flush_interval: @config.flush_interval,
385
- flush_batch_size: @config.flush_batch_size
389
+ flush_batch_size: @config.flush_batch_size,
390
+ # A dropped or re-queued batch must be visible: this fix keeps analytics that the
391
+ # edge briefly rejects, and it must not also hide the rejections themselves.
392
+ logger: @config.logger
386
393
  )
387
394
  @event_processor.start
388
395
  end
@@ -417,16 +424,43 @@ module Featureflip
417
424
  context.transform_keys(&:to_s)
418
425
  end
419
426
 
427
+ # Both spellings of the identity are accepted on an event context; the
428
+ # canonical +user_id+ wins when a caller supplies both. The evaluator
429
+ # already aliases these for bucketing, so events have to as well or an
430
+ # alias caller gets every event attributed to nil.
431
+ IDENTITY_KEYS = %w[user_id userId].freeze
432
+
433
+ def resolve_event_user_id(context)
434
+ raw = context["user_id"]
435
+ raw = context["userId"] if raw.nil?
436
+ raw&.to_s
437
+ end
438
+
439
+ # +userId+ and +metadata+ are optional on the wire. Omit them rather than
440
+ # sending nulls or empty bags, matching the other SDKs.
441
+ #
442
+ # +metadata+ is caller-supplied and untyped, so the emptiness check is
443
+ # guarded: a caller passing a non-collection must not take a NoMethodError
444
+ # into their request path just to record an analytics event.
445
+ def compact_event(event)
446
+ event.delete(:userId) if event[:userId].nil?
447
+ metadata = event[:metadata]
448
+ if metadata.nil? || (metadata.respond_to?(:empty?) && metadata.empty?)
449
+ event.delete(:metadata)
450
+ end
451
+ event
452
+ end
453
+
420
454
  def record_evaluation(key, context, variation_key)
421
455
  return unless @event_processor
422
456
 
423
- @event_processor.queue_event({
457
+ @event_processor.queue_event(compact_event({
424
458
  type: "Evaluation",
425
459
  flagKey: key,
426
- userId: context["user_id"]&.to_s,
460
+ userId: resolve_event_user_id(context),
427
461
  variation: variation_key,
428
462
  timestamp: Time.now.utc.iso8601
429
- })
463
+ }))
430
464
  end
431
465
 
432
466
  # Fire the registered evaluation inspectors. Called once per variation call
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.5.0"
2
+ VERSION = "2.6.1"
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.5.0
4
+ version: 2.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Featureflip
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-20 00:00:00.000000000 Z
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger