flipper 1.4.0 → 1.4.2

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: c886665703939f49361ef42fb82e951383cb53bd99f2b1d902add786f6793da0
4
+ data.tar.gz: b7af2d5972cc409f075390871dc96b225e495133917e0c5024db297760252e73
5
5
  SHA512:
6
- metadata.gz: 9b049bcd83af70810efd36f1873fb9d72cf22f95cf460d80829b7ad7ed68b7ef11e619b621ea001c230dfdbee43c453e76326de59be2306ee52b23444b50619b
7
- data.tar.gz: fb82eca0e34233cf3fe9e001923f3b87ae2acf9f7dfdbbdce553d1b8408d5f23df075d789f5be2f975d6facf077637294f306d774e026005e4a7a13124866a3a
6
+ metadata.gz: 338ccb2e6d9ddeaa6f6a1f20755e7799dcda40341c22831f8eda1f25df5f08bf4c61d67333e9bdebf94172107842b07e7ddb3ec7a54a2d057663f109e78f5a5d
7
+ data.tar.gz: bbfdb72c366e7fa334d8c3903b799366bed22d755cf6771c532d1335cccf7e9480c8a752c700a449f1ae837ae273be5b9c06ef58bc38b56570d96ef417edbded
@@ -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
 
@@ -142,7 +142,8 @@ module Flipper
142
142
  private
143
143
 
144
144
  def app_adapter
145
- Flipper::Adapters::DualWrite.new(poll_adapter, http_adapter)
145
+ read_adapter = sync_method == :webhook ? local_adapter : poll_adapter
146
+ Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
146
147
  end
147
148
 
148
149
  def poller
@@ -201,13 +202,8 @@ module Flipper
201
202
  end
202
203
 
203
204
  def setup_sync(options)
205
+ set_option :sync_interval, options, default: 10, typecast: :float, minimum: 10
204
206
  set_option :sync_secret, options
205
-
206
- # 1 hour for webhook, 10 seconds for poll. If using webhooks we don't
207
- # need to sync as often but we should still sync occasionally to avoid
208
- # any chance of stale data.
209
- default_interval = sync_method == :webhook ? 3600 : 10
210
- set_option :sync_interval, options, default: default_interval, typecast: :float, minimum: 10
211
207
  end
212
208
 
213
209
  def setup_adapter(options)
@@ -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
@@ -96,9 +99,7 @@ module Flipper
96
99
  private
97
100
 
98
101
  def jitter
99
- # Cap jitter at 30 seconds to prevent excessive delays for large intervals
100
- max_jitter = [interval * 0.1, 30].min
101
- rand * max_jitter
102
+ rand
102
103
  end
103
104
 
104
105
  def forked?
@@ -116,6 +117,7 @@ module Flipper
116
117
  begin
117
118
  return if thread_alive?
118
119
  @thread = Thread.new { run }
120
+ @thread&.report_on_exception = false
119
121
  @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
120
122
  operation: :thread_start,
121
123
  })
@@ -1,5 +1,5 @@
1
1
  module Flipper
2
- VERSION = '1.4.0'.freeze
2
+ VERSION = '1.4.2'.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
@@ -135,9 +135,6 @@ RSpec.describe Flipper::Cloud::Configuration do
135
135
  end
136
136
 
137
137
  it "sets sync_method to :webhook if sync_secret provided" do
138
- # The initial sync of http to local invokes this web request.
139
- stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
140
-
141
138
  instance = described_class.new(required_options.merge({
142
139
  sync_secret: "secret",
143
140
  }))
@@ -147,9 +144,6 @@ RSpec.describe Flipper::Cloud::Configuration do
147
144
  end
148
145
 
149
146
  it "sets sync_method to :webhook if FLIPPER_CLOUD_SYNC_SECRET set" do
150
- # The initial sync of http to local invokes this web request.
151
- stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
152
-
153
147
  ENV["FLIPPER_CLOUD_SYNC_SECRET"] = "abc"
154
148
  instance = described_class.new(required_options)
155
149
 
@@ -5,9 +5,6 @@ require 'flipper/adapters/instrumented'
5
5
 
6
6
  RSpec.describe Flipper::Cloud::DSL do
7
7
  it 'delegates everything to flipper instance' do
