File: middleware.rb

package info (click to toggle)
ruby-faraday-retry 2.3.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 104 kB
  • sloc: ruby: 204; makefile: 4
file content (263 lines) | stat: -rw-r--r-- 10,312 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
# frozen_string_literal: true

require_relative 'retryable'

module Faraday
  module Retry
    # This class provides the main implementation for your middleware.
    # Your middleware can implement any of the following methods:
    # * on_request - called when the request is being prepared
    # * on_complete - called when the response is being processed
    #
    # Optionally, you can also override the following methods from Faraday::Middleware
    # * initialize(app, options = {}) - the initializer method
    # * call(env) - the main middleware invocation method.
    #   This already calls on_request and on_complete, so you normally don't need to override it.
    #   You may need to in case you need to "wrap" the request or need more control
    #   (see "retry" middleware: https://github.com/lostisland/faraday/blob/main/lib/faraday/request/retry.rb#L142).
    #   IMPORTANT: Remember to call `@app.call(env)` or `super` to not interrupt the middleware chain!
    class Middleware < Faraday::Middleware
      include Retryable

      DEFAULT_EXCEPTIONS = [
        Errno::ETIMEDOUT, 'Timeout::Error',
        Faraday::TimeoutError, Faraday::RetriableResponse
      ].freeze
      IDEMPOTENT_METHODS = %i[delete get head options put].freeze

      # Options contains the configurable parameters for the Retry middleware.
      class Options < Faraday::Options.new(:max, :interval, :max_interval,
                                           :interval_randomness,
                                           :backoff_factor, :exceptions,
                                           :methods, :retry_if, :retry_block,
                                           :retry_statuses, :rate_limit_retry_header,
                                           :rate_limit_reset_header, :header_parser_block,
                                           :exhausted_retries_block)

        DEFAULT_CHECK = ->(_env, _exception) { false }

        def self.from(value)
          if value.is_a?(Integer)
            new(value)
          else
            super(value)
          end
        end

        def max
          (self[:max] ||= 2).to_i
        end

        def interval
          (self[:interval] ||= 0).to_f
        end

        def max_interval
          (self[:max_interval] ||= Float::MAX).to_f
        end

        def interval_randomness
          (self[:interval_randomness] ||= 0).to_f
        end

        def backoff_factor
          (self[:backoff_factor] ||= 1).to_f
        end

        def exceptions
          Array(self[:exceptions] ||= DEFAULT_EXCEPTIONS)
        end

        def methods
          Array(self[:methods] ||= IDEMPOTENT_METHODS)
        end

        def retry_if
          self[:retry_if] ||= DEFAULT_CHECK
        end

        def retry_block
          self[:retry_block] ||= proc {}
        end

        def retry_statuses
          Array(self[:retry_statuses] ||= [])
        end

        def exhausted_retries_block
          self[:exhausted_retries_block] ||= proc {}
        end
      end

      # @param app [#call]
      # @param options [Hash]
      # @option options [Integer] :max (2) Maximum number of retries
      # @option options [Integer] :interval (0) Pause in seconds between retries
      # @option options [Integer] :interval_randomness (0) The maximum random
      #   interval amount expressed as a float between
      #   0 and 1 to use in addition to the interval.
      # @option options [Integer] :max_interval (Float::MAX) An upper limit
      #   for the interval
      # @option options [Integer] :backoff_factor (1) The amount to multiply
      #   each successive retry's interval amount by in order to provide backoff
      # @option options [Array] :exceptions ([ Errno::ETIMEDOUT,
      #   'Timeout::Error', Faraday::TimeoutError, Faraday::RetriableResponse])
      #   The list of exceptions to handle. Exceptions can be given as
      #   Class, Module, or String.
      # @option options [Array<Symbol>] :methods (the idempotent HTTP methods
      #   in IDEMPOTENT_METHODS) A list of HTTP methods, as symbols, to retry without
      #   calling retry_if. Pass an empty Array to call retry_if
      #   for all exceptions.
      # @option options [Block] :retry_if (false) block that will receive
      #   the env object and the exception raised
      #   and should decide if the code should retry still the action or
      #   not independent of the retry count. This would be useful
      #   if the exception produced is non-recoverable or if the
      #   the HTTP method called is not idempotent.
      # @option options [Block] :retry_block block that is executed before
      #   every retry. The block will be yielded keyword arguments:
      #     * env [Faraday::Env]: Request environment
      #     * options [Faraday::Options]: middleware options
      #     * retry_count [Integer]: how many retries have already occured (starts at 0)
      #     * exception [Exception]: exception that triggered the retry,
      #       will be the synthetic `Faraday::RetriableResponse` if the
      #       retry was triggered by something other than an exception.
      #     * will_retry_in [Float]: retry_block is called *before* the retry
      #       delay, actual retry will happen in will_retry_in number of
      #       seconds.
      # @option options [Array] :retry_statuses Array of Integer HTTP status
      #   codes or a single Integer value that determines whether to raise
      #   a Faraday::RetriableResponse exception based on the HTTP status code
      #   of an HTTP response.
      # @option options [Block] :header_parser_block block that will receive
      #   the the value of the retry header and should return the number of
      #   seconds to wait before retrying the request. This is useful if the
      #   value of the header is not a number of seconds or a RFC 2822 formatted date.
      # @option options [Block] :exhausted_retries_block block will receive
      #   when all attempts are exhausted. The block will be yielded keyword arguments:
      #     * env [Faraday::Env]: Request environment
      #     * exception [Exception]: exception that triggered the retry,
      #       will be the synthetic `Faraday::RetriableResponse` if the
      #       retry was triggered by something other than an exception.
      #     * options [Faraday::Options]: middleware options
      def initialize(app, options = nil)
        super(app)
        @options = Options.from(options)
        @errmatch = build_exception_matcher(@options.exceptions)
      end

      def calculate_sleep_amount(retries, env)
        retry_after = [calculate_retry_after(env), calculate_rate_limit_reset(env)].compact.max
        retry_interval = calculate_retry_interval(retries)

        return if retry_after && retry_after > @options.max_interval

        if retry_after && retry_after >= retry_interval
          retry_after
        else
          retry_interval
        end
      end

      # @param env [Faraday::Env]
      def call(env)
        retries = @options.max
        request_body = env[:body]

        with_retries(env: env, options: @options, retries: retries, body: request_body, errmatch: @errmatch) do
          # after failure env[:body] is set to the response body
          env[:body] = request_body

          @app.call(env).tap do |resp|
            raise Faraday::RetriableResponse.new(nil, resp) if @options.retry_statuses.include?(resp.status)
          end
        end
      end

      # An exception matcher for the rescue clause can usually be any object
      # that responds to `===`, but for Ruby 1.8 it has to be a Class or Module.
      #
      # @param exceptions [Array]
      # @api private
      # @return [Module] an exception matcher
      def build_exception_matcher(exceptions)
        matcher = Module.new
        (
          class << matcher
            self
          end).class_eval do
          define_method(:===) do |error|
            exceptions.any? do |ex|
              if ex.is_a? Module
                error.is_a? ex
              else
                Object.const_defined?(ex.to_s) && error.is_a?(Object.const_get(ex.to_s))
              end
            end
          end
        end
        matcher
      end

      private

      def retry_request?(env, exception)
        @options.methods.include?(env[:method]) ||
          @options.retry_if.call(env, exception)
      end

      def rewind_files(body)
        return unless defined?(Faraday::UploadIO)
        return unless body.is_a?(Hash)

        body.each do |_, value|
          value.rewind if value.is_a?(Faraday::UploadIO)
        end
      end

      # RFC for RateLimit Header Fields for HTTP:
      # https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-05.html#name-fields-definition
      def calculate_rate_limit_reset(env)
        reset_header = @options.rate_limit_reset_header || 'RateLimit-Reset'
        parse_retry_header(env, reset_header)
      end

      # MDN spec for Retry-After header:
      # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
      def calculate_retry_after(env)
        retry_header = @options.rate_limit_retry_header || 'Retry-After'
        parse_retry_header(env, retry_header)
      end

      def calculate_retry_interval(retries)
        retry_index = @options.max - retries
        current_interval = @options.interval *
                           (@options.backoff_factor**retry_index)
        current_interval = [current_interval, @options.max_interval].min
        random_interval = rand * @options.interval_randomness.to_f *
                          @options.interval

        current_interval + random_interval
      end

      def parse_retry_header(env, header)
        response_headers = env[:response_headers]
        return unless response_headers

        retry_after_value = env[:response_headers][header]

        if @options.header_parser_block
          @options.header_parser_block.call(retry_after_value)
        else
          # Try to parse date from the header value
          begin
            datetime = DateTime.rfc2822(retry_after_value)
            datetime.to_time - Time.now.utc
          rescue ArgumentError
            retry_after_value.to_f
          end
        end
      end
    end
  end
end