featureflip 2.5.1 → 2.7.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: a41a0e3edb8d4325e4a0784930973f07ad8339e7b25b127f076bc7cfade0d948
4
- data.tar.gz: 6ceb86141220f142710e6a0937af742270c61961ff87f583a2d727bab1c6ee65
3
+ metadata.gz: 3d0798d6f9dde45580a5345467cfcbc108b01a0bda1ed9c0224a6e58305ec9bd
4
+ data.tar.gz: d7a7ceca0d72477e0987ee83b0af3bdcee069f47add6ecc89a42bb7c9cb635e4
5
5
  SHA512:
6
- metadata.gz: 013af1bacae587f2dd832bd7004ac4a94a45e8d635c64204d8ea892d4e62c80112d07856856d097f03393f4bbf7c3aef7d54c378672db9c61623d9053b8e3e3b
7
- data.tar.gz: 822537c99bfdd30d8f2f4b16b9b8592d2cd23fdaccaf2cea0333fccdf68f0998877bf124da0060ec63abc0fb347f6506ad10f0fc235d8f22291de50a68f20a33
6
+ metadata.gz: 2546ac239e8b8c0b120caa1d8286ad9a58e4fe23900a58cb63343ad469e9e324548ebac72ea194c102c023e5de2ef2e88fd344c80d15beb49dfc18803a3bd711
7
+ data.tar.gz: 6393ec68c36a42813a537fa699e9801132606d273264c142b8d75082c0fc7d260160b27aac53306d49a14bd2d895de2da57e8284690c8affb031076a76500a47
@@ -195,11 +195,27 @@ module Featureflip
195
195
  end
196
196
  end
197
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.
198
+ # Capped exponential backoff, jittered at every level. failures == 0 means a
199
+ # healthy stream just closed cleanly; the jitter band's lower bound keeps the
200
+ # base floor in force so we don't busy-loop.
201
+ #
202
+ # Jittering the FIRST reconnect is load-bearing, not cosmetic: the drops this
203
+ # absorbs are fleet-wide — one edge event severs every stream at once (#2457)
204
+ # — so every client re-enters here at failures == 0 together. A constant there
205
+ # replayed the drop's own synchronisation as a reconnect spike one base delay
206
+ # later (#2508).
200
207
  def backoff_delay(failures)
201
208
  exponent = failures <= 0 ? 0 : failures - 1
202
- [RECONNECT_BASE_DELAY_SECONDS * (2**exponent), MAX_BACKOFF_SECONDS].min
209
+ with_jitter([RECONNECT_BASE_DELAY_SECONDS * (2**exponent), MAX_BACKOFF_SECONDS].min)
210
+ end
211
+
212
+ # Returns a value in [d/2, d] to de-correlate reconnects across many SDK
213
+ # instances (thundering-herd avoidance after a shared outage).
214
+ def with_jitter(delay)
215
+ return delay if delay <= 0
216
+
217
+ half = delay / 2.0
218
+ half + (rand * half)
203
219
  end
204
220
 
205
221
  # Sleep for `seconds`, but return immediately if stop() fires — so a pending
@@ -234,6 +250,15 @@ module Featureflip
234
250
  flags, segments = @http_client.parse_flags_response(JSON.parse(data))
235
251
  @on_sync&.call(flags, segments)
236
252
  end
253
+ rescue UnevaluableEntityError => e
254
+ # Not a malformed payload: the frame was well-formed and simply described
255
+ # behaviour this build cannot evaluate, so the entity was dropped rather than
256
+ # the payload discarded (#2402). Logged at the same volume — a flag that
257
+ # silently stopped updating is exactly as confusing as one that never arrived.
258
+ @config.logger&.warn(
259
+ "Featureflip: dropping #{event_type} update: #{e.message}. This SDK version " \
260
+ "may be older than the flag configuration."
261
+ )
237
262
  rescue MalformedPayloadError => e
238
263
  # A payload that violates the wire contract is discarded WHOLESALE rather
239
264
  # 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,36 @@ 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
103
+
104
+ # Length of each month in a non-leap year, indexed 1..12. Index 0 is unused padding.
105
+ DAYS_IN_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
106
+ private_constant :DAYS_IN_MONTH
87
107
 
