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 +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/http/client.rb +20 -5
- data/lib/featureflip/shared_core.rb +37 -7
- 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: f3e88364434f9dced8db82624524bf1ffe905b35b5ae8a062896bfb6c02dc5e6
|
|
4
|
+
data.tar.gz: 87d5bdaa62bb9f6e8eb0685f235315b31adfb5cd0dbdaac478965144a09709ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b9c0cbfce3e7451f4218dc5a0274a28181d770a5689427d5fba20899702c5a580413b6adcb99482c717708cc2b60cccd5e9ee6dfb423351ff82024befae0477
|
|
7
|
+
data.tar.gz: d4c066e87770edfa6881cf000d36cc8e7f9f476cc3afebc8b44e5842f2c5946805af79a47dbceb575bd4e50496e56d024b3485fa083d414fb52bf8809b25eff2
|
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
|
|
@@ -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,
|
|
@@ -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
|
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.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-
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|