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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -6
  3. data/.github/workflows/examples.yml +5 -4
  4. data/.github/workflows/release.yml +54 -0
  5. data/.superset/config.json +4 -0
  6. data/CLAUDE.md +93 -0
  7. data/README.md +4 -3
  8. data/examples/cloud/poll_interval/README.md +111 -0
  9. data/examples/cloud/poll_interval/client.rb +108 -0
  10. data/examples/cloud/poll_interval/server.rb +98 -0
  11. data/examples/expressions.rb +35 -11
  12. data/lib/flipper/adapter.rb +17 -1
  13. data/lib/flipper/adapters/actor_limit.rb +27 -1
  14. data/lib/flipper/adapters/cache_base.rb +21 -3
  15. data/lib/flipper/adapters/dual_write.rb +6 -2
  16. data/lib/flipper/adapters/failover.rb +9 -3
  17. data/lib/flipper/adapters/failsafe.rb +2 -2
  18. data/lib/flipper/adapters/http/client.rb +15 -4
  19. data/lib/flipper/adapters/http.rb +37 -2
  20. data/lib/flipper/adapters/instrumented.rb +2 -2
  21. data/lib/flipper/adapters/memoizable.rb +3 -3
  22. data/lib/flipper/adapters/memory.rb +1 -1
  23. data/lib/flipper/adapters/pstore.rb +1 -1
  24. data/lib/flipper/adapters/strict.rb +30 -0
  25. data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
  26. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  27. data/lib/flipper/adapters/sync.rb +7 -3
  28. data/lib/flipper/cli.rb +51 -0
  29. data/lib/flipper/cloud/configuration.rb +9 -4
  30. data/lib/flipper/cloud/dsl.rb +2 -2
  31. data/lib/flipper/cloud/middleware.rb +1 -1
  32. data/lib/flipper/cloud/migrate.rb +71 -0
  33. data/lib/flipper/cloud/telemetry.rb +1 -1
  34. data/lib/flipper/cloud.rb +1 -0
  35. data/lib/flipper/dsl.rb +1 -1
  36. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  37. data/lib/flipper/expressions/time.rb +8 -1
  38. data/lib/flipper/gates/expression.rb +2 -2
  39. data/lib/flipper/poller.rb +52 -9
  40. data/lib/flipper/version.rb +1 -1
  41. data/lib/flipper.rb +17 -1
  42. data/spec/flipper/adapter_spec.rb +20 -0
  43. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  44. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  45. data/spec/flipper/adapters/failover_spec.rb +12 -0
  46. data/spec/flipper/adapters/http_spec.rb +240 -0
  47. data/spec/flipper/adapters/strict_spec.rb +62 -4
  48. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  49. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  50. data/spec/flipper/adapters/sync_spec.rb +13 -0
  51. data/spec/flipper/cli_spec.rb +51 -0
  52. data/spec/flipper/cloud/configuration_spec.rb +6 -0
  53. data/spec/flipper/cloud/dsl_spec.rb +10 -2
  54. data/spec/flipper/cloud/middleware_spec.rb +34 -16
  55. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  56. data/spec/flipper/cloud/telemetry_spec.rb +1 -1
  57. data/spec/flipper/engine_spec.rb +2 -2
  58. data/spec/flipper/expressions/time_spec.rb +16 -0
  59. data/spec/flipper/gates/expression_spec.rb +82 -0
  60. data/spec/flipper/middleware/memoizer_spec.rb +37 -6
  61. data/spec/flipper/poller_spec.rb +347 -4
  62. data/spec/flipper_integration_spec.rb +133 -0
  63. data/spec/flipper_spec.rb +6 -1
  64. data/spec/spec_helper.rb +7 -0
  65. metadata +18 -112
  66. data/lib/flipper/expressions/duration.rb +0 -28
  67. data/spec/flipper/expressions/duration_spec.rb +0 -43
@@ -168,7 +168,7 @@ module Flipper
168
168
  end