88
108
  def evaluate_operator(operator, value, targets)
89
109
  # Case-insensitive views for the string/relational/date operators.
@@ -216,35 +236,153 @@ module Featureflip
216
236
  left.send(op, right)
217
237
  end
218
238
 
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
+ # Whether +date+ -- always "YYYY-MM-DD", since only canonicalize_iso calls this --
245
+ # names a day that exists.
246
+ #
247
+ # ISO_OPERAND matches the SHAPE of a calendar date and a character class cannot
248
+ # express "is a real day", so "2024-02-30", "2023-02-29" and "2024-04-31" all pass
249
+ # the grammar. The engine, csharp, go, python and java then reject them at parse;
250
+ # ruby, js and php ROLLED THEM OVER into the 1st of the following month, so one
251
+ # saved rule served different variations to two users purely by which SDK their
252
+ # service ran (#2491).
253
+ #
254
+ # Hand-rolled rather than delegated to Date.valid_date?, which applies the Italian
255
+ # calendar reform by DEFAULT and so rejects 1582-10-05..14 -- dates the engine
256
+ # resolves normally. Passing Date::GREGORIAN would fix that, but computing the
257
+ # arithmetic identically in ruby, js and php is what keeps the accepted set a
258
+ # property of THIS contract rather than of three separate calendars.
259
+ #
260
+ # Proleptic Gregorian, matching the engine: the leap rule applies at every year
261
+ # rather than from a reform date onward.
262
+ def real_calendar_day?(date)
263
+ year = date[0, 4].to_i
264
+ month = date[5, 2].to_i
265
+ day = date[8, 2].to_i
266
+
267
+ # Month 0 and day 0 are the shapes only php mishandled, rolling each BACKWARDS
268
+ # into the previous year ("2024-00-01" -> 2023-12-01, "2024-01-00" -> 2023-12-31).
269
+ return false if month < 1 || month > 12 || day < 1
270
+
271
+ leap_day = month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) ? 1 : 0
272
+ day <= DAYS_IN_MONTH[month] + leap_day
273
+ end
274
+
275
+ # Rewrites an accepted ISO operand into the strict extended form Time.iso8601
276
+ # parses: "T" separator, seconds present, offset spelled "+HH:MM" or "Z".
277
+ # Returns nil when the operand is not an accepted ISO shape.
278
+ def canonicalize_iso(s)
279
+ m = ISO_OPERAND.match(s)
280
+ return nil if m.nil?
281
+
282
+ date, hh, mm, ss, frac, off = m.captures
283
+
284
+ # Checked on the WRITTEN date, before any offset is applied. Validating the
285
+ # resolved UTC components instead would accept "2024-02-30T00:00:00+05:00",
286
+ # which lands on 2024-02-29T19:00Z -- a date that does exist.
287
+ return nil unless real_calendar_day?(date)
288
+
289
+ return "#{date}T00:00:00Z" if hh.nil?
290
+
291
+ # The engine's DateTimeOffset.TryParse rejects hour 24 outright rather than
292
+ # rolling it over to 00:00 the next day, which is what Time.iso8601 does.
293
+ return nil if hh >= "24"
294
+
295
+ ss ||= "00"
296
+ off =
297
+ if off.nil? then "Z"
298
+ elsif off.length == 5 && off != "Z" then "#{off[0, 3]}:#{off[3, 2]}"
299
+ else off
300
+ end
301
+ "#{date}T#{hh}:#{mm}:#{ss}#{frac}#{off}"
302
+ end
303
+
219
304
  # Parses a date-time to a UTC `Time`, mirroring the engine's
220
305
  # TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
221
306
  # without an offset is assumed UTC. A bare integer is treated as Unix time
222
307
  # in seconds. Returns nil when the input parses as neither.
223
308
  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.
