-
Notifications
You must be signed in to change notification settings - Fork 968
/
retry.rb
212 lines (182 loc) · 7.77 KB
/
retry.rb
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
# frozen_string_literal: true
module Faraday
class Request
# Catches exceptions and retries each request a limited number of times.
#
# By default, it retries 2 times and handles only timeout exceptions. It can
# be configured with an arbitrary number of retries, a list of exceptions to
# handle, a retry interval, a percentage of randomness to add to the retry
# interval, and a backoff factor.
#
# @example Configure Retry middleware using intervals
# Faraday.new do |conn|
# conn.request(:retry, max: 2,
# interval: 0.05,
# interval_randomness: 0.5,
# backoff_factor: 2,
# exceptions: [CustomException, 'Timeout::Error'])
#
# conn.adapter(:net_http) # NB: Last middleware must be the adapter
# end
#
# This example will result in a first interval that is random between 0.05 and 0.075 and a second
# interval that is random between 0.1 and 0.15.
class Retry < Faraday::Middleware
DEFAULT_EXCEPTIONS = [Errno::ETIMEDOUT, 'Timeout::Error', Faraday::TimeoutError, Faraday::RetriableResponse].freeze
IDEMPOTENT_METHODS = %i[delete get head options put].freeze
class Options < Faraday::Options.new(:max, :interval, :max_interval, :interval_randomness,
:backoff_factor, :exceptions, :methods, :retry_if, :retry_block,
:retry_statuses)
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
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 multiple 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] :methods (the idempotent HTTP methods in IDEMPOTENT_METHODS) A list of
# HTTP methods 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 after every retry. Request environment, middleware options,
# current number of retries and the exception is passed to the block as parameters.
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)
retry_interval = calculate_retry_interval(retries)
return if retry_after && retry_after > @options.max_interval
retry_after && retry_after >= retry_interval ? retry_after : retry_interval
end
# @param env [Faraday::Env]
def call(env)
retries = @options.max
request_body = env[:body]
begin
env[:body] = request_body # after failure env[:body] is set to the response body
@app.call(env).tap do |resp|
raise Faraday::RetriableResponse.new(nil, resp) if @options.retry_statuses.include?(resp.status)
end
rescue @errmatch => exception
if retries.positive? && retry_request?(env, exception)
retries -= 1
rewind_files(request_body)
@options.retry_block.call(env, @options, retries, exception)
if (sleep_amount = calculate_sleep_amount(retries + 1, env))
sleep sleep_amount
retry
end
end
if exception.is_a?(Faraday::RetriableResponse)
exception.response
else
raise
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
error.class.to_s == 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 body.is_a?(Hash)
body.each do |_, value|
value.rewind if value.is_a?(UploadIO)
end
end
# MDN spec for Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
def calculate_retry_after(env)
response_headers = env[:response_headers]
return unless response_headers
retry_after_value = env[:response_headers]['Retry-After']
# 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
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
end
end
end