169
169
 
170
170
  if interval = response["telemetry-interval"]
171
- self.interval = interval.to_f
171
+ self.interval = interval
172
172
  end
173
173
  end
174
174
  rescue => error
data/lib/flipper/cloud.rb CHANGED
@@ -4,6 +4,7 @@ require "flipper/middleware/memoizer"
4
4
  require "flipper/cloud/configuration"
5
5
  require "flipper/cloud/dsl"
6
6
  require "flipper/cloud/middleware"
7
+ require "flipper/cloud/migrate"
7
8
 
8
9
  module Flipper
9
10
  module Cloud
data/lib/flipper/dsl.rb CHANGED
@@ -10,7 +10,7 @@ module Flipper
10
10
  # Private: What is being used to instrument all the things.
11
11
  attr_reader :instrumenter
12
12
 
13
- def_delegators :@adapter, :memoize=, :memoizing?, :import, :export
13
+ def_delegators :@adapter, :memoize=, :memoizing?, :import, :export, :adapter_stack
14
14
 
15
15
  # Public: Returns a new instance of the DSL.
16
16
  #
@@ -0,0 +1,34 @@
1
+ require "set"
2
+
3
+ module Flipper
4
+ module Expressions
5
+ class FeatureEnabled
6
+ EVALUATING_KEY = :flipper_evaluating_features
7
+
8
+ def self.call(feature_name, context:)
9
+ evaluating = Thread.current[EVALUATING_KEY] ||= Set.new
10
+ feature_name = feature_name.to_s
11
+ current_feature = context[:feature_name].to_s
12
+
13
+ # Track the current feature so A -> B -> A is caught
14
+ added_current = evaluating.add?(current_feature)
15
+
16
+ begin
17
+ # Circular dependency: return false to break the cycle
18
+ return false if evaluating.include?(feature_name)
19
+
20
+ evaluating.add(feature_name)
21
+ actor = context[:actor]
22
+ if actor
23
+ Flipper.enabled?(feature_name, actor)
24
+ else
25
+ Flipper.enabled?(feature_name)
26
+ end
27
+ ensure
28
+ evaluating.delete(feature_name)
29
+ evaluating.delete(current_feature) if added_current
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,8 +1,15 @@
1
+ require "time"
2
+
1
3
  module Flipper
2
4
  module Expressions
3
5
  class Time
4
6
  def self.call(value)
5
- ::Time.parse(value)
7
+ case value
8
+ when Numeric
9
+ ::Time.at(value).utc
10
+ else
11
+ ::Time.parse(value).utc
12
+ end
6
13
  end
7
14
  end
8
15
  end
@@ -30,10 +30,10 @@ module Flipper
30
30
  expression = Flipper::Expression.build(data)
31
31
 
32
32
  if context.actors.nil? || context.actors.empty?
33
- !!expression.evaluate(feature_name: context.feature_name, properties: DEFAULT_PROPERTIES)
33
+ !!expression.evaluate(feature_name: context.feature_name, properties: DEFAULT_PROPERTIES, actor: nil)
34
34
  else
35
35
  context.actors.any? do |actor|
36
- !!expression.evaluate(feature_name: context.feature_name, properties: properties(actor))
36
+ !!expression.evaluate(feature_name: context.feature_name, properties: properties(actor), actor: actor)
37
37
  end
38
38
  end
39
39
  end
@@ -2,6 +2,7 @@ require 'logger'
2
2
  require 'concurrent/utility/monotonic_time'
3
3
  require 'concurrent/map'
4
4
  require 'concurrent/atomic/atomic_fixnum'
5
+ require 'concurrent/atomic/atomic_boolean'
5
6
 
6
7
  module Flipper
7
8
  class Poller
@@ -17,7 +18,10 @@ module Flipper
17
18
  end
18
19
 
19
20
  def self.reset
20
- instances.each {|_, instance| instance.stop }.clear
21
+ instances.each do |_, instance|
22
+ instance.stop
23
+ instance.thread&.join(1)
24
+ end.clear
21
25
  end