8
- # stub the initial sync of http to local
9
- stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
10
-
11
8
  cloud_configuration = Flipper::Cloud::Configuration.new({
12
9
  token: "asdf",
13
10
  sync_secret: "tasty",
@@ -30,13 +27,10 @@ RSpec.describe Flipper::Cloud::DSL do
30
27
  })
31
28
  dsl = described_class.new(cloud_configuration)
32
29
  dsl.sync
33
- expect(stub).to have_been_requested.at_least_once
30
+ expect(stub).to have_been_requested
34
31
  end
35
32
 
36
33
  it 'delegates sync_secret to cloud configuration' do
37
- # stub the initial sync of http to local
38
- stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
39
-
40
34
  cloud_configuration = Flipper::Cloud::Configuration.new({
41
35
  token: "asdf",
42
36
  sync_secret: "tasty",
@@ -59,15 +53,13 @@ RSpec.describe Flipper::Cloud::DSL do
59
53
  end
60
54
 
61
55
  subject do
62
- # stub the initial sync of http to local
63
- stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
64
56
  described_class.new(cloud_configuration)
65
57
  end
66
58
 
67
59
  it "sends reads to local adapter" do
68
60
  subject.features
69
61
  subject.enabled?(:foo)
70
- expect(local_adapter.count(:features)).to be(2)
62
+ expect(local_adapter.count(:features)).to be(1)
71
63
  expect(local_adapter.count(:get)).to be(1)
72
64
  end
73
65
 
@@ -62,7 +62,7 @@ RSpec.describe Flipper::Cloud::Middleware do
62
62
  {"name" => "premium"},
63
63
  ],
64
64
  })
65
- expect(stub).to have_been_made.at_least_once
65
+ expect(stub).to have_been_requested
66
66
  end
67
67
  end
68
68
 
@@ -72,17 +72,15 @@ RSpec.describe Flipper::Cloud::Middleware do
72
72
  Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
73
73
  }
74
74
 
75
- it 'does not perform webhook sync' do
76
- webhook_regular_stub = stub_request_for_token('regular', from_webhook: true)
77
- poll_regular_stub = stub_request_for_token('regular', from_webhook: false)
75
+ it 'uses instance to sync' do
76
+ stub = stub_request_for_token('regular')
78
77
  env = {
79
78
  "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
80
79
  }
81
80
  post '/', request_body, env
82
81
 
83
82
  expect(last_response.status).to eq(400)
84
- expect(poll_regular_stub).to have_been_requested.at_least_once
85
- expect(webhook_regular_stub).not_to have_been_requested
83
+ expect(stub).not_to have_been_requested
86
84
  end
87
85
  end
88
86
 
@@ -105,7 +103,7 @@ RSpec.describe Flipper::Cloud::Middleware do
105
103
  expect(last_response.status).to eq(402)
106
104
  expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
107
105
  expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 402")
108
- expect(stub).to have_been_made.at_least_once
106
+ expect(stub).to have_been_requested
109
107
  end
110
108
  end
111
109
 
@@ -128,7 +126,7 @@ RSpec.describe Flipper::Cloud::Middleware do
128
126
  expect(last_response.status).to eq(500)
129
127
  expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
130
128
  expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 503")
131
- expect(stub).to have_been_made.at_least_once
129
+ expect(stub).to have_been_requested
132
130
  end
133
131
  end
134
132
 
@@ -151,7 +149,7 @@ RSpec.describe Flipper::Cloud::Middleware do
151
149
  expect(last_response.status).to eq(500)
152
150
  expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Net::OpenTimeout")
153
151
  expect(last_response.headers["flipper-cloud-response-error-message"]).to eq("execution expired")
154
- expect(stub).to have_been_made.at_least_once
152
+ expect(stub).to have_been_requested
155
153
  end
156
154
  end
157
155
 
@@ -162,8 +160,7 @@ RSpec.describe Flipper::Cloud::Middleware do
162
160
  }
163
161
 
164
162
  it 'uses env instance to sync' do
