http 5.2.0 → 5.3.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.
- checksums.yaml +4 -4
- data/.rubocop/rspec.yml +9 -0
- data/.rubocop_todo.yml +45 -32
- data/CHANGELOG.md +27 -1
- data/Gemfile +1 -0
- data/http.gemspec +2 -2
- data/lib/http/base64.rb +12 -0
- data/lib/http/chainable.rb +27 -3
- data/lib/http/connection.rb +4 -2
- data/lib/http/errors.rb +16 -0
- data/lib/http/feature.rb +2 -1
- data/lib/http/features/raise_error.rb +22 -0
- data/lib/http/headers/normalizer.rb +69 -0
- data/lib/http/headers.rb +26 -40
- data/lib/http/request/writer.rb +2 -1
- data/lib/http/request.rb +3 -2
- data/lib/http/retriable/client.rb +37 -0
- data/lib/http/retriable/delay_calculator.rb +64 -0
- data/lib/http/retriable/errors.rb +14 -0
- data/lib/http/retriable/performer.rb +153 -0
- data/lib/http/version.rb +1 -1
- data/lib/http.rb +1 -0
- data/spec/lib/http/features/raise_error_spec.rb +62 -0
- data/spec/lib/http/headers/normalizer_spec.rb +52 -0
- data/spec/lib/http/redirector_spec.rb +6 -5
- data/spec/lib/http/retriable/delay_calculator_spec.rb +69 -0
- data/spec/lib/http/retriable/performer_spec.rb +302 -0
- data/spec/lib/http_spec.rb +29 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/support/dummy_server/servlet.rb +13 -0
- data/spec/support/dummy_server.rb +2 -1
- metadata +20 -18
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HTTP
|
|
4
|
+
module Retriable
|
|
5
|
+
# @api private
|
|
6
|
+
class DelayCalculator
|
|
7
|
+
def initialize(opts)
|
|
8
|
+
@max_delay = opts.fetch(:max_delay, Float::MAX).to_f
|
|
9
|
+
if (delay = opts[:delay]).respond_to?(:call)
|
|
10
|
+
@delay_proc = opts.fetch(:delay)
|
|
11
|
+
else
|
|
12
|
+
@delay = delay
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(iteration, response)
|
|
17
|
+
delay = if response && (retry_header = response.headers["Retry-After"])
|
|
18
|
+
delay_from_retry_header(retry_header)
|
|
19
|
+
else
|
|
20
|
+
calculate_delay_from_iteration(iteration)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ensure_dealy_in_bounds(delay)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
RFC2822_DATE_REGEX = /^
|
|
27
|
+
(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat),\s+
|
|
28
|
+
(?:0[1-9]|[1-2]?[0-9]|3[01])\s+
|
|
29
|
+
(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+
|
|
30
|
+
(?:19[0-9]{2}|[2-9][0-9]{3})\s+
|
|
31
|
+
(?:2[0-3]|[0-1][0-9]):(?:[0-5][0-9]):(?:60|[0-5][0-9])\s+
|
|
32
|
+
GMT
|
|
33
|
+
$/x
|
|
34
|
+
|
|
35
|
+
# Spec for Retry-After header
|
|
36
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
|
37
|
+
def delay_from_retry_header(value)
|
|
38
|
+
value = value.to_s.strip
|
|
39
|
+
|
|
40
|
+
case value
|
|
41
|
+
when RFC2822_DATE_REGEX then DateTime.rfc2822(value).to_time - Time.now.utc
|
|
42
|
+
when /^\d+$/ then value.to_i
|
|
43
|
+
else 0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def calculate_delay_from_iteration(iteration)
|
|
48
|
+
if @delay_proc
|
|
49
|
+
@delay_proc.call(iteration)
|
|
50
|
+
elsif @delay
|
|
51
|
+
@delay
|
|
52
|
+
else
|
|
53
|
+
delay = (2**(iteration - 1)) - 1
|
|
54
|
+
delay_noise = rand
|
|
55
|
+
delay + delay_noise
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ensure_dealy_in_bounds(delay)
|
|
60
|
+
delay.clamp(0, @max_delay)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "http/retriable/errors"
|
|
5
|
+
require "http/retriable/delay_calculator"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module HTTP
|
|
9
|
+
module Retriable
|
|
10
|
+
# Request performing watchdog.
|
|
11
|
+
# @api private
|
|
12
|
+
class Performer
|
|
13
|
+
# Exceptions we should retry
|
|
14
|
+
RETRIABLE_ERRORS = [
|
|
15
|
+
HTTP::TimeoutError,
|
|
16
|
+
HTTP::ConnectionError,
|
|
17
|
+
IO::EAGAINWaitReadable,
|
|
18
|
+
Errno::ECONNRESET,
|
|
19
|
+
Errno::ECONNREFUSED,
|
|
20
|
+
Errno::EHOSTUNREACH,
|
|
21
|
+
OpenSSL::SSL::SSLError,
|
|
22
|
+
EOFError,
|
|
23
|
+
IOError
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# @param [Hash] opts
|
|
27
|
+
# @option opts [#to_i] :tries (5)
|
|
28
|
+
# @option opts [#call, #to_i] :delay (DELAY_PROC)
|
|
29
|
+
# @option opts [Array(Exception)] :exceptions (RETRIABLE_ERRORS)
|
|
30
|
+
# @option opts [Array(#to_i)] :retry_statuses
|
|
31
|
+
# @option opts [#call] :on_retry
|
|
32
|
+
# @option opts [#to_f] :max_delay (Float::MAX)
|
|
33
|
+
# @option opts [#call] :should_retry
|
|
34
|
+
def initialize(opts)
|
|
35
|
+
@exception_classes = opts.fetch(:exceptions, RETRIABLE_ERRORS)
|
|
36
|
+
@retry_statuses = opts[:retry_statuses]
|
|
37
|
+
@tries = opts.fetch(:tries, 5).to_i
|
|
38
|
+
@on_retry = opts.fetch(:on_retry, ->(*) {})
|
|
39
|
+
@should_retry_proc = opts[:should_retry]
|
|
40
|
+
@delay_calculator = DelayCalculator.new(opts)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Watches request/response execution.
|
|
44
|
+
#
|
|
45
|
+
# If any of {RETRIABLE_ERRORS} occur or response status is `5xx`, retries
|
|
46
|
+
# up to `:tries` amount of times. Sleeps for amount of seconds calculated
|
|
47
|
+
# with `:delay` proc before each retry.
|
|
48
|
+
#
|
|
49
|
+
# @see #initialize
|
|
50
|
+
# @api private
|
|
51
|
+
def perform(client, req, &block)
|
|
52
|
+
1.upto(Float::INFINITY) do |attempt| # infinite loop with index
|
|
53
|
+
err, res = try_request(&block)
|
|
54
|
+
|
|
55
|
+
if retry_request?(req, err, res, attempt)
|
|
56
|
+
begin
|
|
57
|
+
wait_for_retry_or_raise(req, err, res, attempt)
|
|
58
|
+
ensure
|
|
59
|
+
# Some servers support Keep-Alive on any response. Thus we should
|
|
60
|
+
# flush response before retry, to avoid state error (when socket
|
|
61
|
+
# has pending response data and we try to write new request).
|
|
62
|
+
# Alternatively, as we don't need response body here at all, we
|
|
63
|
+
# are going to close client, effectivle closing underlying socket
|
|
64
|
+
# and resetting client's state.
|
|
65
|
+
client.close
|
|
66
|
+
end
|
|
67
|
+
elsif err
|
|
68
|
+
client.close
|
|
69
|
+
raise err
|
|
70
|
+
elsif res
|
|
71
|
+
return res
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def calculate_delay(iteration, response)
|
|
77
|
+
@delay_calculator.call(iteration, response)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# rubocop:disable Lint/RescueException
|
|
83
|
+
def try_request
|
|
84
|
+
err, res = nil
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
res = yield
|
|
88
|
+
rescue Exception => e
|
|
89
|
+
err = e
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
[err, res]
|
|
93
|
+
end
|
|
94
|
+
# rubocop:enable Lint/RescueException
|
|
95
|
+
|
|
96
|
+
def retry_request?(req, err, res, attempt)
|
|
97
|
+
if @should_retry_proc
|
|
98
|
+
@should_retry_proc.call(req, err, res, attempt)
|
|
99
|
+
elsif err
|
|
100
|
+
retry_exception?(err)
|
|
101
|
+
else
|
|
102
|
+
retry_response?(res)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def retry_exception?(err)
|
|
107
|
+
@exception_classes.any? { |e| err.is_a?(e) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def retry_response?(res)
|
|
111
|
+
return false unless @retry_statuses
|
|
112
|
+
|
|
113
|
+
response_status = res.status.to_i
|
|
114
|
+
retry_matchers = [@retry_statuses].flatten
|
|
115
|
+
|
|
116
|
+
retry_matchers.any? do |matcher|
|
|
117
|
+
case matcher
|
|
118
|
+
when Range then matcher.cover?(response_status)
|
|
119
|
+
when Numeric then matcher == response_status
|
|
120
|
+
else matcher.call(response_status)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def wait_for_retry_or_raise(req, err, res, attempt)
|
|
126
|
+
if attempt < @tries
|
|
127
|
+
@on_retry.call(req, err, res)
|
|
128
|
+
sleep calculate_delay(attempt, res)
|
|
129
|
+
else
|
|
130
|
+
res&.flush
|
|
131
|
+
raise out_of_retries_error(req, res, err)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Builds OutOfRetriesError
|
|
136
|
+
#
|
|
137
|
+
# @param request [HTTP::Request]
|
|
138
|
+
# @param status [HTTP::Response, nil]
|
|
139
|
+
# @param exception [Exception, nil]
|
|
140
|
+
def out_of_retries_error(request, response, exception)
|
|
141
|
+
message = "#{request.verb.to_s.upcase} <#{request.uri}> failed"
|
|
142
|
+
|
|
143
|
+
message += " with #{response.status}" if response
|
|
144
|
+
message += ":#{exception}" if exception
|
|
145
|
+
|
|
146
|
+
HTTP::OutOfRetriesError.new(message).tap do |ex|
|
|
147
|
+
ex.cause = exception
|
|
148
|
+
ex.response = response
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/http/version.rb
CHANGED
data/lib/http.rb
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe HTTP::Features::RaiseError do
|
|
4
|
+
subject(:feature) { described_class.new(ignore: ignore) }
|
|
5
|
+
|
|
6
|
+
let(:connection) { double }
|
|
7
|
+
let(:status) { 200 }
|
|
8
|
+
let(:ignore) { [] }
|
|
9
|
+
|
|
10
|
+
describe "#wrap_response" do
|
|
11
|
+
subject(:result) { feature.wrap_response(response) }
|
|
12
|
+
|
|
13
|
+
let(:response) do
|
|
14
|
+
HTTP::Response.new(
|
|
15
|
+
version: "1.1",
|
|
16
|
+
status: status,
|
|
17
|
+
headers: {},
|
|
18
|
+
connection: connection,
|
|
19
|
+
request: HTTP::Request.new(verb: :get, uri: "https://example.com")
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
context "when status is 200" do
|
|
24
|
+
it "returns original request" do
|
|
25
|
+
expect(result).to be response
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
context "when status is 399" do
|
|
30
|
+
let(:status) { 399 }
|
|
31
|
+
|
|
32
|
+
it "returns original request" do
|
|
33
|
+
expect(result).to be response
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
context "when status is 400" do
|
|
38
|
+
let(:status) { 400 }
|
|
39
|
+
|
|
40
|
+
it "raises" do
|
|
41
|
+
expect { result }.to raise_error(HTTP::StatusError, "Unexpected status code 400")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
context "when status is 599" do
|
|
46
|
+
let(:status) { 599 }
|
|
47
|
+
|
|
48
|
+
it "raises" do
|
|
49
|
+
expect { result }.to raise_error(HTTP::StatusError, "Unexpected status code 599")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
context "when error status is ignored" do
|
|
54
|
+
let(:status) { 500 }
|
|
55
|
+
let(:ignore) { [500] }
|
|
56
|
+
|
|
57
|
+
it "returns original request" do
|
|
58
|
+
expect(result).to be response
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -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
|
|
@@ -115,12 +115,13 @@ RSpec.describe HTTP::Redirector do
|
|
|
115
115
|
expect(req_cookie).to eq request_cookies.shift
|
|
116
116
|
hops.shift
|
|
117
117
|
end
|
|
118
|
+
|
|
118
119
|
expect(res.to_s).to eq "bar"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
expect(res.cookies.cookies.to_h { |c| [c.name, c.value] }).to eq({
|
|
121
|
+
"foo" => "42",
|
|
122
|
+
"bar" => "53",
|
|
123
|
+
"baz" => "65"
|
|
124
|
+
})
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
it "returns original cookies in response" do
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe HTTP::Retriable::DelayCalculator do
|
|
4
|
+
let(:response) do
|
|
5
|
+
HTTP::Response.new(
|
|
6
|
+
status: 200,
|
|
7
|
+
version: "1.1",
|
|
8
|
+
headers: {},
|
|
9
|
+
body: "Hello world!",
|
|
10
|
+
request: HTTP::Request.new(verb: :get, uri: "http://example.com")
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call_delay(iterations, **options)
|
|
15
|
+
described_class.new(options).call(iterations, response)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call_retry_header(value, **options)
|
|
19
|
+
response.headers["Retry-After"] = value
|
|
20
|
+
described_class.new(options).call(rand(1...100), response)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "prevents negative sleep time" do
|
|
24
|
+
expect(call_delay(20, delay: -20)).to eq 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "backs off exponentially" do
|
|
28
|
+
expect(call_delay(1)).to be_between 0, 1
|
|
29
|
+
expect(call_delay(2)).to be_between 1, 2
|
|
30
|
+
expect(call_delay(3)).to be_between 3, 4
|
|
31
|
+
expect(call_delay(4)).to be_between 7, 8
|
|
32
|
+
expect(call_delay(5)).to be_between 15, 16
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "can have a maximum wait time" do
|
|
36
|
+
expect(call_delay(1, max_delay: 5)).to be_between 0, 1
|
|
37
|
+
expect(call_delay(5, max_delay: 5)).to eq 5
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "respects Retry-After headers as integer" do
|
|
41
|
+
delay_time = rand(6...2500)
|
|
42
|
+
header_value = delay_time.to_s
|
|
43
|
+
expect(call_retry_header(header_value)).to eq delay_time
|
|
44
|
+
expect(call_retry_header(header_value, max_delay: 5)).to eq 5
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "respects Retry-After headers as rfc2822 timestamp" do
|
|
48
|
+
delay_time = rand(6...2500)
|
|
49
|
+
header_value = (Time.now.gmtime + delay_time).to_datetime.rfc2822.sub("+0000", "GMT")
|
|
50
|
+
expect(call_retry_header(header_value)).to be_within(1).of(delay_time)
|
|
51
|
+
expect(call_retry_header(header_value, max_delay: 5)).to eq 5
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "respects Retry-After headers as rfc2822 timestamp in the past" do
|
|
55
|
+
delay_time = rand(6...2500)
|
|
56
|
+
header_value = (Time.now.gmtime - delay_time).to_datetime.rfc2822.sub("+0000", "GMT")
|
|
57
|
+
expect(call_retry_header(header_value)).to eq 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "does not error on invalid Retry-After header" do
|
|
61
|
+
[ # invalid strings
|
|
62
|
+
"This is a string with a number 5 in it",
|
|
63
|
+
"8 Eight is the first digit in this string",
|
|
64
|
+
"This is a string with a #{Time.now.gmtime.to_datetime.rfc2822} timestamp in it"
|
|
65
|
+
].each do |header_value|
|
|
66
|
+
expect(call_retry_header(header_value)).to eq 0
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|