22
26
 
23
27
  MINIMUM_POLL_INTERVAL = 10
@@ -28,14 +32,12 @@ module Flipper
28
32
  @mutex = Mutex.new
29
33
  @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
30
34
  @remote_adapter = options.fetch(:remote_adapter)
31
- @interval = options.fetch(:interval, 10).to_f
32
35
  @last_synced_at = Concurrent::AtomicFixnum.new(0)
33
36
  @adapter = Adapters::Memory.new(nil, threadsafe: true)
37
+ @shutdown_requested = Concurrent::AtomicBoolean.new(false)
34
38
 
35
- if @interval < MINIMUM_POLL_INTERVAL
36
- warn "Flipper::Cloud poll interval must be greater than or equal to #{MINIMUM_POLL_INTERVAL} but was #{@interval}. Setting @interval to #{MINIMUM_POLL_INTERVAL}."
37
- @interval = MINIMUM_POLL_INTERVAL
38
- end
39
+ self.interval = options.fetch(:interval, 10)
40
+ @initial_interval = @interval
39
41
 
40
42
  @start_automatically = options.fetch(:start_automatically, true)
41
43
 
@@ -46,6 +48,7 @@ module Flipper
46
48
 
47
49
  def start
48
50
  reset if forked?
51
+ return if @shutdown_requested.true?
49
52
  ensure_worker_running
50
53
  end
51
54
 
@@ -72,15 +75,33 @@ module Flipper
72
75
 
73
76
  def sync
74
77
  @instrumenter.instrument("poller.#{InstrumentationNamespace}", operation: :poll) do
75
- @adapter.import @remote_adapter
76
- @last_synced_at.update { |time| Concurrent.monotonic_time }
78
+ begin
79
+ @adapter.import @remote_adapter
80
+ @last_synced_at.update { |time| Concurrent.monotonic_time }
81
+ ensure
82
+ apply_response_headers
83
+ end
84
+ end
85
+ end
86
+
87
+ # Internal: Sets the interval in seconds for how often to poll.
88
+ def interval=(value)
89
+ requested_interval = Flipper::Typecast.to_float(value)
90
+ new_interval = [requested_interval, MINIMUM_POLL_INTERVAL].max
91
+
92
+ if requested_interval < MINIMUM_POLL_INTERVAL
93
+ warn "Flipper::Cloud poll interval must be greater than or equal to #{MINIMUM_POLL_INTERVAL} but was #{requested_interval}. Setting interval to #{MINIMUM_POLL_INTERVAL}."
77
94
  end
95
+
96
+ @interval = new_interval
78
97
  end
79
98
 
80
99
  private
81
100
 
82
101
  def jitter
83
- rand
102
+ # Cap jitter at 30 seconds to prevent excessive delays for large intervals
103
+ max_jitter = [interval * 0.1, 30].min
104
+ rand * max_jitter
84
105
  end
85
106
 
86
107
  def forked?
@@ -98,6 +119,7 @@ module Flipper
98
119
  begin
99
120
  return if thread_alive?
100
121
  @thread = Thread.new { run }
122
+ @thread&.report_on_exception = false
101
123
  @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
102
124
  operation: :thread_start,
103
125
  })
@@ -112,7 +134,28 @@ module Flipper
112
134
 
113
135
  def reset
114
136
  @pid = Process.pid
137
+ @shutdown_requested.make_false
115
138
  mutex.unlock if mutex.locked?
116
139
  end
140
+
141
+ def apply_response_headers
142
+ return unless @remote_adapter.respond_to?(:last_get_all_response)
143
+
144
+ if response = @remote_adapter.last_get_all_response
145
+ # shutdown based on response header
146
+ if Flipper::Typecast.to_boolean(response["poll-shutdown"])
147
+ @shutdown_requested.make_true
148
+ @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
149
+ operation: :shutdown_requested,
150
+ })
151
+ stop
152
+ end
153
+
154
+ # update interval based on response header
155
+ if interval = response["poll-interval"]
156
+ self.interval = [Flipper::Typecast.to_float(interval), @initial_interval].max
157
+ end
158
+ end
159
+ end
117
160
  end
