featureflip 2.5.1 → 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: a41a0e3edb8d4325e4a0784930973f07ad8339e7b25b127f076bc7cfade0d948
4
- data.tar.gz: 6ceb86141220f142710e6a0937af742270c61961ff87f583a2d727bab1c6ee65
3
+ metadata.gz: edea6a4841539f13194e1e03e76bd7db298e713a0d79ec66327838cc56ed8408
4
+ data.tar.gz: 5a2d3f0946e19fb1734bc7db35dc1006f5d39f22626cbd147f527c31aa8dbddd
5
5
  SHA512:
6
- metadata.gz: 013af1bacae587f2dd832bd7004ac4a94a45e8d635c64204d8ea892d4e62c80112d07856856d097f03393f4bbf7c3aef7d54c378672db9c61623d9053b8e3e3b
7
- data.tar.gz: 822537c99bfdd30d8f2f4b16b9b8592d2cd23fdaccaf2cea0333fccdf68f0998877bf124da0060ec63abc0fb347f6506ad10f0fc235d8f22291de50a68f20a33
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
@@ -74,16 +74,32 @@ module Featureflip
74
74
  SemverVersion = Struct.new(:release, :prerelease)
75
75
  private_constant :SemverVersion
76
76
 
77
- # An ISO-8601 date-time with no timezone offset (no trailing "Z"/±hh:mm),
78
- # so it must be assumed UTC before parsing.
79
- ISO_NO_OFFSET = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?\z/
80
- private_constant :ISO_NO_OFFSET
81
-
82
- # A bare ISO-8601 calendar date ("2024-01-01") with no time component. The
83
- # engine's DateTimeOffset.TryParse accepts these (midnight, assumed UTC),
84
- # but Time.iso8601 rejects them, so they need their own midnight-UTC path.
85
- ISO_DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
86
- 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
87
103
 
88
104
  def evaluate_operator(operator, value, targets)
89
105
  # Case-insensitive views for the string/relational/date operators.
@@ -220,31 +236,87 @@ module Featureflip
220
236
  # TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
221
237
  # without an offset is assumed UTC. A bare integer is treated as Unix time
222
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
+
223
267
  def parse_datetime(value)
224
- s = value.to_s.strip
225
-
226
- begin
227
- # `Time.iso8601` honors offsets/"Z" but raises on a no-offset string;
228
- # append the missing time/offset to assume midnight UTC for bare dates
229
- # and UTC for offset-less date-times (mirroring DateTimeOffset.TryParse
230
- # with AssumeUniversal).
231
- iso =
232
- if s.match?(ISO_DATE_ONLY)
233
- "#{s}T00:00:00Z"
234
- elsif s.match?(ISO_NO_OFFSET)
235
- "#{s}Z"
236
- else
237
- s
238
- end
239
- return Time.iso8601(iso).utc
240
- rescue ArgumentError
241
- # 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
242
284
  end
243
285
 
244
286
  # Integer fallback: treat a bare integer as Unix time in seconds.
245
- 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/)
246
307
  begin
247
- 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
248
320
  rescue RangeError, ArgumentError
249
321
  return nil
250
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
@@ -386,7 +386,10 @@ module Featureflip
386
386
  @event_processor = Events::EventProcessor.new(
387
387
  @http_client,
388
388
  flush_interval: @config.flush_interval,
389
- 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
390
393
  )
391
394
  @event_processor.start
392
395
  end
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.5.1"
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.1
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-23 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