165
- regular_stub = stub_request_for_token('regular')
166
- env_stub = stub_request_for_token('env')
163
+ stub = stub_request_for_token('env')
167
164
  env = {
168
165
  "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
169
166
  'flipper' => env_flipper,
@@ -171,8 +168,7 @@ RSpec.describe Flipper::Cloud::Middleware do
171
168
  post '/', request_body, env
172
169
 
173
170
  expect(last_response.status).to eq(200)
174
- expect(regular_stub).to have_been_made.at_least_once
175
- expect(env_stub).to have_been_made.at_least_once
171
+ expect(stub).to have_been_requested
176
172
  end
177
173
  end
178
174
 
@@ -191,7 +187,7 @@ RSpec.describe Flipper::Cloud::Middleware do
191
187
  post '/', request_body, env
192
188
 
193
189
  expect(last_response.status).to eq(200)
194
- expect(stub).to have_been_made.at_least_once
190
+ expect(stub).to have_been_requested
195
191
  end
196
192
  end
197
193
 
@@ -202,9 +198,7 @@ RSpec.describe Flipper::Cloud::Middleware do
202
198
  }
203
199
 
204
200
  it 'uses provided env key instead of default' do
205
- regular_poll_stub = stub_request_for_token('regular')
206
- env_poll_stub = stub_request_for_token('env')
207
- env_webhook_stub = stub_request_for_token('env', from_webhook: true)
201
+ stub = stub_request_for_token('env')
208
202
  env = {
209
203
  "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
210
204
  'flipper' => flipper,
@@ -213,9 +207,7 @@ RSpec.describe Flipper::Cloud::Middleware do
213
207
  post '/', request_body, env
214
208
 
215
209
  expect(last_response.status).to eq(200)
216
- expect(regular_poll_stub).to have_been_made.at_least_once
217
- expect(env_poll_stub).to have_been_made.at_least_once
218
- expect(env_webhook_stub).not_to have_been_requested
210
+ expect(stub).to have_been_requested
219
211
  end
220
212
  end
221
213
 
@@ -230,7 +222,7 @@ RSpec.describe Flipper::Cloud::Middleware do
230
222
  post '/', request_body, env
231
223
 
232
224
  expect(last_response.status).to eq(200)
233
- expect(stub).to have_been_made.at_least_once
225
+ expect(stub).to have_been_requested
234
226
  end
235
227
  end
236
228
 
@@ -260,13 +252,12 @@ RSpec.describe Flipper::Cloud::Middleware do
260
252
  {"name" => "premium"},
261
253
  ],
262
254
  })
263
- expect(stub).to have_been_made.at_least_once
255
+ expect(stub).to have_been_requested
264
256
  end
265
257
  end
266
258
 
267
259
  describe 'Request method unsupported' do
268
260
  it 'skips middleware' do
269
- stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
270
261
  get '/'
271
262
  expect(last_response.status).to eq(404)
272
263
  expect(last_response.content_type).to eq("application/json")
@@ -276,23 +267,14 @@ RSpec.describe Flipper::Cloud::Middleware do
276
267
 
277
268
  describe 'Inspecting the built Rack app' do
278
269
  it 'returns a String' do
279
- stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
280
270
  expect(Flipper::Cloud.app(flipper).inspect).to eq("Flipper::Cloud")
281
271
  end
282
272
  end
283
273
 
284
274
  private
285
275
 
286
- def stub_request_for_token(token, status: 200, from_webhook: false)
287
- if from_webhook
288
- # Match URL with both exclude_gate_names=true and _cb=integer
289
- url_pattern = %r{https://www\.flippercloud\.io/adapter/features\?.*exclude_gate_names=true.*&_cb=\d+}
290
- else
291
- # Match URL with just exclude_gate_names=true
292
- url_pattern = %r{https://www\.flippercloud\.io/adapter/features\?.*exclude_gate_names=true}
293
- end
294
-
295
- stub = stub_request(:get, url_pattern).
276
+ def stub_request_for_token(token, status: 200)
277
+ stub = stub_request(:get, %r{\Ahttps://www\.flippercloud\.io/adapter/features\?(?=.*\bexclude_gate_names=true\b)(?=.*\b_cb=\d+\b)}).
296
278
  with({
297
279
  headers: {
298
280
  'flipper-cloud-token' => token,
data/spec/flipper_spec.rb CHANGED
@@ -259,7 +259,7 @@ RSpec.describe Flipper do
259
259
  end
260
260
  described_class.sync
261
261
  expect(described_class.sync_secret).to eq("tasty")
262
- expect(stub).to have_been_made.at_least_once
262
+ expect(stub).to have_been_requested
263
263
  end
264
264
  end
265
265
 
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.2
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-05-11 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.2
326
327
  funding_uri: https://github.com/sponsors/flippercloud
327
328
  post_install_message:
328
329
  rdoc_options: []