118
161
  end
@@ -1,5 +1,5 @@
1
1
  module Flipper
2
- VERSION = '1.3.5'.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
data/lib/flipper.rb CHANGED
@@ -64,7 +64,7 @@ module Flipper
64
64
  :enable_percentage_of_actors, :disable_percentage_of_actors,
65
65
  :enable_percentage_of_time, :disable_percentage_of_time,
66
66
  :features, :feature, :[], :preload, :preload_all,
67
- :adapter, :add, :exist?, :remove, :import, :export,
67
+ :adapter, :adapter_stack, :add, :exist?, :remove, :import, :export,
68
68
  :memoize=, :memoizing?, :read_only?,
69
69
  :sync, :sync_secret # For Flipper::Cloud. Will error for OSS Flipper.
70
70
 
@@ -100,6 +100,22 @@ module Flipper
100
100
  Expression.build({ Random: max })
101
101
  end
102
102
 
103
+ def now
104
+ Expression.build({ Now: [] })
105
+ end
106
+
107
+ def time(value)
108
+ Expression.build({ Time: value })
109
+ end
110
+
111
+ def feature_enabled(name)
112
+ Expression.build({ FeatureEnabled: name })
113
+ end
114
+
115
+ def feature_disabled(name)
116
+ feature_enabled(name).eq(false)
117
+ end
118
+
103
119
  # Public: Use this to register a group by name.
104
120
  #
105
121
  # name - The Symbol name of the group.
@@ -143,4 +143,24 @@ RSpec.describe Flipper::Adapter do
143
143
  expect(export.features.dig("search", :boolean)).to eq("true")
144
144
  end
145
145
  end
146
+
147
+ describe "#adapter_stack" do
148
+ it "returns the adapter name for a simple adapter" do
149
+ adapter = Flipper::Adapters::Memory.new
150
+ expect(adapter.adapter_stack).to eq("memory")
151
+ end
152
+
153
+ it "returns the chain for wrapped adapters" do
154
+ memory = Flipper::Adapters::Memory.new
155
+ memoizable = Flipper::Adapters::Memoizable.new(memory)
156
+ expect(memoizable.adapter_stack).to eq("memoizable -> memory")
157
+ end
158
+
159
+ it "returns the chain for deeply nested adapters" do
160
+ memory = Flipper::Adapters::Memory.new
161
+ strict = Flipper::Adapters::Strict.new(memory)
162
+ memoizable = Flipper::Adapters::Memoizable.new(strict)
163
+ expect(memoizable.adapter_stack).to eq("memoizable -> strict -> memory")
164
+ end
165
+ end
146
166
  end
@@ -15,6 +15,61 @@ RSpec.describe Flipper::Adapters::ActorLimit do
15
15
  feature.enable Flipper::Actor.new("User;6")
16
16
  }.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
17
17
  end
