flipper 1.3.4 → 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.
Files changed (66) 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 +3 -2
  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/instrumentation/log_subscriber.rb +1 -1
  40. data/lib/flipper/poller.rb +49 -8
  41. data/lib/flipper/version.rb +1 -1
  42. data/lib/flipper.rb +17 -1
  43. data/spec/flipper/adapter_spec.rb +20 -0
  44. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  45. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  46. data/spec/flipper/adapters/failover_spec.rb +12 -0
  47. data/spec/flipper/adapters/http_spec.rb +240 -0
  48. data/spec/flipper/adapters/strict_spec.rb +62 -4
  49. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  50. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  51. data/spec/flipper/adapters/sync_spec.rb +13 -0
  52. data/spec/flipper/cli_spec.rb +51 -0
  53. data/spec/flipper/cloud/middleware_spec.rb +1 -1
  54. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  55. data/spec/flipper/cloud/telemetry_spec.rb +1 -1
  56. data/spec/flipper/engine_spec.rb +2 -2
  57. data/spec/flipper/expressions/time_spec.rb +16 -0
  58. data/spec/flipper/gates/expression_spec.rb +82 -0
  59. data/spec/flipper/middleware/memoizer_spec.rb +37 -6
  60. data/spec/flipper/poller_spec.rb +347 -4
  61. data/spec/flipper_integration_spec.rb +133 -0
  62. data/spec/flipper_spec.rb +5 -0
  63. data/spec/spec_helper.rb +7 -0
  64. metadata +18 -112
  65. data/lib/flipper/expressions/duration.rb +0 -28
  66. data/spec/flipper/expressions/duration_spec.rb +0 -43
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
@@ -56,7 +56,7 @@ module Flipper
56
56
  operation = event.payload[:operation]
57
57
  result = event.payload[:result]
58
58
 
59
- description = 'Flipper '
59
+ description = String.new('Flipper ')
60
60
  description << "feature(#{feature_name}) " unless feature_name.nil?
61
61
  description << "adapter(#{adapter_name}) "
62
62
  description << "#{operation} "
@@ -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,9 +75,25 @@ 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
@@ -98,6 +117,7 @@ module Flipper
98
117
  begin
99
118
  return if thread_alive?
100
119
  @thread = Thread.new { run }
120
+ @thread&.report_on_exception = false
101
121
  @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
102
122
  operation: :thread_start,
103
123
  })
@@ -112,7 +132,28 @@ module Flipper
112
132
 
113
133
  def reset
114
134
  @pid = Process.pid
135
+ @shutdown_requested.make_false
115
136
  mutex.unlock if mutex.locked?
116
137
  end
138
+
139
+ def apply_response_headers
140
+ return unless @remote_adapter.respond_to?(:last_get_all_response)
141
+
142
+ if response = @remote_adapter.last_get_all_response
143
+ # shutdown based on response header
144
+ if Flipper::Typecast.to_boolean(response["poll-shutdown"])
145
+ @shutdown_requested.make_true
146
+ @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
147
+ operation: :shutdown_requested,
148
+ })
149
+ stop
150
+ end
151
+
152
+ # update interval based on response header
153
+ if interval = response["poll-interval"]
154
+ self.interval = [Flipper::Typecast.to_float(interval), @initial_interval].max
155
+ end
156
+ end
157
+ end
117
158
  end
118
159
  end
@@ -1,5 +1,5 @@
1
1
  module Flipper
2
- VERSION = '1.3.4'.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
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