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
|