309
+ s = value.to_s
310
+ # Trim exactly the engine's whitespace class, then reject anything still
311
+ # carrying a character no operand may contain.
312
+ s = s.gsub(/\A[#{Regexp.escape(OPERAND_WHITESPACE)}]+|[#{Regexp.escape(OPERAND_WHITESPACE)}]+\z/, "")
313
+ return nil if s.empty? || s.match?(FORBIDDEN_OPERAND_CHAR)
314
+
315
+ iso = canonicalize_iso(s)
316
+ if iso
317
+ begin
318
+ # Offset-less forms were canonicalized to an explicit "Z", mirroring
319
+ # DateTimeOffset.TryParse with AssumeUniversal.
320
+ t = Time.iso8601(iso).utc
321
+
322
+ # The SAME range the integer branch below enforces, applied to the
323
+ # RESOLVED instant. The engine parses with DateTimeOffset.TryParse, so its
324
+ # accepted set is bounded by DateTimeOffset's range and it returns false
325
+ # outside it; ruby, js, php, go and java all resolve past both ends --
326
+ # year 0 to a real instant, and a 4-digit year plus an offset to one
327
+ # beyond either bound (#2500).
328
+ #
329
+ # Checked on the RESOLVED instant, deliberately unlike the WRITTEN-triple
330
+ # check in real_calendar_day?. The two answer different questions: whether
331
+ # the operand names a real DAY is a property of what was written
332
+ # ("2024-02-30T00:00:00+05:00" lands on a real UTC day but names none),
333
+ # whereas whether it is REPRESENTABLE is a property of what it resolves to
334
+ # -- the offset is exactly what carries "0001-01-01T00:00:00+05:00" under
335
+ # the floor and "9999-12-31T23:59:59-05:00" over the ceiling.
336
+ #
337
+ # to_i floors, matching the other SDKs: a fractional second is always a
338
+ # non-negative addend, so "0000-12-31T23:59:59.5Z" floors to MIN-1 and is
339
+ # rejected while "0001-01-01T00:00:00.5Z" floors to MIN and is kept.
340
+ seconds = t.to_i
341
+ return nil if seconds < MIN_UNIX_SECONDS || seconds > MAX_UNIX_SECONDS
342
+
343
+ return t
344
+ rescue ArgumentError
345
+ # An unreal day is already gone (real_calendar_day?), so this now only
346
+ # catches the out-of-range minute and second the grammar's \d{2} still
347
+ # admits ("00:99", "00:00:99"), which every other SDK rejects too. Falls
348
+ # through to the Unix-seconds fallback, which rejects a non-integer.
349
+ end
242
350
  end
243
351
 
244
352
  # Integer fallback: treat a bare integer as Unix time in seconds.
245
- if s.match?(/\A-?\d+\z/)
353
+ #
354
+ # Out-of-range seconds match NOTHING rather than resolving to a far-future
355
+ # instant: the engine's FromUnixTimeSeconds throws outside DateTimeOffset's
356
+ # range and TryParseDateTime returns false. Ruby's Time has a far wider range
357
+ # and would happily accept the value, so the bound has to be explicit. The
358
+ # case that matters in practice is a MILLISECONDS timestamp pasted where
359
+ # seconds belong, which would otherwise land in the year 55829 and satisfy
360
+ # every `After` comparison (#2432).
361
+ #
362
+ # The sign class matches the engine's `long.TryParse` with
363
+ # `NumberStyles.Integer` (`AllowLeadingWhite | AllowTrailingWhite |
364
+ # AllowLeadingSign`), so a leading "+" is accepted deliberately rather than
365
+ # incidentally, and `Integer()` reads it the same way. Omitting it made "+5"
366
+ # an unparseable string matching NOTHING here while the engine and four other
367
+ # SDKs read it as five seconds past the epoch (#2458).
368
+ #
369
+ # The whitespace flags now match too: the trim above is exactly
370
+ # `AllowLeadingWhite`/`AllowTrailingWhite`'s class, and anything outside it
371
+ # was already rejected by FORBIDDEN_OPERAND_CHAR (#2468).
372
+ if s.match?(/\A[+-]?\d+\z/)
246
373
  begin
247
- return Time.at(Integer(s)).utc
374
+ # Base 10 EXPLICITLY. Bare `Integer(s)` honours Ruby's literal base
375
+ # prefixes, so a leading zero means OCTAL: "0500" became 320 rather than
376
+ # 500, and "0800" raised ArgumentError (8 is not an octal digit) and
377
+ # matched nothing at all. Every other implementation parses base 10 --
378
+ # the engine's `long.TryParse`, go's `ParseInt(s, 10, 64)`, java's
379
+ # `Long.parseLong`, python's `int()`, php's `(int)` cast and js's
380
+ # `Number()` -- so ruby was alone in reading a zero-padded unix timestamp
381
+ # as a different instant. Pinned by `c-date-unix-leading-zero-*` (#2458).
382
+ seconds = Integer(s, 10)
383
+ return nil if seconds < MIN_UNIX_SECONDS || seconds > MAX_UNIX_SECONDS
384
+
385
+ return Time.at(seconds).utc
248
386
  rescue RangeError, ArgumentError
249
387
  return nil
250
388
  end
@@ -1,38 +1,127 @@
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
37
+
38
+ # Coalescing state for the drain loop. @auto_flush_in_flight above only ever
39
+ # guarded the SIZE trigger; nothing stopped the background thread's interval
40
+ # tick, an explicit Client#flush and a size-triggered flush from entering the
41
+ # loop together. Two concurrent drains mean two request streams against the
42
+ # endpoint the backoff gate exists to protect — and a success in one clears
43
+ # the gate a failure in the other has just armed, re-opening the
44
+ # one-request-per-event behaviour outright (#2477).
45
+ #
46
+ # Generation counters rather than a bare flag: a waiter has to be able to
47
+ # tell "the drain I was waiting for has finished" from "a later drain is
48
+ # running", or it would sleep through its own completion.
49
+ @drain_in_flight = false
50
+ @drain_started = 0
51
+ @drain_finished = 0
52
+ @drain_done = ConditionVariable.new
12
53
  end
13
54
 
14
55
  def queue_event(event)
15
- should_flush = false
56
+ dropped = 0
16
57
  @mutex.synchronize do
58
+ # After #stop nothing will flush again, so buffering here would only leak.
59
+ return if @stopped
60
+
17
61
  @queue << event
18
- should_flush = @queue.length >= @flush_batch_size
62
+ dropped = trim_to_bound
19
63
  end
20
- flush if should_flush
64
+
65
+ warn_overflow(dropped)
66
+ auto_flush
21
67
  end
22
68
 
69
+ # Drains the queue a batch at a time, one request per batch.
70
+ #
71
+ # This used to post the WHOLE queue in a single request, which was harmless while a
72
+ # failure emptied the queue: it never grew far past @flush_batch_size. Re-queuing
73
+ # failures (#2456) is what changed that — after a sustained outage the queue can sit
74
+ # at its 10,000-event bound, and posting all of that at once risks a body the server
75
+ # rejects outright. A 413 is non-retryable, so the entire backlog would be dropped by
76
+ # the very path added to preserve it.
77
+ # At most one drain runs at a time. A caller arriving while one is already
78
+ # going waits for it and returns — it does NOT start its own, and it does NOT
79
+ # return early, because a caller that asked for a flush is asking for its
80
+ # events to be sent. This matches the js/node SDKs, whose flush() has always
81
+ # returned the in-flight promise (#2477).
23
82
  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
83
+ mine = @mutex.synchronize do
84
+ if @drain_in_flight
85
+ nil
86
+ else
87
+ @drain_in_flight = true
88
+ @drain_started += 1
89
+ end
29
90
  end
30
91
 
31
- return unless events_to_send&.any?
92
+ if mine.nil?
93
+ @mutex.synchronize do
94
+ waiting_for = @drain_started
95
+ @drain_done.wait(@mutex) while @drain_finished < waiting_for
96
+ end
97
+ return
98
+ end
32
99
 
33
- @http_client.post_events(events_to_send)
34
- rescue StandardError
35
- # Events are best-effort — drop on failure
100
+ begin
101
+ drain
102
+ ensure
103
+ @mutex.synchronize do
104
+ @drain_in_flight = false
105
+ @drain_finished = mine
106
+ @drain_done.broadcast
107
+ end
108
+ end
109
+ end
110
+
111
+ # The drain loop itself, callable when coalescing must be bypassed.
112
+ # Private: #flush is the public entry point, and #stop reaches this directly.
113
+ private def drain
114
+ loop do
115
+ batch = drain_batch
116
+ return if batch.empty?
117
+
118
+ # False means the batch went back on the queue, and it is at the HEAD of the very
119
+ # queue this loop drains — carrying on would re-send it immediately and spin for
120
+ # as long as the endpoint stayed down. A dropped batch returns true instead: the
121
+ # queue has shrunk, so the loop still terminates, and one poison batch must not
122
+ # block the backlog behind it.
123
+ return unless send_batch(batch)
124
+ end
36
125
  end
37
126
 
38
127
  def start
@@ -42,10 +131,15 @@ module Featureflip
42
131
  until @stop_flag
43
132
  sleep(1)
44
133
  elapsed += 1
45
- queue_size = @mutex.synchronize { @queue.length }
46
- if elapsed >= @flush_interval || queue_size >= @flush_batch_size
134
+ next if @stop_flag
135
+
136
+ if elapsed >= @flush_interval
137
+ # The interval tick is the retry vehicle for a re-queued batch, so it is
138
+ # deliberately NOT subject to the size trigger's backoff gate.
139
+ elapsed = 0
140
+ flush
141
+ elsif auto_flush
47
142
  elapsed = 0
48
- flush unless @stop_flag
49
143
  end
50
144
  end
51
145
  end
@@ -56,7 +150,168 @@ module Featureflip
56
150
  @thread&.wakeup rescue nil
57
151
  @thread&.join(5)
58
152
  @thread = nil
59
- flush
153
+
154
+ # Closed BEFORE the final flush so a failure there is dropped rather than
155
+ # re-queued: nothing will flush again, and retrying until the queue drains would
156
+ # hang shutdown for as long as the endpoint stayed down. One attempt, then let go.
157
+ @mutex.synchronize { @stopped = true }
158
+ # drain, not flush: shutdown must never be the call that gets coalesced
159
+ # away. If the interval tick's drain happens to be in flight, flush would
160
+ # wait for it and return, and anything queued after that loop's last look
161
+ # would be discarded unsent. Two drains overlapping is safe here precisely
162
+ # because @stopped is already set, so neither can re-queue and there is no
163
+ # backoff left to disarm.
164
+ drain
165
+ @mutex.synchronize { @queue.clear }
166
+ end
167
+
168
+ private
169
+
170
+ # Batch-size-triggered flush. Returns true only if it actually sent.
171
+ #
172
+ # A re-queued batch leaves the queue at or above @flush_batch_size, so without a gate
173
+ # every subsequent event would trigger another flush — turning a failing endpoint
174
+ # into one request per evaluation, which is worse for the server than the dropping
175
+ # this replaced. Two guards, and both are load-bearing:
176
+ #
177
+ # * the backoff gate suppresses the size trigger for one flush interval after a
178
+ # retryable failure, leaving the background thread's interval tick as the retry
179
+ # vehicle;
180
+ # * the in-flight latch covers what the gate cannot, because the gate is only armed
181
+ # once a flush has already FAILED and the size trigger fires again long before
182
+ # the first round-trip returns. Without it a tight loop of events starts a pile
183
+ # of concurrent flushes.
184
+ #
185
+ # An explicit #flush (the public API, and the interval tick) bypasses both: the
186
+ # caller asked for a send.
187
+ def auto_flush
188
+ return false unless @mutex.synchronize { claim_size_trigger }
189
+
190
+ begin
191
+ flush
192
+ ensure
193
+ @mutex.synchronize { @auto_flush_in_flight = false }
194
+ end
195
+ true
196
+ end
197
+
198
+ # Whether a size-triggered flush may start, claiming the latch if so.
199
+ # Caller holds @mutex.
200
+ def claim_size_trigger
201
+ return false if @stopped
202
+ return false if @auto_flush_in_flight
203
+ return false if @queue.length < @flush_batch_size
204
+ return false if monotonic_now < @next_auto_flush_at
205
+
206
+ @auto_flush_in_flight = true
207
+ end
208
+
209
+ # Never called with @mutex held: the POST blocks for as long as the endpoint takes to
210
+ # answer — and Http::Client sleeps a second before its own single inline retry — so
211
+ # the background thread and every queue_event caller would block behind it.
212
+ # Sends one batch. Returns true if #flush may go on to the next one — see the comment
213
+ # at its call site for why a re-queued batch must stop the drain.
214
+ def send_batch(events)
215
+ @http_client.post_events(events)
216
+ @mutex.synchronize { @next_auto_flush_at = 0.0 }
217
+ true
218
+ rescue StandardError => e
219
+ unless retryable_failure?(e)
220
+ @logger&.warn(
221
+ "Featureflip: dropped #{events.length} analytics event(s) the events endpoint " \
222
+ "rejected (#{e.class}: #{e.message}); the failure is not retryable"
223
+ )
224
+ return true
225
+ end
226
+
227
+ dropped = requeue(events)
228
+ # Armed here rather than at the first attempt, so the interval is counted from the
229
+ # moment post_events' inline retry finally gave up.
230
+ @mutex.synchronize { @next_auto_flush_at = monotonic_now + @flush_interval }
231
+
232
+ if dropped.nil?
233
+ @logger&.warn(
234
+ "Featureflip: dropped #{events.length} analytics event(s) " \
235
+ "(#{e.class}: #{e.message}); the processor is shutting down and will not flush again"
236
+ )
237
+ else
238
+ @logger&.warn(
239
+ "Featureflip: failed to send #{events.length} analytics event(s) " \
240
+ "(#{e.class}: #{e.message}); re-queued for the next flush"
241
+ )
242
+ warn_overflow(dropped)
243
+ end
244
+
245
+ false
246
+ end
247
+
248
+ # Whether the same batch could succeed if it were sent again.
249
+ #
250
+ # For an HTTP answer the status decides: any 5xx — the production edge answers this
251
+ # endpoint with a 503 at a low but constant rate (#2456) — and 429, where the server
252
+ # is explicitly asking us to come back later. Anything else will fail identically next
253
+ # time: 401/403 means the SDK key was rejected, 400 means the body is malformed, and
254
+ # retrying either forever would pin the queue at its bound and starve every later
255
+ # event.
256
+ #
257
+ # Everything that is NOT an HTTP answer is treated as transient. A transport fault
258
+ # (connection reset, DNS, TLS) or a timeout carries no status at all and is exactly
259
+ # the kind of failure a later flush gets past, so the default has to be "keep it"
260
+ # rather than "not a 5xx, therefore permanent".
261
+ def retryable_failure?(error)
262
+ return error.status >= 500 || error.status == 429 if error.is_a?(Featureflip::HttpStatusError)
263
+
264
+ true
265
+ end
266
+
267
+ # Takes up to @flush_batch_size of the OLDEST events, leaving the rest queued.
268
+ def drain_batch
269
+ @mutex.synchronize do
270
+ return [] if @queue.empty?
271
+
272
+ @queue.shift([@flush_batch_size, @queue.length].min)
273
+ end
274
+ end
275
+
276
+ # Puts a batch that failed to send back at the FRONT of the queue, so the next flush
277
+ # retries it ahead of newer events and rough chronological order survives.
278
+ #
279
+ # Returns how many events were shed to stay within the bound — or nil if it refused
280
+ # the batch entirely because #stop has closed the queue. The caller needs to tell
281
+ # those apart: "re-queued for the next flush" is a lie once there will be no next
282
+ # flush, and this is the branch a shutdown during an outage takes.
283
+ def requeue(events)
284
+ @mutex.synchronize do
285
+ return nil if @stopped
286
+
287
+ @queue.unshift(*events)
288
+ trim_to_bound
289
+ end
290
+ end
291
+
292
+ # Sheds oldest-first until the queue fits the bound, returning how many went.
293
+ # Caller holds @mutex.
294
+ def trim_to_bound
295
+ overflow = @queue.length - @max_queue_size
296
+ return 0 if overflow <= 0
297
+
298
+ @queue.shift(overflow)
299
+ overflow
300
+ end
301
+
302
+ def warn_overflow(dropped)
303
+ return if dropped.zero?
304
+
305
+ @logger&.warn(
306
+ "Featureflip: event queue is full (#{@max_queue_size}); " \
307
+ "dropped #{dropped} of the oldest analytics event(s)"
308
+ )
309
+ end
310
+
311
+ # Wall-clock time can jump backwards (NTP, a suspended host); the backoff gate must
312
+ # not be extended or skipped by that.
313
+ def monotonic_now
314
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
60
315
  end
61
316
  end
62
317
  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.7.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureflip
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.1
4
+ version: 2.7.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-08-23 00:00:00.000000000 Z
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger