flipper 1.3.2 → 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/Gemfile +6 -2
- data/README.md +4 -3
- data/examples/cloud/backoff_policy.rb +1 -1
- 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/error.rb +1 -1
- data/lib/flipper/adapters/http.rb +39 -4
- 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/poll.rb +15 -0
- 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 +14 -6
- 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/backoff_policy.rb +6 -3
- data/lib/flipper/cloud/telemetry/submitter.rb +3 -1
- data/lib/flipper/cloud/telemetry.rb +3 -3
- data/lib/flipper/cloud.rb +1 -0
- data/lib/flipper/dsl.rb +1 -1
- data/lib/flipper/export.rb +0 -2
- data/lib/flipper/expressions/all.rb +0 -2
- data/lib/flipper/expressions/feature_enabled.rb +34 -0
- data/lib/flipper/expressions/time.rb +8 -1
- data/lib/flipper/feature.rb +8 -1
- data/lib/flipper/gate.rb +1 -1
- data/lib/flipper/gates/expression.rb +2 -2
- data/lib/flipper/instrumentation/log_subscriber.rb +1 -2
- data/lib/flipper/instrumentation/statsd.rb +4 -2
- data/lib/flipper/instrumentation/subscriber.rb +0 -4
- data/lib/flipper/metadata.rb +1 -0
- data/lib/flipper/poller.rb +54 -11
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +17 -1
- data/lib/generators/flipper/setup_generator.rb +5 -0
- data/lib/generators/flipper/templates/initializer.rb +45 -0
- 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 +241 -0
- data/spec/flipper/adapters/poll_spec.rb +41 -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 +11 -3
- data/spec/flipper/cloud/middleware_spec.rb +34 -16
- data/spec/flipper/cloud/migrate_spec.rb +160 -0
- data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +3 -3
- data/spec/flipper/cloud/telemetry/submitter_spec.rb +4 -4
- data/spec/flipper/cloud/telemetry_spec.rb +6 -6
- data/spec/flipper/cloud_spec.rb +9 -4
- data/spec/flipper/dsl_spec.rb +0 -3
- data/spec/flipper/engine_spec.rb +3 -2
- data/spec/flipper/expressions/time_spec.rb +16 -0
- data/spec/flipper/feature_spec.rb +22 -11
- data/spec/flipper/gates/expression_spec.rb +82 -0
- data/spec/flipper/instrumentation/log_subscriber_spec.rb +1 -0
- data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +1 -1
- data/spec/flipper/middleware/memoizer_spec.rb +41 -11
- data/spec/flipper/model/active_record_spec.rb +11 -0
- data/spec/flipper/poller_spec.rb +347 -4
- data/spec/flipper_integration_spec.rb +133 -0
- data/spec/flipper_spec.rb +7 -2
- data/spec/spec_helper.rb +15 -5
- data/test_rails/generators/flipper/setup_generator_test.rb +5 -0
- data/test_rails/generators/flipper/update_generator_test.rb +1 -1
- data/test_rails/helper.rb +3 -0
- metadata +17 -111
- data/lib/flipper/expressions/duration.rb +0 -28
- data/spec/flipper/expressions/duration_spec.rb +0 -43
|
@@ -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
|
|
@@ -18,7 +18,7 @@ RSpec.describe Flipper::Instrumentation::StatsdSubscriber do
|
|
|
18
18
|
Flipper.new(adapter, instrumenter: ActiveSupport::Notifications)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
let(:user) {
|
|
21
|
+
let(:user) { Flipper::Actor.new('1') }
|
|
22
22
|
|
|
23
23
|
before do
|
|
24
24
|
described_class.client = statsd_client
|
|
@@ -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
|
|
@@ -80,7 +82,7 @@ RSpec.describe Flipper::Middleware::Memoizer do
|
|
|
80
82
|
context 'with preload: true' do
|
|
81
83
|
let(:app) do
|
|
82
84
|
# ensure scoped for builder block, annoying...
|
|
83
|
-
|
|
85
|
+
flipper
|
|
84
86
|
middleware = described_class
|
|
85
87
|
|
|
86
88
|
Rack::Builder.new do
|
|
@@ -141,7 +143,7 @@ RSpec.describe Flipper::Middleware::Memoizer do
|
|
|
141
143
|
context 'with preload specific' do
|
|
142
144
|
let(:app) do
|
|
143
145
|
# ensure scoped for builder block, annoying...
|
|
144
|
-
|
|
146
|
+
flipper
|
|
145
147
|
middleware = described_class
|
|
146
148
|
|
|
147
149
|
Rack::Builder.new do
|
|
@@ -266,7 +268,7 @@ RSpec.describe Flipper::Middleware::Memoizer do
|
|
|
266
268
|
context 'with multiple instances' do
|
|
267
269
|
let(:app) do
|
|
268
270
|
# ensure scoped for builder block, annoying...
|
|
269
|
-
|
|
271
|
+
flipper
|
|
270
272
|
middleware = described_class
|
|
271
273
|
|
|
272
274
|
Rack::Builder.new do
|
|
@@ -316,7 +318,7 @@ RSpec.describe Flipper::Middleware::Memoizer do
|
|
|
316
318
|
context 'with flipper setup in env' do
|
|
317
319
|
let(:app) do
|
|
318
320
|
# ensure scoped for builder block, annoying...
|
|
319
|
-
|
|
321
|
+
flipper
|
|
320
322
|
middleware = described_class
|
|
321
323
|
|
|
322
324
|
Rack::Builder.new do
|
|
@@ -460,7 +462,6 @@ RSpec.describe Flipper::Middleware::Memoizer do
|
|
|
460
462
|
cache.clear
|
|
461
463
|
cached = Flipper::Adapters::ActiveSupportCacheStore.new(logged_memory, cache)
|
|
462
464
|
logged_cached = Flipper::Adapters::OperationLogger.new(cached)
|
|
463
|
-
memo = {}
|
|
464
465
|
flipper = Flipper.new(logged_cached)
|
|
465
466
|
flipper[:stats].enable
|
|
466
467
|
flipper[:shiny].enable
|
|
@@ -471,18 +472,47 @@ RSpec.describe Flipper::Middleware::Memoizer do
|
|
|
471
472
|
|
|
472
473
|
get '/', {}, 'flipper' => flipper
|
|
473
474
|
expect(logged_cached.count(:get_all)).to be(1)
|
|
474
|
-
expect(logged_memory.count(:
|
|
475
|
-
expect(logged_memory.count(:get_multi)).to be(1)
|
|
475
|
+
expect(logged_memory.count(:get_all)).to be(1)
|
|
476
476
|
|
|
477
477
|
get '/', {}, 'flipper' => flipper
|
|
478
478
|
expect(logged_cached.count(:get_all)).to be(2)
|
|
479
|
-
expect(logged_memory.count(:
|
|
480
|
-
expect(logged_memory.count(:get_multi)).to be(1)
|
|
479
|
+
expect(logged_memory.count(:get_all)).to be(1)
|
|
481
480
|
|
|
482
481
|
get '/', {}, 'flipper' => flipper
|
|
483
482
|
expect(logged_cached.count(:get_all)).to be(3)
|
|
484
|
-
expect(logged_memory.count(:
|
|
485
|
-
|
|
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)
|
|
486
516
|
end
|
|
487
517
|
end
|
|
488
518
|
end
|
|
@@ -30,9 +30,20 @@ RSpec.describe Flipper::Model::ActiveRecord do
|
|
|
30
30
|
include Flipper::Model::ActiveRecord
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
class DelegatedUser < DelegateClass(User)
|
|
34
|
+
end
|
|
35
|
+
|
|
33
36
|
class Admin < User
|
|
34
37
|
end
|
|
35
38
|
|
|
39
|
+
it "doesn't warn for to_ary" do
|
|
40
|
+
# looks like we should remove this but you are wrong, we have specs that
|
|
41
|
+
# fail if there are warnings and if this regresses it will print a warning
|
|
42
|
+
# so it is in fact testing something
|
|
43
|
+
user = User.create!(name: "Test")
|
|
44
|
+
Flipper.enabled?(:something, DelegatedUser.new(user))
|
|
45
|
+
end
|
|
46
|
+
|
|
36
47
|
describe "flipper_id" do
|
|
37
48
|
it "returns class name and id" do
|
|
38
49
|
expect(User.new(id: 1).flipper_id).to eq("User;1")
|
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
|