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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 881ca599099f433a4204f2a058c512b6b9198f529d1db4fd3a3445ba822dcd9b
4
- data.tar.gz: 5ad5da793cec928e09357f2d46ddb139c159dfc1d5b9ddea4ae4c0c52193a75b
3
+ metadata.gz: b53cde688da0c42de8705aa6d3adbcb7c43f2771a75bb49ccb2b500637b10f07
4
+ data.tar.gz: 65532d9f52b340a85028101e7e31538c5b2165e16fb453e74ca2d65f5041bd07
5
5
  SHA512:
6
- metadata.gz: 9b049bcd83af70810efd36f1873fb9d72cf22f95cf460d80829b7ad7ed68b7ef11e619b621ea001c230dfdbee43c453e76326de59be2306ee52b23444b50619b
7
- data.tar.gz: fb82eca0e34233cf3fe9e001923f3b87ae2acf9f7dfdbbdce553d1b8408d5f23df075d789f5be2f975d6facf077637294f306d774e026005e4a7a13124866a3a
6
+ metadata.gz: a250b1d0b4928a0f060fc699cbf4ff69550158732bcfb3f5cf567d088a207daa1607b6da342ec3cc2888353032ad42f66096f2281e2751a0948ff1a5bed8e02b
7
+ data.tar.gz: 8dd06e0a50cf2f80efb2fbbb8ad42ddbc3140befd7d0b28a9a9124b7432365aa4c5e541d5f4d3749d3cb70e4d67ba53c9b2c05ecac50f564b1c426b53314de3e
@@ -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@v4
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@v4
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') }}
@@ -0,0 +1,4 @@
1
+ {
2
+ "setup": ["script/conductor-setup"],
3
+ "teardown": []
4
+ }
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.
@@ -65,7 +65,7 @@ module Flipper
65
65
  path += "&_cb=#{Time.now.to_i}" if cache_bust
66
66
  etag = @get_all_mutex.synchronize { @last_get_all_etag }
67
67
 
68
- if etag
68
+ if etag && !cache_bust
69
69
  options[:headers] = { if_none_match: etag }
70
70
  end
71
71
 
@@ -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::ActorLimit.with_sync_mode { sync }
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
 
@@ -18,7 +18,10 @@ module Flipper
18
18
  end
19
19
 
20
20
  def self.reset
21
- instances.each {|_, instance| instance.stop }.clear
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
  })
@@ -1,5 +1,5 @@
1
1
  module Flipper
2
- VERSION = '1.4.0'.freeze
2
+ VERSION = '1.4.1'.freeze
3
3
 
4
4
  REQUIRED_RUBY_VERSION = '2.6'.freeze
5
5
  NEXT_REQUIRED_RUBY_VERSION = '3.0'.freeze
@@ -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 "raises an error for unknown feature" do
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 "raises an error for unknown feature" do
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 "raises an error for unknown feature" do
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 "raises an error for unknown feature" do
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.0
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-02-26 00:00:00.000000000 Z
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.0
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: []