featureflip 2.4.2 → 2.5.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: aecafb52584cdd159977d5675c171b745b05be804b20c5a375edceb5dd719e8c
4
- data.tar.gz: 61e57b0db95b63da79bacc9c0f9af36ff24525cbf44f50f15a18f79124574a0a
3
+ metadata.gz: a41a0e3edb8d4325e4a0784930973f07ad8339e7b25b127f076bc7cfade0d948
4
+ data.tar.gz: 6ceb86141220f142710e6a0937af742270c61961ff87f583a2d727bab1c6ee65
5
5
  SHA512:
6
- metadata.gz: 2419e95a82d2512a886d535e03cfb644668424873e383af9713503c772e8fa14faa45b734ac4419705f7511fc69fbe61bab0cacd2f98a24e9ca57e67cbdb5978
7
- data.tar.gz: b6c7d2b7e1c4cc9c9aca050b9e97af993ec6201b7caf95f33bc2c6e0cdb8dc6a698c3eb3998f1805051e7f2e73907ea76a7f94a458a02b2f9185a347b4dc3f8f
6
+ metadata.gz: 013af1bacae587f2dd832bd7004ac4a94a45e8d635c64204d8ea892d4e62c80112d07856856d097f03393f4bbf7c3aef7d54c378672db9c61623d9053b8e3e3b
7
+ data.tar.gz: 822537c99bfdd30d8f2f4b16b9b8592d2cd23fdaccaf2cea0333fccdf68f0998877bf124da0060ec63abc0fb347f6506ad10f0fc235d8f22291de50a68f20a33
@@ -16,7 +16,12 @@ module Featureflip
16
16
  new(core)
17
17
  end
18
18
 
19
+ # A closed handle reports false (#2287). Every variation accessor here was
20
+ # already guarded, so a closed client correctly served defaults while still
21
+ # claiming to be initialized. close releases the core -- stopping streaming
22
+ # and polling -- so the store it would read can never update again.
19
23
  def initialized?
24
+ return false if @closed
20
25
  @core.initialized?
21
26
  end
22
27
 
@@ -234,8 +234,23 @@ 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 StandardError
238
- # Swallow event processing errors
237
+ rescue MalformedPayloadError => e
238
+ # A payload that violates the wire contract is discarded WHOLESALE rather
239
+ # than partially applied — a half-parsed snapshot silently mis-evaluates
240
+ # every flag it touches, which is strictly worse than serving the previous
241
+ # config until the next frame. See packages/CLAUDE.md.
242
+ #
243
+ # Never silent: a dropped `sync` means reconnect resync is not happening,
244
+ # and staying quiet about exactly this is how #2279 ran undetected.
245
+ @config.logger&.warn(
246
+ "Featureflip: discarding malformed #{event_type} payload: #{e.message}"
247
+ )
248
+ rescue StandardError => e
249
+ # Other event-processing errors must not kill the stream thread, but they
250
+ # are still worth surfacing — this used to swallow silently.
251
+ @config.logger&.warn(
252
+ "Featureflip: error handling #{event_type} event: #{e.class}: #{e.message}"
253
+ )
239
254
  end
240
255
  end
241
256
  end
@@ -2,4 +2,13 @@ module Featureflip
2
2
  class Error < StandardError; end
3
3
  class ConfigurationError < Error; end
4
4
  class InitializationError < Error; end
5
+
6
+ # Raised when a config payload violates the wire contract — e.g. an enum field
7
+ # arriving as a number where the contract specifies a string.
8
+ #
9
+ # Deliberately NOT raised for an unrecognised enum *string*: that is how a newer
10
+ # server introduces a new operator, and the evaluator already degrades an unknown
11
+ # operator to no-match. Only a TYPE violation is rejected, because that can never
12
+ # be a legitimate newer-server payload. See #2285.
13
+ class MalformedPayloadError < Error; end
5
14
  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
 
@@ -150,7 +158,10 @@ module Featureflip
150
158
  when "SemverLessThanOrEqual"
151
159
  targets.any? { |t| compare_semver(value, t, :<=) }
152
160
  else
153
- false
161
+ # Unrecognised operator. `nil` — NOT `false` — so the caller can tell
162
+ # "cannot evaluate" apart from "evaluated, did not match"; only the
163
+ # latter may be inverted by `negate` (#2262).
164
+ nil
154
165
  end
155
166
  end
156
167
 
@@ -79,11 +79,26 @@ module Featureflip
79
79
  request(method, path, body, retries: retries - 1)
80
80
  end
81
81
 
