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 +4 -4
- data/lib/featureflip/client.rb +5 -0
- data/lib/featureflip/data_source/streaming.rb +17 -2
- data/lib/featureflip/errors.rb +9 -0
- data/lib/featureflip/evaluation/condition_evaluator.rb +12 -1
- data/lib/featureflip/http/client.rb +20 -5
- data/lib/featureflip/shared_core.rb +78 -17
- data/lib/featureflip/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a41a0e3edb8d4325e4a0784930973f07ad8339e7b25b127f076bc7cfade0d948
|
|
4
|
+
data.tar.gz: 6ceb86141220f142710e6a0937af742270c61961ff87f583a2d727bab1c6ee65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 013af1bacae587f2dd832bd7004ac4a94a45e8d635c64204d8ea892d4e62c80112d07856856d097f03393f4bbf7c3aef7d54c378672db9c61623d9053b8e3e3b
|
|
7
|
+
data.tar.gz: 822537c99bfdd30d8f2f4b16b9b8592d2cd23fdaccaf2cea0333fccdf68f0998877bf124da0060ec63abc0fb347f6506ad10f0fc235d8f22291de50a68f20a33
|
data/lib/featureflip/client.rb
CHANGED
|
@@ -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
|
|
238
|
-
#
|
|
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
|
data/lib/featureflip/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/featureflip/version.rb
CHANGED
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
|
+
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-
|
|
11
|
+
date: 2026-08-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|