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.
@@ -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,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.1"
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