Skip to content

Commit 3a0ccef

Browse files
committed
Save etag and send it for get_all
1 parent f256bb8 commit 3a0ccef

3 files changed

Lines changed: 145 additions & 5 deletions

File tree

lib/flipper/adapters/http.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ def initialize(options = {})
2222
write_timeout: options[:write_timeout],
2323
max_retries: options[:max_retries],
2424
debug_output: options[:debug_output])
25+
@last_get_all_etag = nil
26+
@last_get_all_result = nil
2527
end
2628

2729
def get(feature)
@@ -58,9 +60,27 @@ def get_multi(features)
5860
def get_all(cache_bust: false)
5961
path = "/features?exclude_gate_names=true"
6062
path += "&_cb=#{Time.now.to_i}" if cache_bust
61-
response = @client.get(path)
63+
64+
# Pass If-None-Match header if we have an ETag
65+
options = {}
66+
if @last_get_all_etag
67+
options[:headers] = { if_none_match: @last_get_all_etag }
68+
end
69+
70+
response = @client.get(path, options)
71+
72+
# Handle 304 Not Modified - return cached result
73+
if response.is_a?(Net::HTTPNotModified)
74+
return @last_get_all_result if @last_get_all_result
75+
# If we somehow got 304 without a cached result, treat as error
76+
raise Error, response
77+
end
78+
6279
raise Error, response unless response.is_a?(Net::HTTPOK)
6380

81+
# Store ETag from response for future requests
82+
@last_get_all_etag = response['etag'] if response['etag']
83+
6484
parsed_response = response.body.empty? ? {} : Typecast.from_json(response.body)
6585
parsed_features = parsed_response['features'] || []
6686
gates_by_key = parsed_features.each_with_object({}) do |parsed_feature, hash|
@@ -73,6 +93,9 @@ def get_all(cache_bust: false)
7393
feature = Feature.new(key, self)
7494
result[feature.key] = result_for_feature(feature, gates_by_key[feature.key])
7595
end
96+
97+
# Cache the result for 304 responses
98+
@last_get_all_result = result
7699
result
77100
end
78101

lib/flipper/adapters/http/client.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,19 @@ def initialize(options = {})
4545
end
4646

4747
def add_header(key, value)
48-
key = key.to_s.downcase.gsub('_'.freeze, '-'.freeze).freeze
49-
@headers[key] = value
48+
@headers[normalize_header_key(key)] = value
5049
end
5150

52-
def get(path)
53-
perform Net::HTTP::Get, path, @headers
51+
def get(path, options = {})
52+
headers = @headers.dup
53+
54+
if options[:headers]
55+
options[:headers].each do |key, value|
56+
headers[normalize_header_key(key)] = value
57+
end
58+
end
59+
60+
perform Net::HTTP::Get, path, headers
5461
end
5562

5663
def post(path, body = nil)
@@ -125,6 +132,10 @@ def build_request(http_method, uri, headers, options)
125132
def client_frameworks
126133
CLIENT_FRAMEWORKS.transform_values { |detect| detect.call rescue nil }.compact
127134
end
135+
136+
def normalize_header_key(key)
137+
key.to_s.downcase.gsub('_'.freeze, '-'.freeze)
138+
end
128139
end
129140
end
130141
end

spec/flipper/adapters/http_spec.rb

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,112 @@
185185
adapter.get_all
186186
}.to raise_error(Flipper::Adapters::Http::Error)
187187
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 "does not send If-None-Match for other endpoints" do
282+
stub_request(:get, "http://app.com/flipper/features/search")
283+
.to_return(status: 404)
284+
285+
adapter = described_class.new(url: 'http://app.com/flipper')
286+
adapter.get(flipper[:search])
287+
288+
# Verify no If-None-Match header was sent
289+
expect(
290+
a_request(:get, "http://app.com/flipper/features/search")
291+
.with { |req| req.headers['If-None-Match'].nil? }
292+
).to have_been_made.once
293+
end
188294
end
189295

190296
describe "#features" do

0 commit comments

Comments
 (0)