featureflip 2.4.2 → 2.5.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: aecafb52584cdd159977d5675c171b745b05be804b20c5a375edceb5dd719e8c
4
- data.tar.gz: 61e57b0db95b63da79bacc9c0f9af36ff24525cbf44f50f15a18f79124574a0a
3
+ metadata.gz: f3e88364434f9dced8db82624524bf1ffe905b35b5ae8a062896bfb6c02dc5e6
4
+ data.tar.gz: 87d5bdaa62bb9f6e8eb0685f235315b31adfb5cd0dbdaac478965144a09709ff
5
5
  SHA512:
6
- metadata.gz: 2419e95a82d2512a886d535e03cfb644668424873e383af9713503c772e8fa14faa45b734ac4419705f7511fc69fbe61bab0cacd2f98a24e9ca57e67cbdb5978
7
- data.tar.gz: b6c7d2b7e1c4cc9c9aca050b9e97af993ec6201b7caf95f33bc2c6e0cdb8dc6a698c3eb3998f1805051e7f2e73907ea76a7f94a458a02b2f9185a347b4dc3f8f
6
+ metadata.gz: 7b9c0cbfce3e7451f4218dc5a0274a28181d770a5689427d5fba20899702c5a580413b6adcb99482c717708cc2b60cccd5e9ee6dfb423351ff82024befae0477
7
+ data.tar.gz: d4c066e87770edfa6881cf000d36cc8e7f9f476cc3afebc8b44e5842f2c5946805af79a47dbceb575bd4e50496e56d024b3485fa083d414fb52bf8809b25eff2
@@ -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
@@ -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,
@@ -367,17 +387,27 @@ module Featureflip
367
387
  @event_processor.start
368
388
  end
369
389
 
370
- def evaluate_flag(key, context, default_value)
390
+ def evaluate_flag(key, context, default_value, expected: nil)
371
391
  if @test_mode
372
392
  return @test_values.fetch(key, default_value)
373
393
  end
374
394
 
375
- detail = variation_detail(key, context, default_value)
395
+ detail = variation_detail(key, context, default_value, expected: expected)
376
396
  detail.value
377
397
  rescue StandardError
378
398
  default_value
379
399
  end
380
400
 
401
+ # True when +value+ is of the JSON type the caller's typed accessor requires.
402
+ def value_matches_type?(value, expected)
403
+ case expected
404
+ when :bool then value == true || value == false
405
+ when :string then value.is_a?(String)
406
+ when :number then value.is_a?(Numeric)
407
+ else true
408
+ end
409
+ end
410
+
381
411
  def get_segment(key)
382
412
  @store.get_segment(key)
383
413
  end
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.4.2"
2
+ VERSION = "2.5.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.4.2
4
+ version: 2.5.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-05 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger