flipper 1.4.0 → 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 +1 -1
- data/.github/workflows/examples.yml +1 -1
- data/.superset/config.json +4 -0
- data/CLAUDE.md +6 -0
- data/lib/flipper/adapters/http.rb +1 -1
- data/lib/flipper/adapters/strict.rb +30 -0
- data/lib/flipper/adapters/sync/synchronizer.rb +4 -1
- data/lib/flipper/poller.rb +5 -1
- data/lib/flipper/version.rb +1 -1
- data/spec/flipper/adapters/http_spec.rb +89 -0
- data/spec/flipper/adapters/strict_spec.rb +62 -4
- data/spec/spec_helper.rb +7 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b53cde688da0c42de8705aa6d3adbcb7c43f2771a75bb49ccb2b500637b10f07
|
|
4
|
+
data.tar.gz: 65532d9f52b340a85028101e7e31538c5b2165e16fb453e74ca2d65f5041bd07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a250b1d0b4928a0f060fc699cbf4ff69550158732bcfb3f5cf567d088a207daa1607b6da342ec3cc2888353032ad42f66096f2281e2751a0948ff1a5bed8e02b
|
|
7
|
+
data.tar.gz: 8dd06e0a50cf2f80efb2fbbb8ad42ddbc3140befd7d0b28a9a9124b7432365aa4c5e541d5f4d3749d3cb70e4d67ba53c9b2c05ecac50f564b1c426b53314de3e
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -93,7 +93,7 @@ jobs:
|
|
|
93
93
|
- name: Check out repository code
|
|
94
94
|
uses: actions/checkout@v6
|
|
95
95
|
- name: Do some action caching
|
|
96
|
-
uses: actions/cache@
|
|
96
|
+
uses: actions/cache@v5
|
|
97
97
|
with:
|
|
98
98
|
path: vendor/bundle
|
|
99
99
|
key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.rails }}-${{ hashFiles('**/Gemfile.lock') }}
|
|
@@ -73,7 +73,7 @@ jobs:
|
|
|
73
73
|
- name: Check out repository code
|
|
74
74
|
uses: actions/checkout@v6
|
|
75
75
|
- name: Do some action caching
|
|
76
|
-
uses: actions/cache@
|
|
76
|
+
uses: actions/cache@v5
|
|
77
77
|
with:
|
|
78
78
|
path: vendor/bundle
|
|
79
79
|
key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.rails }}-${{ hashFiles('**/Gemfile.lock') }}
|
data/CLAUDE.md
CHANGED
|
@@ -26,6 +26,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
26
26
|
- `bundle exec rake build` - Build all gems locally into pkg/ directory
|
|
27
27
|
- `script/release` - Manual fallback for local releases (prompts for OTP)
|
|
28
28
|
|
|
29
|
+
**After releasing**, purge the cached release URLs on flippercloud.io:
|
|
30
|
+
- `/release`
|
|
31
|
+
- `/release.json`
|
|
32
|
+
|
|
29
33
|
## Architecture Overview
|
|
30
34
|
|
|
31
35
|
Flipper is a feature flag library for Ruby with a modular adapter-based architecture:
|
|
@@ -85,3 +89,5 @@ For outbound HTTP requests, use `Flipper::Adapters::Http::Client` instead of raw
|
|
|
85
89
|
### Testing
|
|
86
90
|
|
|
87
91
|
Uses both RSpec (currently preferred for new tests) and Minitest. Shared adapter specs ensure consistency across all storage backends. Extensive testing across multiple Rails versions (5.0-8.0).
|
|
92
|
+
|
|
93
|
+
`Flipper.configuration` is reset to nil before each spec (in `spec/spec_helper.rb`), but `Flipper::UI.configuration` is **not** globally reset. When modifying UI config in tests, set the value in `before` and reset it in `after` to match the existing pattern throughout the spec suite.
|
|
@@ -10,11 +10,40 @@ module Flipper
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
class << self
|
|
14
|
+
# Returns whether sync mode is enabled for the current thread.
|
|
15
|
+
# When sync mode is enabled, strict checks are not enforced,
|
|
16
|
+
# allowing sync operations to add features and bring local state
|
|
17
|
+
# in line with remote state.
|
|
18
|
+
def sync_mode
|
|
19
|
+
Thread.current[:flipper_strict_sync_mode]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sync_mode=(value)
|
|
23
|
+
Thread.current[:flipper_strict_sync_mode] = value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Executes a block with sync mode enabled. Strict checks will
|
|
27
|
+
# not be enforced within the block.
|
|
28
|
+
def with_sync_mode
|
|
29
|
+
old_value = sync_mode
|
|
30
|
+
self.sync_mode = true
|
|
31
|
+
yield
|
|
32
|
+
ensure
|
|
33
|
+
self.sync_mode = old_value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
13
37
|
def initialize(adapter, handler = nil, &block)
|
|
14
38
|
super(adapter)
|
|
15
39
|
@handler = block || handler
|
|
16
40
|
end
|
|
17
41
|
|
|
42
|
+
def add(feature)
|
|
43
|
+
assert_feature_exists(feature) unless self.class.sync_mode
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
18
47
|
def get(feature)
|
|
19
48
|
assert_feature_exists(feature)
|
|
20
49
|
super
|
|
@@ -28,6 +57,7 @@ module Flipper
|
|
|
28
57
|
private
|
|
29
58
|
|
|
30
59
|
def assert_feature_exists(feature)
|
|
60
|
+
return if self.class.sync_mode
|
|
31
61
|
return if @adapter.features.include?(feature.key)
|
|
32
62
|
|
|
33
63
|
case handler
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "flipper/feature"
|
|
2
2
|
require "flipper/gate_values"
|
|
3
3
|
require "flipper/adapters/actor_limit"
|
|
4
|
+
require "flipper/adapters/strict"
|
|
4
5
|
require "flipper/adapters/sync/feature_synchronizer"
|
|
5
6
|
|
|
6
7
|
module Flipper
|
|
@@ -29,7 +30,9 @@ module Flipper
|
|
|
29
30
|
# Public: Forces a sync.
|
|
30
31
|
def call
|
|
31
32
|
@instrumenter.instrument("synchronizer_call.flipper") do
|
|
32
|
-
Flipper::Adapters::
|
|
33
|
+
Flipper::Adapters::Strict.with_sync_mode do
|
|
34
|
+
Flipper::Adapters::ActorLimit.with_sync_mode { sync }
|
|
35
|
+
end
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
38
|
|
data/lib/flipper/poller.rb
CHANGED
|
@@ -18,7 +18,10 @@ module Flipper
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def self.reset
|
|
21
|
-
instances.each
|
|
21
|
+
instances.each do |_, instance|
|
|
22
|
+
instance.stop
|
|
23
|
+
instance.thread&.join(1)
|
|
24
|
+
end.clear
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
MINIMUM_POLL_INTERVAL = 10
|
|
@@ -116,6 +119,7 @@ module Flipper
|
|
|
116
119
|
begin
|
|
117
120
|
return if thread_alive?
|
|
118
121
|
@thread = Thread.new { run }
|
|
122
|
+
@thread&.report_on_exception = false
|
|
119
123
|
@instrumenter.instrument("poller.#{InstrumentationNamespace}", {
|
|
120
124
|
operation: :thread_start,
|
|
121
125
|
})
|
data/lib/flipper/version.rb
CHANGED
|
@@ -278,6 +278,95 @@ RSpec.describe Flipper::Adapters::Http do
|
|
|
278
278
|
}.to raise_error(Flipper::Adapters::Http::Error)
|
|
279
279
|
end
|
|
280
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
|
+
|
|
281
370
|
it "does not send If-None-Match for other endpoints" do
|
|
282
371
|
stub_request(:get, "http://app.com/flipper/features/search")
|
|
283
372
|
.to_return(status: 404)
|
|
@@ -21,6 +21,12 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
21
21
|
expect { subject.get_multi([feature]) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
context "#add" do
|
|
26
|
+
it "raises an error for unknown feature" do
|
|
27
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
24
30
|
end
|
|
25
31
|
end
|
|
26
32
|
|
|
@@ -28,16 +34,22 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
28
34
|
subject { described_class.new(Flipper::Adapters::Memory.new, :warn) }
|
|
29
35
|
|
|
30
36
|
context "#get" do
|
|
31
|
-
it "
|
|
37
|
+
it "warns for unknown feature" do
|
|
32
38
|
expect(capture_output { subject.get(feature) }).to match(/Could not find feature "unknown"/)
|
|
33
39
|
end
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
context "#get_multi" do
|
|
37
|
-
it "
|
|
43
|
+
it "warns for unknown feature" do
|
|
38
44
|
expect(capture_output { subject.get_multi([feature]) }).to match(/Could not find feature "unknown"/)
|
|
39
45
|
end
|
|
40
46
|
end
|
|
47
|
+
|
|
48
|
+
context "#add" do
|
|
49
|
+
it "warns for unknown feature" do
|
|
50
|
+
expect(capture_output { subject.add(feature) }).to match(/Could not find feature "unknown"/)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
context "handler = Block" do
|
|
@@ -48,17 +60,63 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
48
60
|
|
|
49
61
|
|
|
50
62
|
context "#get" do
|
|
51
|
-
it "
|
|
63
|
+
it "calls block for unknown feature" do
|
|
52
64
|
subject.get(feature)
|
|
53
65
|
expect(unknown_features).to eq(["unknown"])
|
|
54
66
|
end
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
context "#get_multi" do
|
|
58
|
-
it "
|
|
70
|
+
it "calls block for unknown feature" do
|
|
59
71
|
subject.get_multi([flipper[:foo], flipper[:bar]])
|
|
60
72
|
expect(unknown_features).to eq(["foo", "bar"])
|
|
61
73
|
end
|
|
62
74
|
end
|
|
75
|
+
|
|
76
|
+
context "#add" do
|
|
77
|
+
it "calls block for unknown feature" do
|
|
78
|
+
subject.add(feature)
|
|
79
|
+
expect(unknown_features).to eq(["unknown"])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe ".with_sync_mode" do
|
|
85
|
+
subject { described_class.new(Flipper::Adapters::Memory.new, :raise) }
|
|
86
|
+
|
|
87
|
+
it "bypasses strict checks for add" do
|
|
88
|
+
described_class.with_sync_mode do
|
|
89
|
+
expect { subject.add(feature) }.not_to raise_error
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "bypasses strict checks for get" do
|
|
94
|
+
described_class.with_sync_mode do
|
|
95
|
+
expect { subject.get(feature) }.not_to raise_error
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "bypasses strict checks for get_multi" do
|
|
100
|
+
described_class.with_sync_mode do
|
|
101
|
+
expect { subject.get_multi([feature]) }.not_to raise_error
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "restores previous sync mode after block" do
|
|
106
|
+
described_class.with_sync_mode do
|
|
107
|
+
# inside sync mode
|
|
108
|
+
end
|
|
109
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "restores previous sync mode even on error" do
|
|
113
|
+
begin
|
|
114
|
+
described_class.with_sync_mode do
|
|
115
|
+
raise "boom"
|
|
116
|
+
end
|
|
117
|
+
rescue RuntimeError
|
|
118
|
+
end
|
|
119
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
120
|
+
end
|
|
63
121
|
end
|
|
64
122
|
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -39,6 +39,13 @@ RSpec.configure do |config|
|
|
|
39
39
|
Flipper.configuration = nil
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
config.after(:example) do
|
|
43
|
+
# Stop any pollers started during the test BEFORE WebMock clears stubs
|
|
44
|
+
# in its own after hook (RSpec runs after hooks in reverse registration
|
|
45
|
+
# order, so ours runs first since webmock/rspec was required before this).
|
|
46
|
+
Flipper::Poller.reset if defined?(Flipper::Poller)
|
|
47
|
+
end
|
|
48
|
+
|
|
42
49
|
config.disable_monkey_patching!
|
|
43
50
|
|
|
44
51
|
config.filter_run focus: true
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: flipper
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.4.
|
|
4
|
+
version: 1.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- John Nunemaker
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-03-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- ".github/workflows/examples.yml"
|
|
39
39
|
- ".github/workflows/release.yml"
|
|
40
40
|
- ".rspec"
|
|
41
|
+
- ".superset/config.json"
|
|
41
42
|
- CLAUDE.md
|
|
42
43
|
- CODE_OF_CONDUCT.md
|
|
43
44
|
- Changelog.md
|
|
@@ -322,7 +323,7 @@ metadata:
|
|
|
322
323
|
homepage_uri: https://www.flippercloud.io
|
|
323
324
|
source_code_uri: https://github.com/flippercloud/flipper
|
|
324
325
|
bug_tracker_uri: https://github.com/flippercloud/flipper/issues
|
|
325
|
-
changelog_uri: https://github.com/flippercloud/flipper/releases/tag/v1.4.
|
|
326
|
+
changelog_uri: https://github.com/flippercloud/flipper/releases/tag/v1.4.1
|
|
326
327
|
funding_uri: https://github.com/sponsors/flippercloud
|
|
327
328
|
post_install_message:
|
|
328
329
|
rdoc_options: []
|