Skip to content

Commit 470203f

Browse files
alexcwattixti
andauthored
Cache header normalization to reduce object allocation (#789)
Co-authored-by: Alexey Zapparov <[email protected]>
1 parent b74b16c commit 470203f

File tree

9 files changed

+161
-70
lines changed

9 files changed

+161
-70
lines changed

.rubocop/rspec.yml

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
RSpec/ExampleLength:
22
CountAsOne:
33
- array
4+
- hash
45
- heredoc
56
- method_call
7+
8+
RSpec/MultipleExpectations:
9+
Max: 5

.rubocop_todo.yml

-24
Original file line numberDiff line numberDiff line change
@@ -267,30 +267,6 @@ RSpec/InstanceVariable:
267267
RSpec/MessageSpies:
268268
EnforcedStyle: receive
269269

270-
# Offense count: 74
271-
# Configuration parameters: Max.
272-
RSpec/MultipleExpectations:
273-
Exclude:
274-
- 'spec/lib/http/client_spec.rb'
275-
- 'spec/lib/http/connection_spec.rb'
276-
- 'spec/lib/http/features/auto_deflate_spec.rb'
277-
- 'spec/lib/http/headers_spec.rb'
278-
- 'spec/lib/http/options/body_spec.rb'
279-
- 'spec/lib/http/options/features_spec.rb'
280-
- 'spec/lib/http/options/form_spec.rb'
281-
- 'spec/lib/http/options/headers_spec.rb'
282-
- 'spec/lib/http/options/json_spec.rb'
283-
- 'spec/lib/http/options/merge_spec.rb'
284-
- 'spec/lib/http/options/proxy_spec.rb'
285-
- 'spec/lib/http/redirector_spec.rb'
286-
- 'spec/lib/http/response/body_spec.rb'
287-
- 'spec/lib/http/response/parser_spec.rb'
288-
- 'spec/lib/http/retriable/delay_calculator_spec.rb'
289-
- 'spec/lib/http/retriable/performer_spec.rb'
290-
- 'spec/lib/http/uri_spec.rb'
291-
- 'spec/lib/http_spec.rb'
292-
- 'spec/support/http_handling_shared.rb'
293-
294270
# Offense count: 9
295271
# Configuration parameters: AllowSubject, Max.
296272
RSpec/MultipleMemoizedHelpers:

Gemfile

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ group :test do
3737

3838
gem "rspec", "~> 3.10"
3939
gem "rspec-its"
40+
gem "rspec-memory"
4041

4142
gem "yardstick"
4243
end

lib/http/headers.rb

+27-40
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
require "http/errors"
66
require "http/headers/mixin"
7+
require "http/headers/normalizer"
78
require "http/headers/known"
89

910
module HTTP
@@ -12,12 +13,32 @@ class Headers
1213
extend Forwardable
1314
include Enumerable
1415

15-
# Matches HTTP header names when in "Canonical-Http-Format"
16-
CANONICAL_NAME_RE = /\A[A-Z][a-z]*(?:-[A-Z][a-z]*)*\z/
16+
class << self
17+
# Coerces given `object` into Headers.
18+
#
19+
# @raise [Error] if object can't be coerced
20+
# @param [#to_hash, #to_h, #to_a] object
21+
# @return [Headers]
22+
def coerce(object)
23+
unless object.is_a? self
24+
object = case
25+
when object.respond_to?(:to_hash) then object.to_hash
26+
when object.respond_to?(:to_h) then object.to_h
27+
when object.respond_to?(:to_a) then object.to_a
28+
else raise Error, "Can't coerce #{object.inspect} to Headers"
29+
end
30+
end
31+
32+
headers = new
33+
object.each { |k, v| headers.add k, v }
34+
headers
35+
end
36+
alias [] coerce
1737

18-
# Matches valid header field name according to RFC.
19-
# @see http://tools.ietf.org/html/rfc7230#section-3.2
20-
COMPLIANT_NAME_RE = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/
38+
def normalizer
39+
@normalizer ||= Headers::Normalizer.new
40+
end
41+
end
2142

2243
# Class constructor.
2344
def initialize
@@ -194,45 +215,11 @@ def merge(other)
194215
dup.tap { |dupped| dupped.merge! other }
195216
end
196217

197-
class << self
198-
# Coerces given `object` into Headers.
199-
#
200-
# @raise [Error] if object can't be coerced
201-
# @param [#to_hash, #to_h, #to_a] object
202-
# @return [Headers]
203-
def coerce(object)
204-
unless object.is_a? self
205-
object = case
206-
when object.respond_to?(:to_hash) then object.to_hash
207-
when object.respond_to?(:to_h) then object.to_h
208-
when object.respond_to?(:to_a) then object.to_a
209-
else raise Error, "Can't coerce #{object.inspect} to Headers"
210-
end
211-
end
212-
213-
headers = new
214-
object.each { |k, v| headers.add k, v }
215-
headers
216-
end
217-
alias [] coerce
218-
end
219-
220218
private
221219

222220
# Transforms `name` to canonical HTTP header capitalization
223-
#
224-
# @param [String] name
225-
# @raise [HeaderError] if normalized name does not
226-
# match {HEADER_NAME_RE}
227-
# @return [String] canonical HTTP header name
228221
def normalize_header(name)
229-
return name if CANONICAL_NAME_RE.match?(name)
230-
231-
normalized = name.split(/[\-_]/).each(&:capitalize!).join("-")
232-
233-
return normalized if COMPLIANT_NAME_RE.match?(normalized)
234-
235-
raise HeaderError, "Invalid HTTP header field name: #{name.inspect}"
222+
self.class.normalizer.call(name)
236223
end
237224

238225
# Ensures there is no new line character in the header value

lib/http/headers/normalizer.rb

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
module HTTP
4+
class Headers
5+
class Normalizer
6+
# Matches HTTP header names when in "Canonical-Http-Format"
7+
CANONICAL_NAME_RE = /\A[A-Z][a-z]*(?:-[A-Z][a-z]*)*\z/
8+
9+
# Matches valid header field name according to RFC.
10+
# @see http://tools.ietf.org/html/rfc7230#section-3.2
11+
COMPLIANT_NAME_RE = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/
12+
13+
NAME_PARTS_SEPARATOR_RE = /[\-_]/
14+
15+
# @private
16+
# Normalized header names cache
17+
class Cache
18+
MAX_SIZE = 200
19+
20+
def initialize
21+
@store = {}
22+
end
23+
24+
def get(key)
25+
@store[key]
26+
end
27+
alias [] get
28+
29+
def set(key, value)
30+
# Maintain cache size
31+
@store.delete(@store.each_key.first) while MAX_SIZE <= @store.size
32+
33+
@store[key] = value
34+
end
35+
alias []= set
36+
end
37+
38+
def initialize
39+
@cache = Cache.new
40+
end
41+
42+
# Transforms `name` to canonical HTTP header capitalization
43+
def call(name)
44+
name = -name.to_s
45+
value = (@cache[name] ||= -normalize_header(name))
46+
47+
value.dup
48+
end
49+
50+
private
51+
52+
# Transforms `name` to canonical HTTP header capitalization
53+
#
54+
# @param [String] name
55+
# @raise [HeaderError] if normalized name does not
56+
# match {COMPLIANT_NAME_RE}
57+
# @return [String] canonical HTTP header name
58+
def normalize_header(name)
59+
return name if CANONICAL_NAME_RE.match?(name)
60+
61+
normalized = name.split(NAME_PARTS_SEPARATOR_RE).each(&:capitalize!).join("-")
62+
63+
return normalized if COMPLIANT_NAME_RE.match?(normalized)
64+
65+
raise HeaderError, "Invalid HTTP header field name: #{name.inspect}"
66+
end
67+
end
68+
end
69+
end
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe HTTP::Headers::Normalizer do
4+
subject(:normalizer) { described_class.new }
5+
6+
include_context RSpec::Memory
7+
8+
describe "#call" do
9+
it "normalizes the header" do
10+
expect(normalizer.call("content_type")).to eq "Content-Type"
11+
end
12+
13+
it "returns a non-frozen string" do
14+
expect(normalizer.call("content_type")).not_to be_frozen
15+
end
16+
17+
it "evicts the oldest item when cache is full" do
18+
max_headers = (1..described_class::Cache::MAX_SIZE).map { |i| "Header#{i}" }
19+
max_headers.each { |header| normalizer.call(header) }
20+
normalizer.call("New-Header")
21+
cache_store = normalizer.instance_variable_get(:@cache).instance_variable_get(:@store)
22+
expect(cache_store.keys).to eq(max_headers[1..] + ["New-Header"])
23+
end
24+
25+
it "retuns mutable strings" do
26+
normalized_headers = Array.new(3) { normalizer.call("content_type") }
27+
28+
expect(normalized_headers)
29+
.to satisfy { |arr| arr.uniq.size == 1 }
30+
.and(satisfy { |arr| arr.map(&:object_id).uniq.size == normalized_headers.size })
31+
.and(satisfy { |arr| arr.none?(&:frozen?) })
32+
end
33+
34+
it "allocates minimal memory for normalization of the same header" do
35+
normalizer.call("accept") # XXX: Ensure normalizer is pre-allocated
36+
37+
# On first call it is expected to allocate during normalization
38+
expect { normalizer.call("content_type") }.to limit_allocations(
39+
Array => 1,
40+
MatchData => 1,
41+
String => 6
42+
)
43+
44+
# On subsequent call it is expected to only allocate copy of a cached string
45+
expect { normalizer.call("content_type") }.to limit_allocations(
46+
Array => 0,
47+
MatchData => 0,
48+
String => 1
49+
)
50+
end
51+
end
52+
end

spec/lib/http/redirector_spec.rb

+6-5
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,13 @@ def redirect_response(status, location, set_cookie = {})
117117
expect(req_cookie).to eq request_cookies.shift
118118
hops.shift
119119
end
120+
120121
expect(res.to_s).to eq "bar"
121-
cookies = res.cookies.cookies.to_h { |c| [c.name, c.value] }
122-
expect(cookies["foo"]).to eq "42"
123-
expect(cookies["bar"]).to eq "53"
124-
expect(cookies["baz"]).to eq "65"
125-
expect(cookies["deleted"]).to eq nil
122+
expect(res.cookies.cookies.to_h { |c| [c.name, c.value] }).to eq({
123+
"foo" => "42",
124+
"bar" => "53",
125+
"baz" => "65"
126+
})
126127
end
127128

128129
it "returns original cookies in response" do

spec/lib/http/retriable/performer_spec.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def response(**options)
199199
end
200200

201201
describe "should_retry option" do
202-
it "decides if the request should be retried" do
202+
it "decides if the request should be retried" do # rubocop:disable RSpec/MultipleExpectations
203203
retry_proc = proc do |req, err, res, attempt|
204204
expect(req).to eq request
205205
if res

spec/spec_helper.rb

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
require "http"
77
require "rspec/its"
8+
require "rspec/memory"
89
require "support/capture_warning"
910
require "support/fakeio"
1011

0 commit comments

Comments
 (0)