18
+
19
+ it "allows exceeding limit when in sync mode" do
20
+ 5.times { |i| feature.enable Flipper::Actor.new("User;#{i}") }
21
+
22
+ described_class.with_sync_mode do
23
+ expect {
24
+ feature.enable Flipper::Actor.new("User;6")
25
+ }.not_to raise_error
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ describe '.sync_mode' do
32
+ after do
33
+ described_class.sync_mode = nil
34
+ end
35
+
36
+ it 'defaults to nil/falsy' do
37
+ expect(described_class.sync_mode).to be_falsy
38
+ end
39
+
40
+ it 'can be set and read' do
41
+ described_class.sync_mode = true
42
+ expect(described_class.sync_mode).to be true
43
+ end
44
+ end
45
+
46
+ describe '.with_sync_mode' do
47
+ after do
48
+ described_class.sync_mode = nil
49
+ end
50
+
51
+ it 'sets sync_mode to true within block' do
52
+ described_class.with_sync_mode do
53
+ expect(described_class.sync_mode).to be true
54
+ end
55
+ end
56
+
57
+ it 'restores previous value after block' do
58
+ expect(described_class.sync_mode).to be_falsy
59
+ described_class.with_sync_mode { }
60
+ expect(described_class.sync_mode).to be_falsy
61
+ end
62
+
63
+ it 'restores previous value even on exception' do
64
+ expect {
65
+ described_class.with_sync_mode { raise "boom" }
66
+ }.to raise_error("boom")
67
+ expect(described_class.sync_mode).to be_falsy
68
+ end
69
+
70
+ it 'returns the block result' do
71
+ result = described_class.with_sync_mode { 42 }
72
+ expect(result).to eq(42)
18
73
  end
19
74
  end
20
75
  end
@@ -66,4 +66,17 @@ RSpec.describe Flipper::Adapters::DualWrite do
66
66
  expect(remote_adapter.count(:disable)).to be(1)
67
67
  expect(local_adapter.count(:disable)).to be(1)
68
68
  end
69
+
70
+ describe '#adapter_stack' do
71
+ it 'returns the tree representation' do
72
+ expect(subject.adapter_stack).to eq("dual_write(local: operation_logger -> memory, remote: operation_logger -> memory)")
73
+ end
74
+
75
+ it 'shows nested adapters in the tree' do
76
+ memory = Flipper::Adapters::Memory.new
77
+ strict = Flipper::Adapters::Strict.new(Flipper::Adapters::Memory.new)
78
+ adapter = described_class.new(memory, strict)
79
+ expect(adapter.adapter_stack).to eq("dual_write(local: memory, remote: strict -> memory)")
80
+ end
81
+ end
69
82
  end
@@ -126,4 +126,16 @@ RSpec.describe Flipper::Adapters::Failover do
126
126
  end
127
127
  end
128
128
  end
129
+
130
+ describe '#adapter_stack' do
131
+ it 'returns the tree representation' do
132
+ expect(subject.adapter_stack).to eq("failover(primary: memory, secondary: memory)")
133
+ end
134
+
135
+ it 'shows nested adapters in the tree' do
136
+ strict_primary = Flipper::Adapters::Strict.new(primary)
137
+ adapter = described_class.new(strict_primary, secondary)
138
+ expect(adapter.adapter_stack).to eq("failover(primary: strict -> memory, secondary: memory)")
139
+ end
140
+ end
129
141
  end
@@ -185,6 +185,246 @@ RSpec.describe Flipper::Adapters::Http do
185
185
  adapter.get_all
186
186
  }.to raise_error(Flipper::Adapters::Http::Error)
187
187
  end
