File: retries.rb

package info (click to toggle)
ruby-httpx 1.7.2-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 1,816 kB
  • sloc: ruby: 12,209; makefile: 4
file content (266 lines) | stat: -rw-r--r-- 8,943 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# frozen_string_literal: true

module HTTPX
  module Plugins
    #
    # This plugin adds support for retrying requests when errors happen.
    #
    # It has a default max number of retries (see *MAX_RETRIES* and the *max_retries* option),
    # after which it will return the last response, error or not. It will **not** raise an exception.
    #
    # It does not retry which are not considered idempotent (see *retry_change_requests* to override).
    #
    # https://gitlab.com/os85/httpx/wikis/Retries
    #
    module Retries
      MAX_RETRIES = 3
      # TODO: pass max_retries in a configure/load block

      IDEMPOTENT_METHODS = %w[GET OPTIONS HEAD PUT DELETE].freeze

      # subset of retryable errors which are safe to retry when reconnecting
      RECONNECTABLE_ERRORS = [
        IOError,
        EOFError,
        Errno::ECONNRESET,
        Errno::ECONNABORTED,
        Errno::EPIPE,
        Errno::EINVAL,
        Errno::ETIMEDOUT,
        ConnectionError,
        TLSError,
        Connection::HTTP2::Error,
      ].freeze

      RETRYABLE_ERRORS = (RECONNECTABLE_ERRORS + [
        Parser::Error,
        TimeoutError,
      ]).freeze

      DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }.freeze

      # list of supported backoff algorithms
      BACKOFF_ALGORITHMS = %i[exponential_backoff polynomial_backoff].freeze

      class << self
        if ENV.key?("HTTPX_NO_JITTER")
          def extra_options(options)
            options.merge(max_retries: MAX_RETRIES)
          end
        else
          def extra_options(options)
            options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
          end
        end

        # returns the time to wait before resending +request+ as per the polynomial backoff retry strategy.
        def retry_after_polynomial_backoff(request, _)
          offset = request.options.max_retries - request.retries
          2 * (offset - 1)
        end

        # returns the time to wait before resending +request+ as per the exponential backoff retry strategy.
        def retry_after_exponential_backoff(request, _)
          offset = request.options.max_retries - request.retries
          (offset - 1) * 2
        end
      end

      # adds support for the following options:
      #
      # :max_retries :: max number of times a request will be retried (defaults to <tt>3</tt>).
      # :retry_change_requests :: whether idempotent requests are retried (defaults to <tt>false</tt>).
      # :retry_after:: seconds after which a request is retried; can also be a callable object (i.e. <tt>->(req, res) { ... } </tt>)
      #                or the name of a supported backoff algorithm (i.e. <tt>:exponential_backoff</tt>).
      # :retry_jitter :: number of seconds applied to *:retry_after* (must be a callable, i.e. <tt>->(retry_after) { ... } </tt>).
      # :retry_on :: callable which alternatively defines a different rule for when a response is to be retried
      #              (i.e. <tt>->(res) { ... }</tt>).
      module OptionsMethods
        private

        def option_retry_after(value)
          if value.respond_to?(:call)
            value1 = value
            value1 = value1.method(:call) unless value1.respond_to?(:arity)

            # allow ->(*) arity as well, which is < 0
            raise TypeError, "`:retry_after` proc has invalid number of parameters" unless value1.arity.negative? || value1.arity.between?(
              1, 2
            )

          else
            case value
            when Symbol
              raise TypeError, "`retry_after`: `#{value}` is not a supported backoff algorithm" unless BACKOFF_ALGORITHMS.include?(value)

              value = Retries.method(:"retry_after_#{value}")

            else
              value = Float(value)
              raise TypeError, "`:retry_after` must be positive" unless value.positive?
            end
          end

          value
        end

        def option_retry_jitter(value)
          # return early if callable
          raise TypeError, ":retry_jitter must be callable" unless value.respond_to?(:call)

          value
        end

        def option_max_retries(value)
          num = Integer(value)
          raise TypeError, ":max_retries must be positive" unless num >= 0

          num
        end

        def option_retry_change_requests(v)
          v
        end

        def option_retry_on(value)
          raise TypeError, ":retry_on must be called with the response" unless value.respond_to?(:call)

          value
        end
      end

      module InstanceMethods
        # returns a `:retries` plugin enabled session with +n+ maximum retries per request setting.
        def max_retries(n)
          with(max_retries: n)
        end

        private

        def fetch_response(request, selector, options)
          response = super

          if response &&
             request.retries.positive? &&
             retryable_request?(request, response, options) &&
             retryable_response?(response, options)
            try_partial_retry(request, response)
            log { "failed to get response, #{request.retries} tries to go..." }
            prepare_to_retry(request, response)

            retry_after = options.retry_after
            retry_after = retry_after.call(request, response) if retry_after.respond_to?(:call)

            if retry_after
              # apply jitter
              if (jitter = request.options.retry_jitter)
                retry_after = jitter.call(retry_after)
              end

              retry_start = Utils.now
              log { "retrying after #{retry_after} secs..." }
              selector.after(retry_after) do
                if (response = request.response)
                  response.finish!
                  # request has terminated abruptly meanwhile
                  request.emit(:response, response)
                else
                  log { "retrying (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
                  send_request(request, selector, options)
                end
              end
            else
              send_request(request, selector, options)
            end

            return
          end
          response
        end

        # returns whether +request+ can be retried.
        def retryable_request?(request, _, options)
          IDEMPOTENT_METHODS.include?(request.verb) || options.retry_change_requests
        end

        def retryable_response?(response, options)
          (response.is_a?(ErrorResponse) && retryable_error?(response.error, options)) || options.retry_on&.call(response)
        end

        # returns whether the +ex+ exception happend for a retriable request.
        def retryable_error?(ex, _)
          RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) }
        end

        def proxy_error?(request, response, _)
          super && !request.retries.positive?
        end

        def prepare_to_retry(request, _response)
          request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
          request.transition(:idle)
        end

        #
        # Attempt to set the request to perform a partial range request.
        # This happens if the peer server accepts byte-range requests, and
        # the last response contains some body payload.
        #
        def try_partial_retry(request, response)
          response = response.response if response.is_a?(ErrorResponse)

          return unless response

          unless response.headers.key?("accept-ranges") &&
                 response.headers["accept-ranges"] == "bytes" && # there's nothing else supported though...
                 (original_body = response.body)
            response.body.close
            return
          end

          request.partial_response = response

          size = original_body.bytesize

          request.headers["range"] = "bytes=#{size}-"
        end
      end

      module RequestMethods
        # number of retries left.
        attr_accessor :retries

        # a response partially received before.
        attr_writer :partial_response

        # initializes the request instance, sets the number of retries for the request.
        def initialize(*args)
          super
          @retries = @options.max_retries
        end

        def response=(response)
          if @partial_response
            if response.is_a?(Response) && response.status == 206
              response.from_partial_response(@partial_response)
            else
              @partial_response.close
            end
            @partial_response = nil
          end

          super
        end
      end

      module ResponseMethods
        def from_partial_response(response)
          @status = response.status
          @headers = response.headers
          @body = response.body
        end
      end
    end
    register_plugin :retries, Retries
  end
end