Skip to content

Commit 6ddf9c3

Browse files
bronislaviMacTia
authored andcommitted
Retry middleware improvements (honour Retry-After header, retry statuses) (#773)
1 parent c6a2098 commit 6ddf9c3

File tree

3 files changed

+130
-25
lines changed

3 files changed

+130
-25
lines changed

lib/faraday/error.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,12 @@ def initialize(ex = nil)
5555
class SSLError < ClientError
5656
end
5757

58+
class RetriableResponse < ClientError; end
59+
5860
[:ClientError, :ConnectionFailed, :ResourceNotFound,
59-
:ParsingError, :TimeoutError, :SSLError].each do |const|
61+
:ParsingError, :TimeoutError, :SSLError, :RetriableResponse].each do |const|
6062
Error.const_set(const, Faraday.const_get(const))
6163
end
64+
65+
6266
end

lib/faraday/request/retry.rb

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ class Request::Retry < Faraday::Middleware
2323
IDEMPOTENT_METHODS = [:delete, :get, :head, :options, :put]
2424

2525
class Options < Faraday::Options.new(:max, :interval, :max_interval, :interval_randomness,
26-
:backoff_factor, :exceptions, :methods, :retry_if, :retry_block)
26+
:backoff_factor, :exceptions, :methods, :retry_if, :retry_block,
27+
:retry_statuses)
28+
2729
DEFAULT_CHECK = lambda { |env,exception| false }
2830

2931
def self.from(value)
@@ -56,7 +58,8 @@ def backoff_factor
5658

5759
def exceptions
5860
Array(self[:exceptions] ||= [Errno::ETIMEDOUT, 'Timeout::Error',
59-
Error::TimeoutError])
61+
Error::TimeoutError,
62+
Faraday::Error::RetriableResponse])
6063
end
6164

6265
def methods
@@ -71,6 +74,9 @@ def retry_block
7174
self[:retry_block] ||= Proc.new {}
7275
end
7376

77+
def retry_statuses
78+
Array(self[:retry_statuses] ||= [])
79+
end
7480
end
7581

7682
# Public: Initialize middleware
@@ -106,29 +112,35 @@ def initialize(app, options = nil)
106112
@errmatch = build_exception_matcher(@options.exceptions)
107113
end
108114

109-
def sleep_amount(retries)
110-
retry_index = @options.max - retries
111-
current_interval = @options.interval * (@options.backoff_factor ** retry_index)
112-
current_interval = [current_interval, @options.max_interval].min
113-
random_interval = rand * @options.interval_randomness.to_f * @options.interval
114-
current_interval + random_interval
115+
def calculate_sleep_amount(retries, env)
116+
retry_after = calculate_retry_after(env)
117+
retry_interval = calculate_retry_interval(retries)
118+
119+
return if retry_after && retry_after > @options.max_interval
120+
121+
retry_after && retry_after >= retry_interval ? retry_after : retry_interval
115122
end
116123

117124
def call(env)
118125
retries = @options.max
119126
request_body = env[:body]
120127
begin
121128
env[:body] = request_body # after failure env[:body] is set to the response body
122-
@app.call(env)
129+
@app.call(env).tap do |resp|
130+
raise Faraday::Error::RetriableResponse.new(nil, resp) if @options.retry_statuses.include?(resp.status)
131+
end
123132
rescue @errmatch => exception
124133
if retries > 0 && retry_request?(env, exception)
125134
retries -= 1
126135
rewind_files(request_body)
127136
@options.retry_block.call(env, @options, retries, exception)
128-
sleep sleep_amount(retries + 1)
129-
retry
137+
if (sleep_amount = calculate_sleep_amount(retries + 1, env))
138+
sleep sleep_amount
139+
retry
140+
end
130141
end
131-
raise
142+
143+
raise unless exception.is_a?(Faraday::Error::RetriableResponse)
132144
end
133145
end
134146

@@ -167,5 +179,29 @@ def rewind_files(body)
167179
end
168180
end
169181

182+
# MDN spec for Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
183+
def calculate_retry_after(env)
184+
response_headers = env[:response_headers]
185+
return unless response_headers
186+
187+
retry_after_value = env[:response_headers]["Retry-After"]
188+
189+
# Try to parse date from the header value
190+
begin
191+
datetime = DateTime.rfc2822(retry_after_value)
192+
datetime.to_time - Time.now.utc
193+
rescue ArgumentError
194+
retry_after_value.to_f
195+
end
196+
end
197+
198+
def calculate_retry_interval(retries)
199+
retry_index = @options.max - retries
200+
current_interval = @options.interval * (@options.backoff_factor ** retry_index)
201+
current_interval = [current_interval, @options.max_interval].min
202+
random_interval = rand * @options.interval_randomness.to_f * @options.interval
203+
204+
current_interval + random_interval
205+
end
170206
end
171207
end

test/middleware/retry_test.rb

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ def setup
1010
def conn(*retry_args)
1111
Faraday.new do |b|
1212
b.request :retry, *retry_args
13+
1314
b.adapter :test do |stub|
1415
['get', 'post'].each do |method|
1516
stub.send(method, '/unstable') do |env|
@@ -18,6 +19,22 @@ def conn(*retry_args)
1819
env[:body] = nil # simulate blanking out response body
1920
@explode.call @times_called
2021
end
22+
23+
stub.send(method, '/throttled') do |env|
24+
@times_called += 1
25+
@envs << env.dup
26+
27+
params = env[:params]
28+
29+
status = (params['status'] || 429).to_i
30+
headers = {}
31+
32+
retry_after = params['retry_after']
33+
34+
headers['Retry-After'] = retry_after if retry_after
35+
36+
[status, headers, '']
37+
end
2138
end
2239
end
2340
end
@@ -68,7 +85,7 @@ def test_interval
6885
assert_in_delta 0.2, Time.now - started, 0.04
6986
end
7087

