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/spec/flipper/engine_spec.rb
CHANGED
|
@@ -263,10 +263,10 @@ RSpec.describe Flipper::Engine do
|
|
|
263
263
|
Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
it "configures webhook app" do
|
|
266
|
+
it "configures webhook app and uses cache busting" do
|
|
267
267
|
silence { application.initialize! }
|
|
268
268
|
|
|
269
|
-
stub = stub_request(:get,
|
|
269
|
+
stub = stub_request(:get, /https:\/\/www\.flippercloud\.io\/adapter\/features\?_cb=\d+&exclude_gate_names=true/).with({
|
|
270
270
|
headers: { "flipper-cloud-token" => ENV["FLIPPER_CLOUD_TOKEN"] },
|
|
271
271
|
}).to_return(status: 200, body: JSON.generate({ features: {} }), headers: {})
|
|
272
272
|
|
|
@@ -9,5 +9,21 @@ RSpec.describe Flipper::Expressions::Time do
|
|
|
9
9
|
it "returns time for #iso8601 format" do
|
|
10
10
|
expect(described_class.call(time.iso8601)).to eq(time)
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
it "returns time for epoch integer" do
|
|
14
|
+
expect(described_class.call(time.to_i)).to eq(Time.at(time.to_i).utc)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "returns time for epoch float" do
|
|
18
|
+
expect(described_class.call(time.to_f)).to be_within(0.001).of(time)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "returns utc for string input" do
|
|
22
|
+
expect(described_class.call(time.to_s).utc?).to be(true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "returns utc for numeric input" do
|
|
26
|
+
expect(described_class.call(time.to_i).utc?).to be(true)
|
|
27
|
+
end
|
|
12
28
|
end
|
|
13
29
|
end
|
|
@@ -62,6 +62,30 @@ RSpec.describe Flipper::Gates::Expression do
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
context 'for actor in context' do
|
|
66
|
+
it 'passes actor to expression context' do
|
|
67
|
+
actor = Flipper::Actor.new("User;1", {type: "User"})
|
|
68
|
+
wrapped_actor = Flipper::Types::Actor.new(actor)
|
|
69
|
+
expression = Flipper.property(:flipper_id).eq("User;1")
|
|
70
|
+
ctx = Flipper::FeatureCheckContext.new(
|
|
71
|
+
feature_name: feature_name,
|
|
72
|
+
values: Flipper::GateValues.new(expression: expression.value),
|
|
73
|
+
actors: [wrapped_actor]
|
|
74
|
+
)
|
|
75
|
+
expect(subject.open?(ctx)).to be(true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'passes nil actor when no actors provided' do
|
|
79
|
+
expression = Flipper.boolean(true).eq(true)
|
|
80
|
+
ctx = Flipper::FeatureCheckContext.new(
|
|
81
|
+
feature_name: feature_name,
|
|
82
|
+
values: Flipper::GateValues.new(expression: expression.value),
|
|
83
|
+
actors: nil
|
|
84
|
+
)
|
|
85
|
+
expect(subject.open?(ctx)).to be(true)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
65
89
|
context 'for properties that have symbol keys' do
|
|
66
90
|
it 'returns true when expression evalutes to true' do
|
|
67
91
|
expression = Flipper.property(:type).eq("User")
|
|
@@ -75,6 +99,64 @@ RSpec.describe Flipper::Gates::Expression do
|
|
|
75
99
|
expect(subject.open?(context)).to be(false)
|
|
76
100
|
end
|
|
77
101
|
end
|
|
102
|
+
|
|
103
|
+
context 'for time-based expressions' do
|
|
104
|
+
it 'enables when now is past a scheduled epoch' do
|
|
105
|
+
past_epoch = Time.now.to_i - 86_400
|
|
106
|
+
expression = Flipper.now.gte(Flipper.time(past_epoch))
|
|
107
|
+
expect(subject.open?(context(expression.value))).to be(true)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'does not enable when now is before a future epoch' do
|
|
111
|
+
future_epoch = Time.now.to_i + 86_400
|
|
112
|
+
expression = Flipper.now.gte(Flipper.time(future_epoch))
|
|
113
|
+
expect(subject.open?(context(expression.value))).to be(false)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'enables when now is past a scheduled datetime' do
|
|
117
|
+
past_time = (Time.now.utc - 86_400).iso8601
|
|
118
|
+
expression = Flipper.now.gte(Flipper.time(past_time))
|
|
119
|
+
expect(subject.open?(context(expression.value))).to be(true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'does not enable when now is before a future datetime' do
|
|
123
|
+
future_time = (Time.now.utc + 86_400).iso8601
|
|
124
|
+
expression = Flipper.now.gte(Flipper.time(future_time))
|
|
125
|
+
expect(subject.open?(context(expression.value))).to be(false)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'enables expiring features with lt' do
|
|
129
|
+
future_time = (Time.now.utc + 86_400).iso8601
|
|
130
|
+
expression = Flipper.now.lt(Flipper.time(future_time))
|
|
131
|
+
expect(subject.open?(context(expression.value))).to be(true)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'disables expired features with lt' do
|
|
135
|
+
past_time = (Time.now.utc - 86_400).iso8601
|
|
136
|
+
expression = Flipper.now.lt(Flipper.time(past_time))
|
|
137
|
+
expect(subject.open?(context(expression.value))).to be(false)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'enables within a time window using all' do
|
|
141
|
+
start_time = (Time.now.utc - 86_400).iso8601
|
|
142
|
+
end_time = (Time.now.utc + 86_400).iso8601
|
|
143
|
+
expression = Flipper.all(
|
|
144
|
+
Flipper.now.gte(Flipper.time(start_time)),
|
|
145
|
+
Flipper.now.lt(Flipper.time(end_time))
|
|
146
|
+
)
|
|
147
|
+
expect(subject.open?(context(expression.value))).to be(true)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'does not enable outside a time window' do
|
|
151
|
+
start_time = (Time.now.utc + 86_400).iso8601
|
|
152
|
+
end_time = (Time.now.utc + 172_800).iso8601
|
|
153
|
+
expression = Flipper.all(
|
|
154
|
+
Flipper.now.gte(Flipper.time(start_time)),
|
|
155
|
+
Flipper.now.lt(Flipper.time(end_time))
|
|
156
|
+
)
|
|
157
|
+
expect(subject.open?(context(expression.value))).to be(false)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
78
160
|
end
|
|
79
161
|
|
|
80
162
|
describe '#protects?' do
|
|
@@ -2,6 +2,8 @@ require 'rack/test'
|
|
|
2
2
|
require 'active_support/cache'
|
|
3
3
|
require 'flipper/adapters/active_support_cache_store'
|
|
4
4
|
require 'flipper/adapters/operation_logger'
|
|
5
|
+
require 'flipper/adapters/actor_limit'
|
|
6
|
+
require 'flipper/adapters/sync'
|
|
5
7
|
|
|
6
8
|
RSpec.describe Flipper::Middleware::Memoizer do
|
|
7
9
|
include Rack::Test::Methods
|
|
@@ -470,18 +472,47 @@ RSpec.describe Flipper::Middleware::Memoizer do
|
|
|
470
472
|
|
|
471
473
|
get '/', {}, 'flipper' => flipper
|
|
472
474
|
expect(logged_cached.count(:get_all)).to be(1)
|
|
473
|
-
expect(logged_memory.count(:
|
|
474
|
-
expect(logged_memory.count(:get_multi)).to be(1)
|
|
475
|
+
expect(logged_memory.count(:get_all)).to be(1)
|
|
475
476
|
|
|
476
477
|
get '/', {}, 'flipper' => flipper
|
|
477
478
|
expect(logged_cached.count(:get_all)).to be(2)
|
|
478
|
-
expect(logged_memory.count(:
|
|
479
|
-
expect(logged_memory.count(:get_multi)).to be(1)
|
|
479
|
+
expect(logged_memory.count(:get_all)).to be(1)
|
|
480
480
|
|
|
481
481
|
get '/', {}, 'flipper' => flipper
|
|
482
482
|
expect(logged_cached.count(:get_all)).to be(3)
|
|
483
|
-
expect(logged_memory.count(:
|
|
484
|
-
|
|
483
|
+
expect(logged_memory.count(:get_all)).to be(1)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
context 'with preload:true and Sync adapter wrapped with ActorLimit' do
|
|
488
|
+
it 'preloads even when remote has more actors than local limit' do
|
|
489
|
+
local = Flipper::Adapters::Memory.new
|
|
490
|
+
remote = Flipper::Adapters::Memory.new
|
|
491
|
+
remote_flipper = Flipper.new(remote)
|
|
492
|
+
|
|
493
|
+
# Remote has more actors than limit allows (actor-only enables, not boolean)
|
|
494
|
+
10.times { |i| remote_flipper[:stats].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
495
|
+
|
|
496
|
+
# Sync adapter will sync from remote to local, then ActorLimit wraps it
|
|
497
|
+
# Use interval: 0 to force sync on every call
|
|
498
|
+
sync_adapter = Flipper::Adapters::Sync.new(local, remote, interval: 0)
|
|
499
|
+
limited_adapter = Flipper::Adapters::ActorLimit.new(sync_adapter, 5)
|
|
500
|
+
test_flipper = Flipper.new(limited_adapter)
|
|
501
|
+
|
|
502
|
+
app = lambda do |env|
|
|
503
|
+
f = env['flipper']
|
|
504
|
+
f[:stats].enabled?
|
|
505
|
+
[200, {}, []]
|
|
506
|
+
end
|
|
507
|
+
middleware = described_class.new(app, preload: true)
|
|
508
|
+
|
|
509
|
+
# Preload should work without raising ActorLimit::LimitExceeded
|
|
510
|
+
expect {
|
|
511
|
+
middleware.call('flipper' => test_flipper)
|
|
512
|
+
}.not_to raise_error
|
|
513
|
+
|
|
514
|
+
# Verify actors were synced (all 10, not just 5)
|
|
515
|
+
expect(test_flipper[:stats].actors_value.size).to eq(10)
|
|
485
516
|
end
|
|
486
517
|
end
|
|
487
518
|
end
|
data/spec/flipper/poller_spec.rb
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
require "flipper/poller"
|
|
2
|
+
require "flipper/adapters/http"
|
|
2
3
|
|
|
3
4
|
RSpec.describe Flipper::Poller do
|
|
4
|
-
let(:
|
|
5
|
-
let(:
|
|
5
|
+
let(:url) { "http://app.com/flipper" }
|
|
6
|
+
let(:remote_adapter) { Flipper::Adapters::Http.new(url: url) }
|
|
6
7
|
let(:local) { Flipper.new(subject.adapter) }
|
|
7
8
|
|
|
8
9
|
subject do
|
|
9
10
|
described_class.new(
|
|
10
11
|
remote_adapter: remote_adapter,
|
|
11
12
|
start_automatically: false,
|
|
12
|
-
interval:
|
|
13
|
+
interval: 3600 # 1 hour
|
|
13
14
|
)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
before do
|
|
18
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
19
|
+
.to_return(status: 200, body: JSON.generate(features: []))
|
|
20
|
+
|
|
17
21
|
allow(subject).to receive(:loop).and_yield # Make loop just call once
|
|
18
22
|
allow(subject).to receive(:sleep) # Disable sleep
|
|
19
23
|
allow(Thread).to receive(:new).and_yield # Disable separate thread
|
|
@@ -28,12 +32,320 @@ RSpec.describe Flipper::Poller do
|
|
|
28
32
|
|
|
29
33
|
describe "#sync" do
|
|
30
34
|
it "syncs remote adapter to local adapter" do
|
|
31
|
-
|
|
35
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
36
|
+
.to_return(status: 200, body: JSON.generate(
|
|
37
|
+
features: [
|
|
38
|
+
{
|
|
39
|
+
key: "polling",
|
|
40
|
+
gates: [
|
|
41
|
+
{ key: "boolean", value: true }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
))
|
|
32
46
|
|
|
33
47
|
expect(local.enabled?(:polling)).to be(false)
|
|
34
48
|
subject.sync
|
|
35
49
|
expect(local.enabled?(:polling)).to be(true)
|
|
36
50
|
end
|
|
51
|
+
|
|
52
|
+
context "when poll-shutdown header is present" do
|
|
53
|
+
before do
|
|
54
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
55
|
+
.to_return(
|
|
56
|
+
status: 200,
|
|
57
|
+
body: JSON.generate(
|
|
58
|
+
features: [
|
|
59
|
+
{
|
|
60
|
+
key: "polling",
|
|
61
|
+
gates: [
|
|
62
|
+
{ key: "boolean", value: true }
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
),
|
|
67
|
+
headers: { "poll-shutdown" => "true" }
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "stops the poller when poll-shutdown header is true" do
|
|
72
|
+
expect(subject).to receive(:stop).and_call_original
|
|
73
|
+
subject.sync
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "prevents poller from restarting after shutdown" do
|
|
77
|
+
subject.sync # This should trigger shutdown
|
|
78
|
+
|
|
79
|
+
# Try to start again - should be a no-op
|
|
80
|
+
expect(Thread).not_to receive(:new)
|
|
81
|
+
subject.start
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "instruments the shutdown_requested event" do
|
|
85
|
+
instrumenter = subject.instance_variable_get(:@instrumenter)
|
|
86
|
+
|
|
87
|
+
expect(instrumenter).to receive(:instrument).with(
|
|
88
|
+
"poller.#{Flipper::InstrumentationNamespace}",
|
|
89
|
+
{ operation: :poll }
|
|
90
|
+
).and_call_original
|
|
91
|
+
|
|
92
|
+
expect(instrumenter).to receive(:instrument).with(
|
|
93
|
+
"poller.#{Flipper::InstrumentationNamespace}",
|
|
94
|
+
{ operation: :shutdown_requested }
|
|
95
|
+
).and_call_original
|
|
96
|
+
|
|
97
|
+
expect(instrumenter).to receive(:instrument).with(
|
|
98
|
+
"poller.#{Flipper::InstrumentationNamespace}",
|
|
99
|
+
{ operation: :stop }
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
subject.sync
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
context "when poll-shutdown header is present on error response" do
|
|
107
|
+
before do
|
|
108
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
109
|
+
.to_return(
|
|
110
|
+
status: 404,
|
|
111
|
+
body: JSON.generate({ error: "Not found" }),
|
|
112
|
+
headers: { "poll-shutdown" => "true" }
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "stops polling even when sync fails with error response" do
|
|
117
|
+
# sync will raise an error, but should still check shutdown header
|
|
118
|
+
expect { subject.sync }.to raise_error(Flipper::Adapters::Http::Error)
|
|
119
|
+
|
|
120
|
+
# Verify shutdown was triggered
|
|
121
|
+
expect(Thread).not_to receive(:new)
|
|
122
|
+
subject.start
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
context "when poll-shutdown header is false" do
|
|
127
|
+
before do
|
|
128
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
129
|
+
.to_return(
|
|
130
|
+
status: 200,
|
|
131
|
+
body: JSON.generate(
|
|
132
|
+
features: [
|
|
133
|
+
{
|
|
134
|
+
key: "polling",
|
|
135
|
+
gates: [
|
|
136
|
+
{ key: "boolean", value: true }
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
),
|
|
141
|
+
headers: { "poll-shutdown" => "false" }
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "does not stop the poller" do
|
|
146
|
+
expect(subject).not_to receive(:stop)
|
|
147
|
+
subject.sync
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
context "when poll-shutdown header is missing" do
|
|
152
|
+
before do
|
|
153
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
154
|
+
.to_return(
|
|
155
|
+
status: 200,
|
|
156
|
+
body: JSON.generate(
|
|
157
|
+
features: [
|
|
158
|
+
{
|
|
159
|
+
key: "polling",
|
|
160
|
+
gates: [
|
|
161
|
+
{ key: "boolean", value: true }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "does not stop the poller" do
|
|
170
|
+
expect(subject).not_to receive(:stop)
|
|
171
|
+
subject.sync
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
context "when poll-interval header is lower than initial interval" do
|
|
176
|
+
before do
|
|
177
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
178
|
+
.to_return(
|
|
179
|
+
status: 200,
|
|
180
|
+
body: JSON.generate(
|
|
181
|
+
features: [
|
|
182
|
+
{
|
|
183
|
+
key: "polling",
|
|
184
|
+
gates: [
|
|
185
|
+
{ key: "boolean", value: true }
|
|
186
|
+
]
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
),
|
|
190
|
+
headers: { "poll-interval" => "30" }
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "uses the initial interval as minimum" do
|
|
195
|
+
expect(subject.interval).to eq(3600.0)
|
|
196
|
+
subject.sync
|
|
197
|
+
expect(subject.interval).to eq(3600.0) # Keeps 3600 because it's the initial interval
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
context "when poll-interval header is below minimum" do
|
|
202
|
+
subject do
|
|
203
|
+
described_class.new(
|
|
204
|
+
remote_adapter: remote_adapter,
|
|
205
|
+
start_automatically: false,
|
|
206
|
+
interval: 10 # Set initial to minimum
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
before do
|
|
211
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
212
|
+
.to_return(
|
|
213
|
+
status: 200,
|
|
214
|
+
body: JSON.generate(
|
|
215
|
+
features: [
|
|
216
|
+
{
|
|
217
|
+
key: "polling",
|
|
218
|
+
gates: [
|
|
219
|
+
{ key: "boolean", value: true }
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
]
|
|
223
|
+
),
|
|
224
|
+
headers: { "poll-interval" => "5" }
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it "enforces minimum poll interval" do
|
|
229
|
+
expect(subject.interval).to eq(10.0)
|
|
230
|
+
subject.sync
|
|
231
|
+
# Header says 5, minimum is 10, initial is 10, so max(5->10, 10) = 10
|
|
232
|
+
expect(subject.interval).to eq(Flipper::Poller::MINIMUM_POLL_INTERVAL)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
context "when poll-interval header is higher than initial interval" do
|
|
237
|
+
subject do
|
|
238
|
+
described_class.new(
|
|
239
|
+
remote_adapter: remote_adapter,
|
|
240
|
+
start_automatically: false,
|
|
241
|
+
interval: 20
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
before do
|
|
246
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
247
|
+
.to_return(
|
|
248
|
+
status: 200,
|
|
249
|
+
body: JSON.generate(
|
|
250
|
+
features: [
|
|
251
|
+
{
|
|
252
|
+
key: "polling",
|
|
253
|
+
gates: [
|
|
254
|
+
{ key: "boolean", value: true }
|
|
255
|
+
]
|
|
256
|
+
}
|
|
257
|
+
]
|
|
258
|
+
),
|
|
259
|
+
headers: { "poll-interval" => "60" }
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "updates to the higher interval from header" do
|
|
264
|
+
expect(subject.interval).to eq(20.0)
|
|
265
|
+
subject.sync
|
|
266
|
+
expect(subject.interval).to eq(60.0) # Uses 60 because it's higher than initial 20
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
context "when poll-interval header can decrease back to initial interval" do
|
|
271
|
+
subject do
|
|
272
|
+
described_class.new(
|
|
273
|
+
remote_adapter: remote_adapter,
|
|
274
|
+
start_automatically: false,
|
|
275
|
+
interval: 10
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
before do
|
|
280
|
+
# First sync increases interval to 60
|
|
281
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
282
|
+
.to_return(
|
|
283
|
+
status: 200,
|
|
284
|
+
body: JSON.generate(
|
|
285
|
+
features: [
|
|
286
|
+
{
|
|
287
|
+
key: "polling",
|
|
288
|
+
gates: [
|
|
289
|
+
{ key: "boolean", value: true }
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
]
|
|
293
|
+
),
|
|
294
|
+
headers: { "poll-interval" => "60" }
|
|
295
|
+
).times(1).then
|
|
296
|
+
.to_return(
|
|
297
|
+
status: 200,
|
|
298
|
+
body: JSON.generate(
|
|
299
|
+
features: [
|
|
300
|
+
{
|
|
301
|
+
key: "polling",
|
|
302
|
+
gates: [
|
|
303
|
+
{ key: "boolean", value: true }
|
|
304
|
+
]
|
|
305
|
+
}
|
|
306
|
+
]
|
|
307
|
+
),
|
|
308
|
+
headers: { "poll-interval" => "10" }
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "allows interval to go back down to initial after being increased" do
|
|
313
|
+
expect(subject.interval).to eq(10.0)
|
|
314
|
+
|
|
315
|
+
# First sync: header says 60, initial is 10, so use 60
|
|
316
|
+
subject.sync
|
|
317
|
+
expect(subject.interval).to eq(60.0)
|
|
318
|
+
|
|
319
|
+
# Second sync: header says 10, initial is 10, so use 10
|
|
320
|
+
subject.sync
|
|
321
|
+
expect(subject.interval).to eq(10.0)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
context "when poll-interval header is missing" do
|
|
326
|
+
before do
|
|
327
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
328
|
+
.to_return(
|
|
329
|
+
status: 200,
|
|
330
|
+
body: JSON.generate(
|
|
331
|
+
features: [
|
|
332
|
+
{
|
|
333
|
+
key: "polling",
|
|
334
|
+
gates: [
|
|
335
|
+
{ key: "boolean", value: true }
|
|
336
|
+
]
|
|
337
|
+
}
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it "does not change the interval" do
|
|
344
|
+
original_interval = subject.interval
|
|
345
|
+
subject.sync
|
|
346
|
+
expect(subject.interval).to eq(original_interval)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
37
349
|
end
|
|
38
350
|
|
|
39
351
|
describe "#start" do
|
|
@@ -43,5 +355,36 @@ RSpec.describe Flipper::Poller do
|
|
|
43
355
|
expect(subject).to receive(:sync)
|
|
44
356
|
subject.start
|
|
45
357
|
end
|
|
358
|
+
|
|
359
|
+
context "after shutdown_requested" do
|
|
360
|
+
before do
|
|
361
|
+
stub_request(:get, "#{url}/features?exclude_gate_names=true")
|
|
362
|
+
.to_return(
|
|
363
|
+
status: 200,
|
|
364
|
+
body: JSON.generate(features: []),
|
|
365
|
+
headers: { "poll-shutdown" => "true" }
|
|
366
|
+
)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it "does not start when shutdown was requested" do
|
|
370
|
+
subject.sync # This triggers shutdown
|
|
371
|
+
|
|
372
|
+
expect(Thread).not_to receive(:new)
|
|
373
|
+
subject.start
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
it "allows starting after a fork" do
|
|
377
|
+
subject.sync # This triggers shutdown
|
|
378
|
+
|
|
379
|
+
# Simulate fork by changing PID
|
|
380
|
+
allow(Process).to receive(:pid).and_return(subject.instance_variable_get(:@pid) + 1)
|
|
381
|
+
|
|
382
|
+
# After fork, start should work again
|
|
383
|
+
expect(Thread).to receive(:new).and_yield
|
|
384
|
+
expect(subject).to receive(:loop).and_yield
|
|
385
|
+
expect(subject).to receive(:sync)
|
|
386
|
+
subject.start
|
|
387
|
+
end
|
|
388
|
+
end
|
|
46
389
|
end
|
|
47
390
|
end
|