http 3.1.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 +5 -5
- data/.github/workflows/ci.yml +67 -0
- data/.gitignore +6 -9
- data/.rspec +0 -4
- data/.rubocop/layout.yml +8 -0
- data/.rubocop/metrics.yml +4 -0
- data/.rubocop/rspec.yml +9 -0
- data/.rubocop/style.yml +32 -0
- data/.rubocop.yml +9 -108
- data/.rubocop_todo.yml +219 -0
- data/.yardopts +1 -1
- data/CHANGELOG.md +67 -0
- data/{CHANGES.md → CHANGES_OLD.md} +358 -0
- data/Gemfile +19 -10
- data/LICENSE.txt +1 -1
- data/README.md +53 -85
- data/Rakefile +3 -11
- data/SECURITY.md +17 -0
- data/http.gemspec +15 -6
- data/lib/http/base64.rb +12 -0
- data/lib/http/chainable.rb +71 -41
- data/lib/http/client.rb +73 -52
- data/lib/http/connection.rb +28 -18
- data/lib/http/content_type.rb +12 -7
- data/lib/http/errors.rb +19 -0
- data/lib/http/feature.rb +18 -1
- data/lib/http/features/auto_deflate.rb +27 -6
- data/lib/http/features/auto_inflate.rb +32 -6
- data/lib/http/features/instrumentation.rb +69 -0
- data/lib/http/features/logging.rb +53 -0
- data/lib/http/features/normalize_uri.rb +17 -0
- data/lib/http/features/raise_error.rb +22 -0
- data/lib/http/headers/known.rb +3 -0
- data/lib/http/headers/normalizer.rb +69 -0
- data/lib/http/headers.rb +72 -49
- data/lib/http/mime_type/adapter.rb +3 -1
- data/lib/http/mime_type/json.rb +1 -0
- data/lib/http/options.rb +31 -28
- data/lib/http/redirector.rb +56 -4
- data/lib/http/request/body.rb +31 -0
- data/lib/http/request/writer.rb +29 -9
- data/lib/http/request.rb +76 -41
- data/lib/http/response/body.rb +6 -4
- data/lib/http/response/inflater.rb +1 -1
- data/lib/http/response/parser.rb +78 -26
- data/lib/http/response/status.rb +4 -3
- data/lib/http/response.rb +45 -27
- 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/timeout/global.rb +29 -47
- data/lib/http/timeout/null.rb +12 -8
- data/lib/http/timeout/per_operation.rb +32 -57
- data/lib/http/uri.rb +75 -1
- data/lib/http/version.rb +1 -1
- data/lib/http.rb +2 -2
- data/spec/lib/http/client_spec.rb +189 -36
- data/spec/lib/http/connection_spec.rb +31 -6
- data/spec/lib/http/features/auto_inflate_spec.rb +40 -23
- data/spec/lib/http/features/instrumentation_spec.rb +81 -0
- data/spec/lib/http/features/logging_spec.rb +65 -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/headers_spec.rb +53 -18
- data/spec/lib/http/options/headers_spec.rb +6 -2
- data/spec/lib/http/options/merge_spec.rb +16 -16
- data/spec/lib/http/redirector_spec.rb +147 -3
- data/spec/lib/http/request/body_spec.rb +71 -4
- data/spec/lib/http/request/writer_spec.rb +45 -2
- data/spec/lib/http/request_spec.rb +11 -5
- data/spec/lib/http/response/body_spec.rb +5 -5
- data/spec/lib/http/response/parser_spec.rb +74 -0
- data/spec/lib/http/response/status_spec.rb +3 -3
- data/spec/lib/http/response_spec.rb +83 -7
- 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/uri/normalizer_spec.rb +95 -0
- data/spec/lib/http/uri_spec.rb +39 -0
- data/spec/lib/http_spec.rb +121 -68
- data/spec/regression_specs.rb +7 -0
- data/spec/spec_helper.rb +22 -21
- data/spec/support/black_hole.rb +1 -1
- data/spec/support/dummy_server/servlet.rb +42 -11
- data/spec/support/dummy_server.rb +9 -8
- data/spec/support/fuubar.rb +21 -0
- data/spec/support/http_handling_shared.rb +62 -66
- data/spec/support/simplecov.rb +19 -0
- data/spec/support/ssl_helper.rb +4 -4
- metadata +66 -27
- data/.coveralls.yml +0 -1
- data/.ruby-version +0 -1
- data/.travis.yml +0 -36
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
1
|
# coding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
RSpec.describe HTTP::Request do
|
|
5
5
|
let(:proxy) { {} }
|
|
@@ -8,10 +8,10 @@ RSpec.describe HTTP::Request do
|
|
|
8
8
|
|
|
9
9
|
subject :request do
|
|
10
10
|
HTTP::Request.new(
|
|
11
|
-
:verb
|
|
12
|
-
:uri
|
|
13
|
-
:headers
|
|
14
|
-
:proxy
|
|
11
|
+
:verb => :get,
|
|
12
|
+
:uri => request_uri,
|
|
13
|
+
:headers => headers,
|
|
14
|
+
:proxy => proxy
|
|
15
15
|
)
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -225,4 +225,10 @@ RSpec.describe HTTP::Request do
|
|
|
225
225
|
end
|
|
226
226
|
end
|
|
227
227
|
end
|
|
228
|
+
|
|
229
|
+
describe "#inspect" do
|
|
230
|
+
subject { request.inspect }
|
|
231
|
+
|
|
232
|
+
it { is_expected.to eq "#<HTTP::Request/1.1 GET #{request_uri}>" }
|
|
233
|
+
end
|
|
228
234
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
RSpec.describe HTTP::Response::Body do
|
|
4
4
|
let(:connection) { double(:sequence_id => 0) }
|
|
5
|
-
let(:chunks) { [
|
|
5
|
+
let(:chunks) { ["Hello, ", "World!"] }
|
|
6
6
|
|
|
7
7
|
before do
|
|
8
8
|
allow(connection).to receive(:readpartial) { chunks.shift }
|
|
@@ -16,7 +16,7 @@ RSpec.describe HTTP::Response::Body do
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
context "when body empty" do
|
|
19
|
-
let(:chunks) { [
|
|
19
|
+
let(:chunks) { [""] }
|
|
20
20
|
|
|
21
21
|
it "returns responds to empty? with true" do
|
|
22
22
|
expect(subject).to be_empty
|
|
@@ -45,12 +45,12 @@ RSpec.describe HTTP::Response::Body do
|
|
|
45
45
|
it "returns content in specified encoding" do
|
|
46
46
|
body = described_class.new(connection)
|
|
47
47
|
expect(connection).to receive(:readpartial).
|
|
48
|
-
and_return(String.new("content"
|
|
48
|
+
and_return(String.new("content", :encoding => Encoding::UTF_8))
|
|
49
49
|
expect(body.readpartial.encoding).to eq Encoding::BINARY
|
|
50
50
|
|
|
51
51
|
body = described_class.new(connection, :encoding => Encoding::UTF_8)
|
|
52
52
|
expect(connection).to receive(:readpartial).
|
|
53
|
-
and_return(String.new("content"
|
|
53
|
+
and_return(String.new("content", :encoding => Encoding::BINARY))
|
|
54
54
|
expect(body.readpartial.encoding).to eq Encoding::UTF_8
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -59,7 +59,7 @@ RSpec.describe HTTP::Response::Body do
|
|
|
59
59
|
let(:chunks) do
|
|
60
60
|
body = Zlib::Deflate.deflate("Hi, HTTP here ☺")
|
|
61
61
|
len = body.length
|
|
62
|
-
[
|
|
62
|
+
[body[0, len / 2], body[(len / 2)..]]
|
|
63
63
|
end
|
|
64
64
|
subject(:body) do
|
|
65
65
|
inflater = HTTP::Response::Inflater.new(connection)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe HTTP::Response::Parser do
|
|
4
|
+
subject(:parser) { described_class.new }
|
|
5
|
+
let(:raw_response) do
|
|
6
|
+
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nContent-Type: application/json\r\nMyHeader: val\r\nEmptyHeader: \r\n\r\n{}"
|
|
7
|
+
end
|
|
8
|
+
let(:expected_headers) do
|
|
9
|
+
{
|
|
10
|
+
"Content-Length" => "2",
|
|
11
|
+
"Content-Type" => "application/json",
|
|
12
|
+
"MyHeader" => "val",
|
|
13
|
+
"EmptyHeader" => ""
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
let(:expected_body) { "{}" }
|
|
17
|
+
|
|
18
|
+
before do
|
|
19
|
+
parts.each { |part| subject.add(part) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
context "whole response in one part" do
|
|
23
|
+
let(:parts) { [raw_response] }
|
|
24
|
+
|
|
25
|
+
it "parses headers" do
|
|
26
|
+
expect(subject.headers.to_h).to eq(expected_headers)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "parses body" do
|
|
30
|
+
expect(subject.read(expected_body.size)).to eq(expected_body)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
context "response in many parts" do
|
|
35
|
+
let(:parts) { raw_response.chars }
|
|
36
|
+
|
|
37
|
+
it "parses headers" do
|
|
38
|
+
expect(subject.headers.to_h).to eq(expected_headers)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "parses body" do
|
|
42
|
+
expect(subject.read(expected_body.size)).to eq(expected_body)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
context "when got 100 Continue response" do
|
|
47
|
+
let :raw_response do
|
|
48
|
+
"HTTP/1.1 100 Continue\r\n\r\n" \
|
|
49
|
+
"HTTP/1.1 200 OK\r\n" \
|
|
50
|
+
"Content-Length: 12\r\n\r\n" \
|
|
51
|
+
"Hello World!"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context "when response is feeded in one part" do
|
|
55
|
+
let(:parts) { [raw_response] }
|
|
56
|
+
|
|
57
|
+
it "skips to next non-info response" do
|
|
58
|
+
expect(subject.status_code).to eq(200)
|
|
59
|
+
expect(subject.headers).to eq("Content-Length" => "12")
|
|
60
|
+
expect(subject.read(12)).to eq("Hello World!")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
context "when response is feeded in many parts" do
|
|
65
|
+
let(:parts) { raw_response.chars }
|
|
66
|
+
|
|
67
|
+
it "skips to next non-info response" do
|
|
68
|
+
expect(subject.status_code).to eq(200)
|
|
69
|
+
expect(subject.headers).to eq("Content-Length" => "12")
|
|
70
|
+
expect(subject.read(12)).to eq("Hello World!")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -26,7 +26,7 @@ RSpec.describe HTTP::Response::Status do
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
described_class::REASONS.each do |code, reason|
|
|
29
|
-
class_eval <<-RUBY
|
|
29
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
30
30
|
context 'with well-known code: #{code}' do
|
|
31
31
|
let(:code) { #{code} }
|
|
32
32
|
it { is_expected.to eq #{reason.inspect} }
|
|
@@ -165,7 +165,7 @@ RSpec.describe HTTP::Response::Status do
|
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
described_class::SYMBOLS.each do |code, symbol|
|
|
168
|
-
class_eval <<-RUBY
|
|
168
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
169
169
|
context 'with well-known code: #{code}' do
|
|
170
170
|
let(:code) { #{code} }
|
|
171
171
|
it { is_expected.to be #{symbol.inspect} }
|
|
@@ -193,7 +193,7 @@ RSpec.describe HTTP::Response::Status do
|
|
|
193
193
|
end
|
|
194
194
|
|
|
195
195
|
described_class::SYMBOLS.each do |code, symbol|
|
|
196
|
-
class_eval <<-RUBY
|
|
196
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
197
197
|
describe '##{symbol}?' do
|
|
198
198
|
subject { status.#{symbol}? }
|
|
199
199
|
|
|
@@ -4,6 +4,7 @@ RSpec.describe HTTP::Response do
|
|
|
4
4
|
let(:body) { "Hello world!" }
|
|
5
5
|
let(:uri) { "http://example.com/" }
|
|
6
6
|
let(:headers) { {} }
|
|
7
|
+
let(:request) { HTTP::Request.new(:verb => :get, :uri => uri) }
|
|
7
8
|
|
|
8
9
|
subject(:response) do
|
|
9
10
|
HTTP::Response.new(
|
|
@@ -11,7 +12,7 @@ RSpec.describe HTTP::Response do
|
|
|
11
12
|
:version => "1.1",
|
|
12
13
|
:headers => headers,
|
|
13
14
|
:body => body,
|
|
14
|
-
:
|
|
15
|
+
:request => request
|
|
15
16
|
)
|
|
16
17
|
end
|
|
17
18
|
|
|
@@ -109,7 +110,7 @@ RSpec.describe HTTP::Response do
|
|
|
109
110
|
expect(response.parse("application/json")).to eq "foo" => "bar"
|
|
110
111
|
end
|
|
111
112
|
|
|
112
|
-
it "supports
|
|
113
|
+
it "supports mime type aliases" do
|
|
113
114
|
expect(response.parse(:json)).to eq "foo" => "bar"
|
|
114
115
|
end
|
|
115
116
|
end
|
|
@@ -129,13 +130,12 @@ RSpec.describe HTTP::Response do
|
|
|
129
130
|
end
|
|
130
131
|
|
|
131
132
|
describe "#inspect" do
|
|
133
|
+
subject { response.inspect }
|
|
134
|
+
|
|
132
135
|
let(:headers) { {:content_type => "text/plain"} }
|
|
133
136
|
let(:body) { double :to_s => "foobar" }
|
|
134
137
|
|
|
135
|
-
it
|
|
136
|
-
expect(response.inspect).
|
|
137
|
-
to eq '#<HTTP::Response/1.1 200 OK {"Content-Type"=>"text/plain"}>'
|
|
138
|
-
end
|
|
138
|
+
it { is_expected.to eq '#<HTTP::Response/1.1 200 OK {"Content-Type"=>"text/plain"}>' }
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
describe "#cookies" do
|
|
@@ -166,7 +166,8 @@ RSpec.describe HTTP::Response do
|
|
|
166
166
|
HTTP::Response.new(
|
|
167
167
|
:version => "1.1",
|
|
168
168
|
:status => 200,
|
|
169
|
-
:connection => connection
|
|
169
|
+
:connection => connection,
|
|
170
|
+
:request => request
|
|
170
171
|
)
|
|
171
172
|
end
|
|
172
173
|
|
|
@@ -183,4 +184,79 @@ RSpec.describe HTTP::Response do
|
|
|
183
184
|
end
|
|
184
185
|
it { is_expected.not_to be_chunked }
|
|
185
186
|
end
|
|
187
|
+
|
|
188
|
+
describe "backwards compatibilty with :uri" do
|
|
189
|
+
context "with no :verb" do
|
|
190
|
+
subject(:response) do
|
|
191
|
+
HTTP::Response.new(
|
|
192
|
+
:status => 200,
|
|
193
|
+
:version => "1.1",
|
|
194
|
+
:headers => headers,
|
|
195
|
+
:body => body,
|
|
196
|
+
:uri => uri
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "defaults the uri to :uri" do
|
|
201
|
+
expect(response.request.uri.to_s).to eq uri
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "defaults to the verb to :get" do
|
|
205
|
+
expect(response.request.verb).to eq :get
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
context "with both a :request and :uri" do
|
|
210
|
+
subject(:response) do
|
|
211
|
+
HTTP::Response.new(
|
|
212
|
+
:status => 200,
|
|
213
|
+
:version => "1.1",
|
|
214
|
+
:headers => headers,
|
|
215
|
+
:body => body,
|
|
216
|
+
:uri => uri,
|
|
217
|
+
:request => request
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "raises ArgumentError" do
|
|
222
|
+
expect { response }.to raise_error(ArgumentError)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
describe "#body" do
|
|
228
|
+
let(:connection) { double(:sequence_id => 0) }
|
|
229
|
+
let(:chunks) { ["Hello, ", "World!"] }
|
|
230
|
+
|
|
231
|
+
subject(:response) do
|
|
232
|
+
HTTP::Response.new(
|
|
233
|
+
:status => 200,
|
|
234
|
+
:version => "1.1",
|
|
235
|
+
:headers => headers,
|
|
236
|
+
:request => request,
|
|
237
|
+
:connection => connection
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
before do
|
|
242
|
+
allow(connection).to receive(:readpartial) { chunks.shift }
|
|
243
|
+
allow(connection).to receive(:body_completed?) { chunks.empty? }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
context "with no Content-Type" do
|
|
247
|
+
let(:headers) { {} }
|
|
248
|
+
|
|
249
|
+
it "returns a body with default binary encoding" do
|
|
250
|
+
expect(response.body.to_s.encoding).to eq Encoding::BINARY
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
context "with Content-Type: application/json" do
|
|
255
|
+
let(:headers) { {"Content-Type" => "application/json"} }
|
|
256
|
+
|
|
257
|
+
it "returns a body with a default UTF_8 encoding" do
|
|
258
|
+
expect(response.body.to_s.encoding).to eq Encoding::UTF_8
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
186
262
|
end
|
|
@@ -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
|