71-
def test_calls_sleep_amount
88+
def test_calls_calculate_sleep_amount
7289
explode_app = MiniTest::Mock.new
7390
explode_app.expect(:call, nil, [{:body=>nil}])
7491
def explode_app.call(env)
@@ -79,7 +96,7 @@ def explode_app.call(env)
7996
class << retry_middleware
8097
attr_accessor :sleep_amount_retries
8198

82-
def sleep_amount(retries)
99+
def calculate_sleep_amount(retries, env)
83100
self.sleep_amount_retries.delete(retries)
84101
0
85102
end
@@ -95,30 +112,30 @@ def sleep_amount(retries)
95112

96113
def test_exponential_backoff
97114
middleware = Faraday::Request::Retry.new(nil, :max => 5, :interval => 0.1, :backoff_factor => 2)
98-
assert_equal middleware.sleep_amount(5), 0.1
99-
assert_equal middleware.sleep_amount(4), 0.2
100-
assert_equal middleware.sleep_amount(3), 0.4
115+
assert_equal middleware.send(:calculate_retry_interval, 5), 0.1
116+
assert_equal middleware.send(:calculate_retry_interval, 4), 0.2
117+
assert_equal middleware.send(:calculate_retry_interval, 3), 0.4
101118
end
102119

103120
def test_exponential_backoff_with_max_interval
104121
middleware = Faraday::Request::Retry.new(nil, :max => 5, :interval => 1, :max_interval => 3, :backoff_factor => 2)
105-
assert_equal middleware.sleep_amount(5), 1
106-
assert_equal middleware.sleep_amount(4), 2
107-
assert_equal middleware.sleep_amount(3), 3
108-
assert_equal middleware.sleep_amount(2), 3
122+
assert_equal middleware.send(:calculate_retry_interval, 5), 1
123+
assert_equal middleware.send(:calculate_retry_interval, 4), 2
124+
assert_equal middleware.send(:calculate_retry_interval, 3), 3
125+
assert_equal middleware.send(:calculate_retry_interval, 2), 3
109126
end
110127

111128
def test_random_additional_interval_amount
112129
middleware = Faraday::Request::Retry.new(nil, :max => 2, :interval => 0.1, :interval_randomness => 1.0)
113-
sleep_amount = middleware.sleep_amount(2)
130+
sleep_amount = middleware.send(:calculate_retry_interval, 2)
114131
assert_operator sleep_amount, :>=, 0.1
115132
assert_operator sleep_amount, :<=, 0.2
116133
middleware = Faraday::Request::Retry.new(nil, :max => 2, :interval => 0.1, :interval_randomness => 0.5)
117-
sleep_amount = middleware.sleep_amount(2)
134+
sleep_amount = middleware.send(:calculate_retry_interval, 2)
118135
assert_operator sleep_amount, :>=, 0.1
119136
assert_operator sleep_amount, :<=, 0.15
120137
middleware = Faraday::Request::Retry.new(nil, :max => 2, :interval => 0.1, :interval_randomness => 0.25)
121-
sleep_amount = middleware.sleep_amount(2)
138+
sleep_amount = middleware.send(:calculate_retry_interval, 2)
122139
assert_operator sleep_amount, :>=, 0.1
123140
assert_operator sleep_amount, :<=, 0.125
124141
end
@@ -212,5 +229,53 @@ def test_should_rewind_files_on_retry
212229
assert_equal 3, @times_called
213230
assert_equal 2, rewound
214231
end
232+
233+
def test_should_retry_retriable_response
234+
params = { status: 429 }
235+
conn(:max => 1, :retry_statuses => 429).get("/throttled", params)
236+
237+
assert_equal 2, @times_called
238+
end
239+
240+
def test_should_not_retry_non_retriable_response
241+
params = { status: 503 }
242+
conn(:max => 1, :retry_statuses => 429).get("/throttled", params)
243+
244+
assert_equal 1, @times_called
245+
end
246+
247+
def test_interval_if_retry_after_present
248+
started = Time.now
249+
250+
params = { :retry_after => 0.5 }
251+
conn(:max => 1, :interval => 0.1, :retry_statuses => [429]).get("/throttled", params)
252+
253+
assert Time.now - started > 0.5
254+
end
255+
256+
def test_should_ignore_retry_after_if_less_then_calculated
257+
started = Time.now
258+
259+
params = { :retry_after => 0.1 }
260+
conn(:max => 1, :interval => 0.2, :retry_statuses => [429]).get("/throttled", params)
261+
262+
assert Time.now - started > 0.2
263+
end
264+
265+
def test_interval_when_retry_after_is_timestamp
266+
started = Time.now
267+
268+
params = { :retry_after => (Time.now.utc + 2).strftime('%a, %d %b %Y %H:%M:%S GMT') }
269+
conn(:max => 1, :interval => 0.1, :retry_statuses => [429]).get("/throttled", params)
270+
271+
assert Time.now - started > 1
272+
end
273+
274+
def test_should_not_retry_when_retry_after_greater_then_max_interval
275+
params = { :retry_after => (Time.now.utc + 20).strftime('%a, %d %b %Y %H:%M:%S GMT') }
276+
conn(:max => 2, :interval => 0.1, :retry_statuses => [429], max_interval: 5).get("/throttled", params)
277+
278+
assert_equal 1, @times_called
279+
end
215280
end
216281
end

0 commit comments

Comments
 (0)