188
+
189
+ it "stores ETag and sends If-None-Match on subsequent requests" do
190
+ features_response = {
191
+ "features" => [
192
+ {
193
+ "key" => "search",
194
+ "gates" => [
195
+ {"key" => "boolean", "value" => true}
196
+ ]
197
+ }
198
+ ]
199
+ }
200
+
201
+ # First request - server returns ETag
202
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
203
+ .to_return(
204
+ status: 200,
205
+ body: JSON.generate(features_response),
206
+ headers: { 'ETag' => '"abc123"' }
207
+ )
208
+
209
+ adapter = described_class.new(url: 'http://app.com/flipper')
210
+ result = adapter.get_all
211
+
212
+ expect(result).to have_key("search")
213
+
214
+ # Second request - should send If-None-Match header
215
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
216
+ .with(headers: { 'If-None-Match' => '"abc123"' })
217
+ .to_return(
218
+ status: 200,
219
+ body: JSON.generate(features_response),
220
+ headers: { 'ETag' => '"abc123"' }
221
+ )
222
+
223
+ etag_result = adapter.get_all
224
+
225
+ expect(etag_result).to eq(result)
226
+ expect(etag_result).to have_key("search")
227
+ expect(
228
+ a_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
229
+ .with(headers: { 'If-None-Match' => '"abc123"' })
230
+ ).to have_been_made.once
231
+ end
232
+
233
+ it "returns cached result when server returns 304 Not Modified" do
234
+ features_response = {
235
+ "features" => [
236
+ {
237
+ "key" => "search",
238
+ "gates" => [
239
+ {"key" => "boolean", "value" => true}
240
+ ]
241
+ }
242
+ ]
243
+ }
244
+
245
+ # First request - server returns ETag
246
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
247
+ .to_return(
248
+ status: 200,
249
+ body: JSON.generate(features_response),
250
+ headers: { 'ETag' => '"abc123"' }
251
+ )
252
+
253
+ adapter = described_class.new(url: 'http://app.com/flipper')
254
+ first_result = adapter.get_all
255
+
256
+ expect(first_result).to have_key("search")
257
+
258
+ # Second request - server returns 304
259
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
260
+ .with(headers: { 'If-None-Match' => '"abc123"' })
261
+ .to_return(status: 304, headers: { 'ETag' => '"abc123"' })
262
+
263
+ second_result = adapter.get_all
264
+
265
+ # Should return the cached result
266
+ expect(second_result).to eq(first_result)
267
+ expect(second_result).to have_key("search")
268
+ end
269
+
270
+ it "raises error when 304 received without cached result" do
271
+ # Server returns 304 without any prior request
272
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
273
+ .to_return(status: 304)
274
+
275
+ adapter = described_class.new(url: 'http://app.com/flipper')
276
+ expect {
277
+ adapter.get_all
278
+ }.to raise_error(Flipper::Adapters::Http::Error)
279
+ end
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
+
370
+ it "does not send If-None-Match for other endpoints" do
371
+ stub_request(:get, "http://app.com/flipper/features/search")
372
+ .to_return(status: 404)
373
+
374
+ adapter = described_class.new(url: 'http://app.com/flipper')
375
+ adapter.get(flipper[:search])
376
+
377
+ # Verify no If-None-Match header was sent
378
+ expect(
379
+ a_request(:get, "http://app.com/flipper/features/search")
380
+ .with { |req| req.headers['If-None-Match'].nil? }
381
+ ).to have_been_made.once
382
+ end
383
+
384
+ it "stores last get_all response for poll-shutdown header checking" do
385
+ features_response = {
386
+ "features" => [
387
+ {
388
+ "key" => "search",
389
+ "gates" => [
390
+ {"key" => "boolean", "value" => true}
391
+ ]
392
+ }
393
+ ]
394
+ }
395
+
396
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
397
+ .to_return(
398
+ status: 200,
399
+ body: JSON.generate(features_response),
400
+ headers: { 'poll-shutdown' => 'true' }
401
+ )
402
+
403
+ adapter = described_class.new(url: 'http://app.com/flipper')
404
+ adapter.get_all
405
+
406
+ expect(adapter.last_get_all_response).not_to be_nil
407
+ expect(adapter.last_get_all_response['poll-shutdown']).to eq('true')
408
+ end
409
+
410
+ it "stores last get_all response even for error responses" do
411
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
412
+ .to_return(
413
+ status: 404,
414
+ body: JSON.generate({ error: "Not found" }),
415
+ headers: { 'poll-shutdown' => 'true' }
416
+ )
417
+
418
+ adapter = described_class.new(url: 'http://app.com/flipper')
419
+
420
+ expect {
421
+ adapter.get_all
422
+ }.to raise_error(Flipper::Adapters::Http::Error)
423
+
424
+ # Even though it raised an error, response should be stored
425
+ expect(adapter.last_get_all_response).not_to be_nil
426
+ expect(adapter.last_get_all_response['poll-shutdown']).to eq('true')
427
+ end
188
428
  end
189
429
 
190
430
  describe "#features" do