# 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
RETRYABLE_ERRORS = [
IOError,
EOFError,
Errno::ECONNRESET,
Errno::ECONNABORTED,
Errno::EPIPE,
Errno::EINVAL,
Errno::ETIMEDOUT,
Parser::Error,
TLSError,
TimeoutError,
ConnectionError,
Connection::HTTP2::GoawayError,
].freeze
DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }
if ENV.key?("HTTPX_NO_JITTER")
def self.extra_options(options)
options.merge(max_retries: MAX_RETRIES)
end
else
def self.extra_options(options)
options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
end
end
# adds support for the following options:
#
# :max_retries :: max number of times a request will be retried (defaults to 3).
# :retry_change_requests :: whether idempotent requests are retried (defaults to false).
# :retry_after:: seconds after which a request is retried; can also be a callable object (i.e. ->(req, res) { ... } )
# :retry_jitter :: number of seconds applied to *:retry_after* (must be a callable, i.e. ->(retry_after) { ... } ).
# :retry_on :: callable which alternatively defines a different rule for when a response is to be retried
# (i.e. ->(res) { ... }).
module OptionsMethods
def option_retry_after(value)
# return early if callable
unless value.respond_to?(:call)
value = Float(value)
raise TypeError, ":retry_after must be positive" unless value.positive?
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
def max_retries(n)
with(max_retries: n)
end
private
def fetch_response(request, connections, options)
response = super
if response &&
request.retries.positive? &&
__repeatable_request?(request, options) &&
(
(
response.is_a?(ErrorResponse) && __retryable_error?(response.error)
) ||
(
options.retry_on && options.retry_on.call(response)
)
)
__try_partial_retry(request, response)
log { "failed to get response, #{request.retries} tries to go..." }
request.retries -= 1
request.transition(:idle)
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..." }
deactivate_connection(request, connections, options)
pool.after(retry_after) do
if request.response
# request has terminated abruptly meanwhile
request.emit(:response, request.response)
else
log { "retrying (elapsed time: #{Utils.elapsed_time(retry_start)})!!" }
send_request(request, connections, options)
end
end
else
send_request(request, connections, options)
end
return
end
response
end
def __repeatable_request?(request, options)
IDEMPOTENT_METHODS.include?(request.verb) || options.retry_change_requests
end
def __retryable_error?(ex)
RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) }
end
def proxy_error?(request, response)
super && !request.retries.positive?
end
#
# Atttempt 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.close if response.respond_to?(:close)
return
end
request.partial_response = response
size = original_body.bytesize
request.headers["range"] = "bytes=#{size}-"
end
end
module RequestMethods
attr_accessor :retries
attr_writer :partial_response
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