82
+ # Enum fields are strings on the wire. Ruby keeps whatever it is handed and the
83
+ # evaluator compares against string literals, so a non-string here is stored
84
+ # verbatim and then silently matches nothing forever — note `value || "And"`
85
+ # does NOT save us, because 0 is truthy in Ruby (#2285).
86
+ #
87
+ # Only the TYPE is checked. An unrecognised enum *string* is how a newer server
88
+ # introduces a new operator, and the evaluator already degrades that to
89
+ # no-match; rejecting it would break this SDK against every future server.
90
+ def require_enum_string!(value, field)
91
+ return value if value.nil? || value.is_a?(String)
92
+
93
+ raise MalformedPayloadError,
94
+ "#{field} must be a string, got #{value.class} (#{value.inspect})"
95
+ end
96
+
82
97
  def parse_flag(data)
83
98
  Models::FlagConfiguration.new(
84
99
  key: data["key"],
85
100
  version: data["version"],
86
- type: data["type"],
101
+ type: require_enum_string!(data["type"], "flag.type"),
87
102
  enabled: data["enabled"],
88
103
  variations: (data["variations"] || []).map { |v| Models::Variation.new(key: v["key"], value: v["value"]) },
89
104
  rules: (data["rules"] || []).map { |r| parse_rule(r) },
@@ -114,7 +129,7 @@ module Featureflip
114
129
 
115
130
  def parse_condition_group(data)
116
131
  Models::ConditionGroup.new(
117
- operator: data["operator"] || "And",
132
+ operator: require_enum_string!(data["operator"], "conditionGroup.operator") || "And",
118
133
  conditions: (data["conditions"] || []).map { |c| parse_condition(c) }
119
134
  )
120
135
  end
@@ -122,7 +137,7 @@ module Featureflip
122
137
  def parse_condition(data)
123
138
  Models::Condition.new(
124
139
  attribute: data["attribute"],
125
- operator: data["operator"],
140
+ operator: require_enum_string!(data["operator"], "condition.operator"),
126
141
  values: data["values"],
127
142
  negate: data["negate"] || false
128
143
  )
@@ -134,7 +149,7 @@ module Featureflip
134
149
  end
135
150
 
136
151
  Models::ServeConfig.new(
137
- type: data["type"],
152
+ type: require_enum_string!(data["type"], "serve.type"),
138
153
  variation: data["variation"],
139
154
  bucket_by: data["bucketBy"],
140
155
  salt: data["salt"],
@@ -147,7 +162,7 @@ module Featureflip
147
162
  key: data["key"],
148
163
  version: data["version"],
149
164
  conditions: (data["conditions"] || []).map { |c| parse_condition(c) },
150
- condition_logic: data["conditionLogic"] || "And"
165
+ condition_logic: require_enum_string!(data["conditionLogic"], "segment.conditionLogic") || "And"
151
166
  )
152
167
  end
153
168
  end
@@ -136,23 +136,30 @@ module Featureflip
136
136
 
137
137
  # --- Evaluation methods ---
138
138
 
139
+ # The typed accessors declare the JSON type they expect so a mismatch degrades
140
+ # to the caller's default with reason "Error" (#2281/#2286). json_variation
141
+ # takes no expectation: its value is an arbitrary structure, so there is no
142
+ # single type to check it against.
139
143
  def bool_variation(key, context, default_value)
140
- evaluate_flag(key, context, default_value)
144
+ evaluate_flag(key, context, default_value, expected: :bool)
141
145
  end
142
146
 
143
147
  def string_variation(key, context, default_value)
144
- evaluate_flag(key, context, default_value)
148
+ evaluate_flag(key, context, default_value, expected: :string)
145
149
  end
146
150
 
147
151
  def number_variation(key, context, default_value)
148
- evaluate_flag(key, context, default_value)
152
+ evaluate_flag(key, context, default_value, expected: :number)
149
153
  end
150
154
 
151
155
  def json_variation(key, context, default_value)
152
156
  evaluate_flag(key, context, default_value)
153
157
  end
154
158
 
155
- def variation_detail(key, context, default_value)
159
+ # +expected+ is the JSON type the caller's accessor requires (:bool, :string
160
+ # or :number), or nil for the generic/JSON accessors, which are not
161
+ # type-checked because they have no single expected type.
162
+ def variation_detail(key, context, default_value, expected: nil)
156
163
  context = normalize_context(context)
157
164
 
158
165
  if @test_mode
@@ -190,7 +197,20 @@ module Featureflip
190
197
  result.reason
191
198
  end
192
199
 
193
- value = result.value.nil? ? default_value : result.value
200
+ served = result.value
201
+ value = served.nil? ? default_value : served
202
+
203
+ # A typed accessor asked for a specific JSON type and the SERVED value is not
204
+ # it. Degrade to the caller's default and report Error so the mismatch is
205
+ # detectable, rather than handing back a String where the caller's code
206
+ # expects true/false (#2281/#2286). Checking `served` rather than `value`
207
+ # matters: a variation whose value is genuinely JSON null has already been
208
+ # replaced by default_value above, and that substitute would pass any check.
209
+ if expected && !value_matches_type?(served, expected)
210
+ value = default_value
211
+ reason = "Error"
212
+ end
213
+
194
214
  record_evaluation(key, context, result.variation_key)
195
215
  notify_inspectors(
196
216
  key, context, value,
@@ -221,25 +241,29 @@ module Featureflip
221
241
  return unless @event_processor
222
242
 
223
243
  context = normalize_context(context)
224
- @event_processor.queue_event({
244
+ @event_processor.queue_event(compact_event({
225
245
  type: "Custom",
226
246
  flagKey: event_key,
227
- userId: context["user_id"]&.to_s,
228
- metadata: metadata || {},
247
+ userId: resolve_event_user_id(context),
248
+ metadata: metadata,
229
249
  timestamp: Time.now.utc.iso8601
230
- })
250
+ }))
231
251
  end
232
252
 
233
253
  def identify(context)
234
254
  return unless @event_processor
235
255
 
236
256
  context = normalize_context(context)
237
- @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({
238
261
  type: "Identify",
239
262
  flagKey: "$identify",
240
- userId: context["user_id"]&.to_s,
263
+ userId: resolve_event_user_id(context),
264
+ metadata: attributes,
241
265
  timestamp: Time.now.utc.iso8601
242
- })
266
+ }))
243
267
  end
