http 5.2.0 → 5.3.0

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.
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ # Retriable performance ran out of attempts
5
+ class OutOfRetriesError < Error
6
+ attr_accessor :response
7
+
8
+ attr_writer :cause
9
+
10
+ def cause
11
+ @cause || super
12
+ end
13
+ end
14
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTTP
4
- VERSION = "5.2.0"
4
+ VERSION = "5.3.0"
5
5
  end
data/lib/http.rb CHANGED
@@ -6,6 +6,7 @@ require "http/timeout/per_operation"
6
6
  require "http/timeout/global"
7
7
  require "http/chainable"
8
8
  require "http/client"
9
+ require "http/retriable/client"
9
10
  require "http/connection"
10
11
  require "http/options"
11
12
  require "http/feature"
@@ -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
- cookies = res.cookies.cookies.to_h { |c| [c.name, c.value] }
120
- expect(cookies["foo"]).to eq "42"
121
- expect(cookies["bar"]).to eq "53"
122
- expect(cookies["baz"]).to eq "65"
123
- expect(cookies["deleted"]).to eq nil
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
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe HTTP::Retriable::Performer do
4
+ let(:client) do
5
+ HTTP::Client.new
6
+ end
7
+
8
+ let(:response) do
9
+ HTTP::Response.new(
10
+ status: 200,
11
+ version: "1.1",
12
+ headers: {},
13
+ body: "Hello world!",
14
+ request: request
15
+ )
16
+ end
17
+
18
+ let(:request) do
19
+ HTTP::Request.new(
20
+ verb: :get,
21
+ uri: "http://example.com"
22
+ )
23
+ end
24
+
25
+ let(:perform_spy) { { counter: 0 } }
26
+ let(:counter_spy) { perform_spy[:counter] }
27
+
28
+ before do
29
+ stub_const("CustomException", Class.new(StandardError))
30
+ end
31
+
32
+ def perform(options = {}, client_arg = client, request_arg = request, &block)
33
+ # by explicitly overwriting the default delay, we make a much faster test suite
34
+ default_options = { delay: 0 }
35
+ options = default_options.merge(options)
36
+
37
+ HTTP::Retriable::Performer
38
+ .new(options)
39
+ .perform(client_arg, request_arg) do
40
+ perform_spy[:counter] += 1
41
+ block ? yield : response
42
+ end
43
+ end
44
+
45
+ def measure_wait
46
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
47
+ result = yield
48
+ t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
+ [t2 - t1, result]
50
+ end
51
+
52
+ describe "#perform" do
53
+ describe "expected exception" do
54
+ it "retries the request" do
55
+ expect do
56
+ perform(exceptions: [CustomException], tries: 2) do
57
+ raise CustomException
58
+ end
59
+ end.to raise_error HTTP::OutOfRetriesError
60
+
61
+ expect(counter_spy).to eq 2
62
+ end
63
+ end
64
+
65
+ describe "unexpected exception" do
66
+ it "does not retry the request" do
67
+ expect do
68
+ perform(exceptions: [], tries: 2) do
69
+ raise CustomException
70
+ end
71
+ end.to raise_error CustomException
72
+
73
+ expect(counter_spy).to eq 1
74
+ end
75
+ end
76
+
77
+ describe "expected status codes" do
78
+ def response(**options)
79
+ HTTP::Response.new(
80
+ {
81
+ status: 200,
82
+ version: "1.1",
83
+ headers: {},
84
+ body: "Hello world!",
85
+ request: request
86
+ }.merge(options)
87
+ )
88
+ end
89
+
90
+ it "retries the request" do
91
+ expect do
92
+ perform(retry_statuses: [200], tries: 2)
93
+ end.to raise_error HTTP::OutOfRetriesError
94
+
95
+ expect(counter_spy).to eq 2
96
+ end
97
+
98
+ describe "status codes can be expressed in many ways" do
99
+ [
100
+ 301,
101
+ [200, 301, 485],
102
+ 250...400,
103
+ [250...Float::INFINITY],
104
+ ->(status_code) { status_code == 301 },
105
+ [->(status_code) { status_code == 301 }]
106
+ ].each do |retry_statuses|
107
+ it retry_statuses.to_s do
108
+ expect do
109
+ perform(retry_statuses: retry_statuses, tries: 2) do
110
+ response(status: 301)
111
+ end
112
+ end.to raise_error HTTP::OutOfRetriesError
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ describe "unexpected status code" do
119
+ it "does not retry the request" do
120
+ expect(
121
+ perform(retry_statuses: [], tries: 2)
122
+ ).to eq response
123
+
124
+ expect(counter_spy).to eq 1
125
+ end
126
+ end
127
+
128
+ describe "on_retry callback" do
129
+ it "calls the on_retry callback on each retry with exception" do
130
+ callback_call_spy = 0
131
+
132
+ callback_spy = proc do |callback_request, error, callback_response|
133
+ expect(callback_request).to eq request
134
+ expect(error).to be_a HTTP::TimeoutError
135
+ expect(callback_response).to be_nil
136
+ callback_call_spy += 1
137
+ end
138
+
139
+ expect do
140
+ perform(tries: 3, on_retry: callback_spy) do
141
+ raise HTTP::TimeoutError
142
+ end
143
+ end.to raise_error HTTP::OutOfRetriesError
144
+
145
+ expect(callback_call_spy).to eq 2
146
+ end
147
+
148
+ it "calls the on_retry callback on each retry with response" do
149
+ callback_call_spy = 0
150
+
151
+ callback_spy = proc do |callback_request, error, callback_response|
152
+ expect(callback_request).to eq request
153
+ expect(error).to be_nil
154
+ expect(callback_response).to be response
155
+ callback_call_spy += 1
156
+ end
157
+
158
+ expect do
159
+ perform(retry_statuses: [200], tries: 3, on_retry: callback_spy)
160
+ end.to raise_error HTTP::OutOfRetriesError
161
+
162
+ expect(callback_call_spy).to eq 2
163
+ end
164
+ end
165
+
166
+ describe "delay option" do
167
+ let(:timing_slack) { 0.05 }
168
+
169
+ it "can be a positive number" do
170
+ time, = measure_wait do
171
+ perform(delay: 0.1, tries: 3, should_retry: ->(*) { true })
172
+ rescue HTTP::OutOfRetriesError
173
+ end
174
+ expect(time).to be_within(timing_slack).of(0.2)
175
+ end
176
+
177
+ it "can be a proc number" do
178
+ time, = measure_wait do
179
+ perform(delay: ->(attempt) { attempt / 10.0 }, tries: 3, should_retry: ->(*) { true })
180
+ rescue HTTP::OutOfRetriesError
181
+ end
182
+ expect(time).to be_within(timing_slack).of(0.3)
183
+ end
184
+
185
+ it "receives correct retry number when a proc" do
186
+ retry_count = 0
187
+ retry_proc = proc do |attempt|
188
+ expect(attempt).to eq(retry_count).and(be > 0)
189
+ 0
190
+ end
191
+ begin
192
+ perform(delay: retry_proc, should_retry: ->(*) { true }) do
193
+ retry_count += 1
194
+ response
195
+ end
196
+ rescue HTTP::OutOfRetriesError
197
+ end
198
+ end
199
+ end
200
+
201
+ describe "should_retry option" do
202
+ it "decides if the request should be retried" do # rubocop:disable RSpec/MultipleExpectations
203
+ retry_proc = proc do |req, err, res, attempt|
204
+ expect(req).to eq request
205
+ if res
206
+ expect(err).to be_nil
207
+ expect(res).to be response
208
+ else
209
+ expect(err).to be_a CustomException
210
+ expect(res).to be_nil
211
+ end
212
+
213
+ attempt < 5
214
+ end
215
+
216
+ begin
217
+ perform(should_retry: retry_proc) do
218
+ rand < 0.5 ? response : raise(CustomException)
219
+ end
220
+ rescue CustomException
221
+ end
222
+
223
+ expect(counter_spy).to eq 5
224
+ end
225
+
226
+ it "raises the original error if not retryable" do
227
+ retry_proc = ->(*) { false }
228
+
229
+ expect do
230
+ perform(should_retry: retry_proc) do
231
+ raise CustomException
232
+ end
233
+ end.to raise_error CustomException
234
+
235
+ expect(counter_spy).to eq 1
236
+ end
237
+
238
+ it "raises HTTP::OutOfRetriesError if retryable" do
239
+ retry_proc = ->(*) { true }
240
+
241
+ expect do
242
+ perform(should_retry: retry_proc) do
243
+ raise CustomException
244
+ end
245
+ end.to raise_error HTTP::OutOfRetriesError
246
+
247
+ expect(counter_spy).to eq 5
248
+ end
249
+ end
250
+ end
251
+
252
+ describe "connection closing" do
253
+ let(:client) { double(:client) }
254
+
255
+ it "does not close the connection if we get a propper response" do
256
+ expect(client).not_to receive(:close)
257
+ perform
258
+ end
259
+
260
+ it "closes the connection after each raiseed attempt" do
261
+ expect(client).to receive(:close).exactly(3).times
262
+ begin
263
+ perform(should_retry: ->(*) { true }, tries: 3)
264
+ rescue HTTP::OutOfRetriesError
265
+ end
266
+ end
267
+
268
+ it "closes the connection on an unexpected exception" do
269
+ expect(client).to receive(:close)
270
+ begin
271
+ perform do
272
+ raise CustomException
273
+ end
274
+ rescue CustomException
275
+ end
276
+ end
277
+ end
278
+
279
+ describe HTTP::OutOfRetriesError do
280
+ it "has the original exception as a cause if available" do
281
+ err = nil
282
+ begin
283
+ perform(exceptions: [CustomException]) do
284
+ raise CustomException
285
+ end
286
+ rescue described_class => e
287
+ err = e
288
+ end
289
+ expect(err.cause).to be_a CustomException
290
+ end
291
+
292
+ it "has the last raiseed response as an attribute" do
293
+ err = nil
294
+ begin
295
+ perform(should_retry: ->(*) { true })
296
+ rescue described_class => e
297
+ err = e
298
+ end
299
+ expect(err.response).to be response
300
+ end
301
+ end
302
+ end