flipper 1.3.5 → 1.4.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/.github/workflows/ci.yml +9 -6
- data/.github/workflows/examples.yml +5 -4
- data/.github/workflows/release.yml +54 -0
- data/.superset/config.json +4 -0
- data/CLAUDE.md +93 -0
- data/README.md +4 -3
- data/examples/cloud/poll_interval/README.md +111 -0
- data/examples/cloud/poll_interval/client.rb +108 -0
- data/examples/cloud/poll_interval/server.rb +98 -0
- data/examples/expressions.rb +35 -11
- data/lib/flipper/adapter.rb +17 -1
- data/lib/flipper/adapters/actor_limit.rb +27 -1
- data/lib/flipper/adapters/cache_base.rb +21 -3
- data/lib/flipper/adapters/dual_write.rb +6 -2
- data/lib/flipper/adapters/failover.rb +9 -3
- data/lib/flipper/adapters/failsafe.rb +2 -2
- data/lib/flipper/adapters/http/client.rb +15 -4
- data/lib/flipper/adapters/http.rb +37 -2
- data/lib/flipper/adapters/instrumented.rb +2 -2
- data/lib/flipper/adapters/memoizable.rb +3 -3
- data/lib/flipper/adapters/memory.rb +1 -1
- data/lib/flipper/adapters/pstore.rb +1 -1
- data/lib/flipper/adapters/strict.rb +30 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
- data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
- data/lib/flipper/adapters/sync.rb +7 -3
- data/lib/flipper/cli.rb +51 -0
- data/lib/flipper/cloud/configuration.rb +9 -4
- data/lib/flipper/cloud/dsl.rb +2 -2
- data/lib/flipper/cloud/middleware.rb +1 -1
- data/lib/flipper/cloud/migrate.rb +71 -0
- data/lib/flipper/cloud/telemetry.rb +1 -1
- data/lib/flipper/cloud.rb +1 -0
- data/lib/flipper/dsl.rb +1 -1
- data/lib/flipper/expressions/feature_enabled.rb +34 -0
- data/lib/flipper/expressions/time.rb +8 -1
- data/lib/flipper/gates/expression.rb +2 -2
- data/lib/flipper/poller.rb +52 -9
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +17 -1
- data/spec/flipper/adapter_spec.rb +20 -0
- data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
- data/spec/flipper/adapters/dual_write_spec.rb +13 -0
- data/spec/flipper/adapters/failover_spec.rb +12 -0
- data/spec/flipper/adapters/http_spec.rb +240 -0
- data/spec/flipper/adapters/strict_spec.rb +62 -4
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
- data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
- data/spec/flipper/adapters/sync_spec.rb +13 -0
- data/spec/flipper/cli_spec.rb +51 -0
- data/spec/flipper/cloud/configuration_spec.rb +6 -0
- data/spec/flipper/cloud/dsl_spec.rb +10 -2
- data/spec/flipper/cloud/middleware_spec.rb +34 -16
- data/spec/flipper/cloud/migrate_spec.rb +160 -0
- data/spec/flipper/cloud/telemetry_spec.rb +1 -1
- data/spec/flipper/engine_spec.rb +2 -2
- data/spec/flipper/expressions/time_spec.rb +16 -0
- data/spec/flipper/gates/expression_spec.rb +82 -0
- data/spec/flipper/middleware/memoizer_spec.rb +37 -6
- data/spec/flipper/poller_spec.rb +347 -4
- data/spec/flipper_integration_spec.rb +133 -0
- data/spec/flipper_spec.rb +6 -1
- data/spec/spec_helper.rb +7 -0
- metadata +18 -112
- data/lib/flipper/expressions/duration.rb +0 -28
- data/spec/flipper/expressions/duration_spec.rb +0 -43
data/lib/flipper/cloud.rb
CHANGED
data/lib/flipper/dsl.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Flipper
|
|
|
10
10
|
# Private: What is being used to instrument all the things.
|
|
11
11
|
attr_reader :instrumenter
|
|
12
12
|
|
|
13
|
-
def_delegators :@adapter, :memoize=, :memoizing?, :import, :export
|
|
13
|
+
def_delegators :@adapter, :memoize=, :memoizing?, :import, :export, :adapter_stack
|
|
14
14
|
|
|
15
15
|
# Public: Returns a new instance of the DSL.
|
|
16
16
|
#
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Flipper
|
|
4
|
+
module Expressions
|
|
5
|
+
class FeatureEnabled
|
|
6
|
+
EVALUATING_KEY = :flipper_evaluating_features
|
|
7
|
+
|
|
8
|
+
def self.call(feature_name, context:)
|
|
9
|
+
evaluating = Thread.current[EVALUATING_KEY] ||= Set.new
|
|
10
|
+
feature_name = feature_name.to_s
|
|
11
|
+
current_feature = context[:feature_name].to_s
|
|
12
|
+
|
|
13
|
+
# Track the current feature so A -> B -> A is caught
|
|
14
|
+
added_current = evaluating.add?(current_feature)
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
# Circular dependency: return false to break the cycle
|
|
18
|
+
return false if evaluating.include?(feature_name)
|
|
19
|
+
|
|
20
|
+
evaluating.add(feature_name)
|
|
21
|
+
actor = context[:actor]
|
|
22
|
+
if actor
|
|
23
|
+
Flipper.enabled?(feature_name, actor)
|
|
24
|
+
else
|
|
25
|
+
Flipper.enabled?(feature_name)
|
|
26
|
+
end
|
|
27
|
+
ensure
|
|
28
|
+
evaluating.delete(feature_name)
|
|
29
|
+
evaluating.delete(current_feature) if added_current
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -30,10 +30,10 @@ module Flipper
|
|
|
30
30
|
expression = Flipper::Expression.build(data)
|
|
31
31
|
|
|
32
32
|
if context.actors.nil? || context.actors.empty?
|
|
33
|
-
!!expression.evaluate(feature_name: context.feature_name, properties: DEFAULT_PROPERTIES)
|
|
33
|
+
!!expression.evaluate(feature_name: context.feature_name, properties: DEFAULT_PROPERTIES, actor: nil)
|
|
34
34
|
else
|
|
35
35
|
context.actors.any? do |actor|
|
|
36
|
-
!!expression.evaluate(feature_name: context.feature_name, properties: properties(actor))
|
|
36
|
+
!!expression.evaluate(feature_name: context.feature_name, properties: properties(actor), actor: actor)
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
end
|
data/lib/flipper/poller.rb
CHANGED
|
@@ -2,6 +2,7 @@ require 'logger'
|
|
|
2
2
|
require 'concurrent/utility/monotonic_time'
|
|
3
3
|
require 'concurrent/map'
|
|
4
4
|
require 'concurrent/atomic/atomic_fixnum'
|
|
5
|
+
require 'concurrent/atomic/atomic_boolean'
|
|
5
6
|
|
|
6
7
|
module Flipper
|
|
7
8
|
class Poller
|
|
@@ -17,7 +18,10 @@ module Flipper
|
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def self.reset
|
|
20
|
-
instances.each
|
|
21
|
+
instances.each do |_, instance|
|
|
22
|
+
instance.stop
|
|
23
|
+
instance.thread&.join(1)
|
|
24
|
+
end.clear
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
MINIMUM_POLL_INTERVAL = 10
|
|
@@ -28,14 +32,12 @@ module Flipper
|
|
|
28
32
|
@mutex = Mutex.new
|
|
29
33
|
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
|
|
30
34
|
@remote_adapter = options.fetch(:remote_adapter)
|
|
31
|
-
@interval = options.fetch(:interval, 10).to_f
|
|
32
35
|
@last_synced_at = Concurrent::AtomicFixnum.new(0)
|
|
33
36
|
@adapter = Adapters::Memory.new(nil, threadsafe: true)
|
|
37
|
+
@shutdown_requested = Concurrent::AtomicBoolean.new(false)
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@interval = MINIMUM_POLL_INTERVAL
|
|
38
|
-
end
|
|
39
|
+
self.interval = options.fetch(:interval, 10)
|
|
40
|
+
@initial_interval = @interval
|
|
39
41
|
|
|
40
42
|
@start_automatically = options.fetch(:start_automatically, true)
|
|
41
43
|
|
|
@@ -46,6 +48,7 @@ module Flipper
|
|
|
46
48
|
|
|
47
49
|
def start
|
|
48
50
|
reset if forked?
|
|
51
|
+
return if @shutdown_requested.true?
|
|
49
52
|
ensure_worker_running
|
|
50
53
|
end
|
|
51
54
|
|
|
@@ -72,15 +75,33 @@ module Flipper
|
|
|
72
75
|
|
|
73
76
|
def sync
|
|
74
77
|
@instrumenter.instrument("poller.#{InstrumentationNamespace}", operation: :poll) do
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
begin
|
|
79
|
+
@adapter.import @remote_adapter
|
|
80
|
+
@last_synced_at.update { |time| Concurrent.monotonic_time }
|
|
81
|
+
ensure
|
|
82
|
+
apply_response_headers
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Internal: Sets the interval in seconds for how often to poll.
|
|
88
|
+
def interval=(value)
|
|
89
|
+
requested_interval = Flipper::Typecast.to_float(value)
|
|
90
|
+
new_interval = [requested_interval, MINIMUM_POLL_INTERVAL].max
|
|
91
|
+
|
|
92
|
+
if requested_interval < MINIMUM_POLL_INTERVAL
|
|
93
|
+
warn "Flipper::Cloud poll interval must be greater than or equal to #{MINIMUM_POLL_INTERVAL} but was #{requested_interval}. Setting interval to #{MINIMUM_POLL_INTERVAL}."
|
|
77
94
|
end
|
|
95
|
+
|
|
96
|
+
@interval = new_interval
|
|
78
97
|
end
|
|
79
98
|
|
|
80
99
|
private
|
|
81
100
|
|
|
82
101
|
def jitter
|
|
83
|
-
|
|
102
|
+
# Cap jitter at 30 seconds to prevent excessive delays for large intervals
|
|
103
|
+
max_jitter = [interval * 0.1, 30].min
|
|
104
|
+
rand * max_jitter
|
|
84
105
|
end
|
|
85
106
|
|
|
86
107
|
def forked?
|
|
@@ -98,6 +119,7 @@ module Flipper
|
|
|
98
119
|
begin
|
|
99
120
|
return if thread_alive?
|
|
100
121
|
@thread = Thread.new { run }
|
|
122
|
+
@thread&.report_on_exception = false
|
|
101
123
|
@instrumenter.instrument("poller.#{InstrumentationNamespace}", {
|
|
102
124
|
operation: :thread_start,
|
|
103
125
|
})
|
|
@@ -112,7 +134,28 @@ module Flipper
|
|
|
112
134
|
|
|
113
135
|
def reset
|
|
114
136
|
@pid = Process.pid
|
|
137
|
+
@shutdown_requested.make_false
|
|
115
138
|
mutex.unlock if mutex.locked?
|
|
116
139
|
end
|
|
140
|
+
|
|
141
|
+
def apply_response_headers
|
|
142
|
+
return unless @remote_adapter.respond_to?(:last_get_all_response)
|
|
143
|
+
|
|
144
|
+
if response = @remote_adapter.last_get_all_response
|
|
145
|
+
# shutdown based on response header
|
|
146
|
+
if Flipper::Typecast.to_boolean(response["poll-shutdown"])
|
|
147
|
+
@shutdown_requested.make_true
|
|
148
|
+
@instrumenter.instrument("poller.#{InstrumentationNamespace}", {
|
|
149
|
+
operation: :shutdown_requested,
|
|
150
|
+
})
|
|
151
|
+
stop
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# update interval based on response header
|
|
155
|
+
if interval = response["poll-interval"]
|
|
156
|
+
self.interval = [Flipper::Typecast.to_float(interval), @initial_interval].max
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
117
160
|
end
|
|
118
161
|
end
|
data/lib/flipper/version.rb
CHANGED
data/lib/flipper.rb
CHANGED
|
@@ -64,7 +64,7 @@ module Flipper
|
|
|
64
64
|
:enable_percentage_of_actors, :disable_percentage_of_actors,
|
|
65
65
|
:enable_percentage_of_time, :disable_percentage_of_time,
|
|
66
66
|
:features, :feature, :[], :preload, :preload_all,
|
|
67
|
-
:adapter, :add, :exist?, :remove, :import, :export,
|
|
67
|
+
:adapter, :adapter_stack, :add, :exist?, :remove, :import, :export,
|
|
68
68
|
:memoize=, :memoizing?, :read_only?,
|
|
69
69
|
:sync, :sync_secret # For Flipper::Cloud. Will error for OSS Flipper.
|
|
70
70
|
|
|
@@ -100,6 +100,22 @@ module Flipper
|
|
|
100
100
|
Expression.build({ Random: max })
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
def now
|
|
104
|
+
Expression.build({ Now: [] })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def time(value)
|
|
108
|
+
Expression.build({ Time: value })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def feature_enabled(name)
|
|
112
|
+
Expression.build({ FeatureEnabled: name })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def feature_disabled(name)
|
|
116
|
+
feature_enabled(name).eq(false)
|
|
117
|
+
end
|
|
118
|
+
|
|
103
119
|
# Public: Use this to register a group by name.
|
|
104
120
|
#
|
|
105
121
|
# name - The Symbol name of the group.
|
|
@@ -143,4 +143,24 @@ RSpec.describe Flipper::Adapter do
|
|
|
143
143
|
expect(export.features.dig("search", :boolean)).to eq("true")
|
|
144
144
|
end
|
|
145
145
|
end
|
|
146
|
+
|
|
147
|
+
describe "#adapter_stack" do
|
|
148
|
+
it "returns the adapter name for a simple adapter" do
|
|
149
|
+
adapter = Flipper::Adapters::Memory.new
|
|
150
|
+
expect(adapter.adapter_stack).to eq("memory")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "returns the chain for wrapped adapters" do
|
|
154
|
+
memory = Flipper::Adapters::Memory.new
|
|
155
|
+
memoizable = Flipper::Adapters::Memoizable.new(memory)
|
|
156
|
+
expect(memoizable.adapter_stack).to eq("memoizable -> memory")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "returns the chain for deeply nested adapters" do
|
|
160
|
+
memory = Flipper::Adapters::Memory.new
|
|
161
|
+
strict = Flipper::Adapters::Strict.new(memory)
|
|
162
|
+
memoizable = Flipper::Adapters::Memoizable.new(strict)
|
|
163
|
+
expect(memoizable.adapter_stack).to eq("memoizable -> strict -> memory")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
146
166
|
end
|
|
@@ -15,6 +15,61 @@ RSpec.describe Flipper::Adapters::ActorLimit do
|
|
|
15
15
|
feature.enable Flipper::Actor.new("User;6")
|
|
16
16
|
}.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
it "allows exceeding limit when in sync mode" do
|
|
20
|
+
5.times { |i| feature.enable Flipper::Actor.new("User;#{i}") }
|
|
21
|
+
|
|
22
|
+
described_class.with_sync_mode do
|
|
23
|
+
expect {
|
|
24
|
+
feature.enable Flipper::Actor.new("User;6")
|
|
25
|
+
}.not_to raise_error
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '.sync_mode' do
|
|
32
|
+
after do
|
|
33
|
+
described_class.sync_mode = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'defaults to nil/falsy' do
|
|
37
|
+
expect(described_class.sync_mode).to be_falsy
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'can be set and read' do
|
|
41
|
+
described_class.sync_mode = true
|
|
42
|
+
expect(described_class.sync_mode).to be true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe '.with_sync_mode' do
|
|
47
|
+
after do
|
|
48
|
+
described_class.sync_mode = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'sets sync_mode to true within block' do
|
|
52
|
+
described_class.with_sync_mode do
|
|
53
|
+
expect(described_class.sync_mode).to be true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'restores previous value after block' do
|
|
58
|
+
expect(described_class.sync_mode).to be_falsy
|
|
59
|
+
described_class.with_sync_mode { }
|
|
60
|
+
expect(described_class.sync_mode).to be_falsy
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'restores previous value even on exception' do
|
|
64
|
+
expect {
|
|
65
|
+
described_class.with_sync_mode { raise "boom" }
|
|
66
|
+
}.to raise_error("boom")
|
|
67
|
+
expect(described_class.sync_mode).to be_falsy
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'returns the block result' do
|
|
71
|
+
result = described_class.with_sync_mode { 42 }
|
|
72
|
+
expect(result).to eq(42)
|
|
18
73
|
end
|
|
19
74
|
end
|
|
20
75
|
end
|
|
@@ -66,4 +66,17 @@ RSpec.describe Flipper::Adapters::DualWrite do
|
|
|
66
66
|
expect(remote_adapter.count(:disable)).to be(1)
|
|
67
67
|
expect(local_adapter.count(:disable)).to be(1)
|
|
68
68
|
end
|
|
69
|
+
|
|
70
|
+
describe '#adapter_stack' do
|
|
71
|
+
it 'returns the tree representation' do
|
|
72
|
+
expect(subject.adapter_stack).to eq("dual_write(local: operation_logger -> memory, remote: operation_logger -> memory)")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'shows nested adapters in the tree' do
|
|
76
|
+
memory = Flipper::Adapters::Memory.new
|
|
77
|
+
strict = Flipper::Adapters::Strict.new(Flipper::Adapters::Memory.new)
|
|
78
|
+
adapter = described_class.new(memory, strict)
|
|
79
|
+
expect(adapter.adapter_stack).to eq("dual_write(local: memory, remote: strict -> memory)")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
69
82
|
end
|
|
@@ -126,4 +126,16 @@ RSpec.describe Flipper::Adapters::Failover do
|
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
128
|
end
|
|
129
|
+
|
|
130
|
+
describe '#adapter_stack' do
|
|
131
|
+
it 'returns the tree representation' do
|
|
132
|
+
expect(subject.adapter_stack).to eq("failover(primary: memory, secondary: memory)")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'shows nested adapters in the tree' do
|
|
136
|
+
strict_primary = Flipper::Adapters::Strict.new(primary)
|
|
137
|
+
adapter = described_class.new(strict_primary, secondary)
|
|
138
|
+
expect(adapter.adapter_stack).to eq("failover(primary: strict -> memory, secondary: memory)")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
129
141
|
end
|
|
@@ -185,6 +185,246 @@ RSpec.describe Flipper::Adapters::Http do
|
|
|
185
185
|
adapter.get_all
|
|
186
186
|
}.to raise_error(Flipper::Adapters::Http::Error)
|
|
187
187
|
end
|
|
188
|
+
|
|
189
|
+
it "stores ETag and sends If-None-Match on subsequent requests" do
|
|
190
|
+
features_response = {
|
|
191
|
+
"features" => [
|
|
192
|
+
{
|
|
193
|
+
"key" => "search",
|
|
194
|
+
"gates" => [
|
|
195
|
+
{"key" => "boolean", "value" => true}
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# First request - server returns ETag
|
|
202
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
203
|
+
.to_return(
|
|
204
|
+
status: 200,
|
|
205
|
+
body: JSON.generate(features_response),
|
|
206
|
+
headers: { 'ETag' => '"abc123"' }
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
adapter = described_class.new(url: 'http://app.com/flipper')
|
|
210
|
+
result = adapter.get_all
|
|
211
|
+
|
|
212
|
+
expect(result).to have_key("search")
|
|
213
|
+
|
|
214
|
+
# Second request - should send If-None-Match header
|
|
215
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
216
|
+
.with(headers: { 'If-None-Match' => '"abc123"' })
|
|
217
|
+
.to_return(
|
|
218
|
+
status: 200,
|
|
219
|
+
body: JSON.generate(features_response),
|
|
220
|
+
headers: { 'ETag' => '"abc123"' }
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
etag_result = adapter.get_all
|
|
224
|
+
|
|
225
|
+
expect(etag_result).to eq(result)
|
|
226
|
+
expect(etag_result).to have_key("search")
|
|
227
|
+
expect(
|
|
228
|
+
a_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
229
|
+
.with(headers: { 'If-None-Match' => '"abc123"' })
|
|
230
|
+
).to have_been_made.once
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "returns cached result when server returns 304 Not Modified" do
|
|
234
|
+
features_response = {
|
|
235
|
+
"features" => [
|
|
236
|
+
{
|
|
237
|
+
"key" => "search",
|
|
238
|
+
"gates" => [
|
|
239
|
+
{"key" => "boolean", "value" => true}
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# First request - server returns ETag
|
|
246
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
247
|
+
.to_return(
|
|
248
|
+
status: 200,
|
|
249
|
+
body: JSON.generate(features_response),
|
|
250
|
+
headers: { 'ETag' => '"abc123"' }
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
adapter = described_class.new(url: 'http://app.com/flipper')
|
|
254
|
+
first_result = adapter.get_all
|
|
255
|
+
|
|
256
|
+
expect(first_result).to have_key("search")
|
|
257
|
+
|
|
258
|
+
# Second request - server returns 304
|
|
259
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
260
|
+
.with(headers: { 'If-None-Match' => '"abc123"' })
|
|
261
|
+
.to_return(status: 304, headers: { 'ETag' => '"abc123"' })
|
|
262
|
+
|
|
263
|
+
second_result = adapter.get_all
|
|
264
|
+
|
|
265
|
+
# Should return the cached result
|
|
266
|
+
expect(second_result).to eq(first_result)
|
|
267
|
+
expect(second_result).to have_key("search")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "raises error when 304 received without cached result" do
|
|
271
|
+
# Server returns 304 without any prior request
|
|
272
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
273
|
+
.to_return(status: 304)
|
|
274
|
+
|
|
275
|
+
adapter = described_class.new(url: 'http://app.com/flipper')
|
|
276
|
+
expect {
|
|
277
|
+
adapter.get_all
|
|
278
|
+
}.to raise_error(Flipper::Adapters::Http::Error)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "skips If-None-Match header when cache_bust is true" do
|
|
282
|
+
features_response = {
|
|
283
|
+
"features" => [
|
|
284
|
+
{
|
|
285
|
+
"key" => "search",
|
|
286
|
+
"gates" => [
|
|
287
|
+
{"key" => "boolean", "value" => true}
|
|
288
|
+
]
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
# First request - populate the ETag cache
|
|
294
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
295
|
+
.to_return(
|
|
296
|
+
status: 200,
|
|
297
|
+
body: JSON.generate(features_response),
|
|
298
|
+
headers: { 'ETag' => '"abc123"' }
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
adapter = described_class.new(url: 'http://app.com/flipper')
|
|
302
|
+
adapter.get_all
|
|
303
|
+
|
|
304
|
+
# Second request with cache_bust - should NOT send If-None-Match
|
|
305
|
+
cache_bust_stub = stub_request(:get, %r{/flipper/features\?_cb=\d+&exclude_gate_names=true})
|
|
306
|
+
.to_return(
|
|
307
|
+
status: 200,
|
|
308
|
+
body: JSON.generate(features_response),
|
|
309
|
+
headers: { 'ETag' => '"def456"' }
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
adapter.get_all(cache_bust: true)
|
|
313
|
+
|
|
314
|
+
expect(cache_bust_stub).to have_been_requested.once
|
|
315
|
+
expect(
|
|
316
|
+
a_request(:get, %r{/flipper/features\?_cb=\d+&exclude_gate_names=true})
|
|
317
|
+
.with { |req| req.headers['If-None-Match'].nil? }
|
|
318
|
+
).to have_been_made.once
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
it "returns fresh data on cache_bust even when ETag is cached" do
|
|
322
|
+
stale_response = {
|
|
323
|
+
"features" => [
|
|
324
|
+
{
|
|
325
|
+
"key" => "search",
|
|
326
|
+
"gates" => [
|
|
327
|
+
{"key" => "boolean", "value" => nil}
|
|
328
|
+
]
|
|
329
|
+
}
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
fresh_response = {
|
|
334
|
+
"features" => [
|
|
335
|
+
{
|
|
336
|
+
"key" => "search",
|
|
337
|
+
"gates" => [
|
|
338
|
+
{"key" => "boolean", "value" => true}
|
|
339
|
+
]
|
|
340
|
+
}
|
|
341
|
+
]
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# First request - populate ETag cache with feature disabled
|
|
345
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
346
|
+
.to_return(
|
|
347
|
+
status: 200,
|
|
348
|
+
body: JSON.generate(stale_response),
|
|
349
|
+
headers: { 'ETag' => '"abc123"' }
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
adapter = described_class.new(url: 'http://app.com/flipper')
|
|
353
|
+
stale_result = adapter.get_all
|
|
354
|
+
|
|
355
|
+
expect(stale_result["search"][:boolean]).to be_nil
|
|
356
|
+
|
|
357
|
+
# Cache bust request returns fresh data (feature now enabled)
|
|
358
|
+
stub_request(:get, %r{/flipper/features\?_cb=\d+&exclude_gate_names=true})
|
|
359
|
+
.to_return(
|
|
360
|
+
status: 200,
|
|
361
|
+
body: JSON.generate(fresh_response),
|
|
362
|
+
headers: { 'ETag' => '"def456"' }
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
fresh_result = adapter.get_all(cache_bust: true)
|
|
366
|
+
|
|
367
|
+
expect(fresh_result["search"][:boolean]).to eq("true")
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it "does not send If-None-Match for other endpoints" do
|
|
371
|
+
stub_request(:get, "http://app.com/flipper/features/search")
|
|
372
|
+
.to_return(status: 404)
|
|
373
|
+
|
|
374
|
+
adapter = described_class.new(url: 'http://app.com/flipper')
|
|
375
|
+
adapter.get(flipper[:search])
|
|
376
|
+
|
|
377
|
+
# Verify no If-None-Match header was sent
|
|
378
|
+
expect(
|
|
379
|
+
a_request(:get, "http://app.com/flipper/features/search")
|
|
380
|
+
.with { |req| req.headers['If-None-Match'].nil? }
|
|
381
|
+
).to have_been_made.once
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it "stores last get_all response for poll-shutdown header checking" do
|
|
385
|
+
features_response = {
|
|
386
|
+
"features" => [
|
|
387
|
+
{
|
|
388
|
+
"key" => "search",
|
|
389
|
+
"gates" => [
|
|
390
|
+
{"key" => "boolean", "value" => true}
|
|
391
|
+
]
|
|
392
|
+
}
|
|
393
|
+
]
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
397
|
+
.to_return(
|
|
398
|
+
status: 200,
|
|
399
|
+
body: JSON.generate(features_response),
|
|
400
|
+
headers: { 'poll-shutdown' => 'true' }
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
adapter = described_class.new(url: 'http://app.com/flipper')
|
|
404
|
+
adapter.get_all
|
|
405
|
+
|
|
406
|
+
expect(adapter.last_get_all_response).not_to be_nil
|
|
407
|
+
expect(adapter.last_get_all_response['poll-shutdown']).to eq('true')
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
it "stores last get_all response even for error responses" do
|
|
411
|
+
stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
|
|
412
|
+
.to_return(
|
|
413
|
+
status: 404,
|
|
414
|
+
body: JSON.generate({ error: "Not found" }),
|
|
415
|
+
headers: { 'poll-shutdown' => 'true' }
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
adapter = described_class.new(url: 'http://app.com/flipper')
|
|
419
|
+
|
|
420
|
+
expect {
|
|
421
|
+
adapter.get_all
|
|
422
|
+
}.to raise_error(Flipper::Adapters::Http::Error)
|
|
423
|
+
|
|
424
|
+
# Even though it raised an error, response should be stored
|
|
425
|
+
expect(adapter.last_get_all_response).not_to be_nil
|
|
426
|
+
expect(adapter.last_get_all_response['poll-shutdown']).to eq('true')
|
|
427
|
+
end
|
|
188
428
|
end
|
|
189
429
|
|
|
190
430
|
describe "#features" do
|