244
268
 
245
269
  def flush
@@ -367,17 +391,27 @@ module Featureflip
367
391
  @event_processor.start
368
392
  end
369
393
 
370
- def evaluate_flag(key, context, default_value)
394
+ def evaluate_flag(key, context, default_value, expected: nil)
371
395
  if @test_mode
372
396
  return @test_values.fetch(key, default_value)
373
397
  end
374
398
 
375
- detail = variation_detail(key, context, default_value)
399
+ detail = variation_detail(key, context, default_value, expected: expected)
376
400
  detail.value
377
401
  rescue StandardError
378
402
  default_value
379
403
  end
380
404
 
405
+ # True when +value+ is of the JSON type the caller's typed accessor requires.
406
+ def value_matches_type?(value, expected)
407
+ case expected
408
+ when :bool then value == true || value == false
409
+ when :string then value.is_a?(String)
410
+ when :number then value.is_a?(Numeric)
411
+ else true
412
+ end
413
+ end
414
+
381
415
  def get_segment(key)
382
416
  @store.get_segment(key)
383
417
  end
@@ -387,16 +421,43 @@ module Featureflip
387
421
  context.transform_keys(&:to_s)
388
422
  end
389
423
 
424
+ # Both spellings of the identity are accepted on an event context; the
425
+ # canonical +user_id+ wins when a caller supplies both. The evaluator
426
+ # already aliases these for bucketing, so events have to as well or an
427
+ # alias caller gets every event attributed to nil.
428
+ IDENTITY_KEYS = %w[user_id userId].freeze
429
+
430
+ def resolve_event_user_id(context)
431
+ raw = context["user_id"]
432
+ raw = context["userId"] if raw.nil?
433
+ raw&.to_s
434
+ end
435
+
436
+ # +userId+ and +metadata+ are optional on the wire. Omit them rather than
437
+ # sending nulls or empty bags, matching the other SDKs.
438
+ #
439
+ # +metadata+ is caller-supplied and untyped, so the emptiness check is
440
+ # guarded: a caller passing a non-collection must not take a NoMethodError
441
+ # into their request path just to record an analytics event.
442
+ def compact_event(event)
443
+ event.delete(:userId) if event[:userId].nil?
444
+ metadata = event[:metadata]
445
+ if metadata.nil? || (metadata.respond_to?(:empty?) && metadata.empty?)
446
+ event.delete(:metadata)
447
+ end
448
+ event
449
+ end
450
+
390
451
  def record_evaluation(key, context, variation_key)
391
452
  return unless @event_processor
392
453
 
393
- @event_processor.queue_event({
454
+ @event_processor.queue_event(compact_event({
394
455
  type: "Evaluation",
395
456
  flagKey: key,
396
- userId: context["user_id"]&.to_s,
457
+ userId: resolve_event_user_id(context),
397
458
  variation: variation_key,
398
459
  timestamp: Time.now.utc.iso8601
399
- })
460
+ }))
400
461
  end
401
462
 
402
463
  # Fire the registered evaluation inspectors. Called once per variation call
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.4.2"
2
+ VERSION = "2.5.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.4.2
4
+ version: 2.5.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-05 00:00:00.000000000 Z
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger