httpx 1.6.3 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/0_11_0.md +3 -3
  3. data/doc/release_notes/1_6_3.md +2 -2
  4. data/doc/release_notes/1_7_0.md +149 -0
  5. data/doc/release_notes/1_7_1.md +21 -0
  6. data/lib/httpx/adapters/datadog.rb +1 -1
  7. data/lib/httpx/adapters/faraday.rb +1 -1
  8. data/lib/httpx/adapters/webmock.rb +18 -9
  9. data/lib/httpx/altsvc.rb +4 -2
  10. data/lib/httpx/connection/http1.rb +9 -9
  11. data/lib/httpx/connection/http2.rb +2 -0
  12. data/lib/httpx/connection.rb +7 -9
  13. data/lib/httpx/domain_name.rb +1 -1
  14. data/lib/httpx/headers.rb +2 -2
  15. data/lib/httpx/io/tcp.rb +1 -1
  16. data/lib/httpx/loggable.rb +2 -0
  17. data/lib/httpx/options.rb +118 -22
  18. data/lib/httpx/parser/http1.rb +1 -0
  19. data/lib/httpx/plugins/auth/digest.rb +44 -4
  20. data/lib/httpx/plugins/auth.rb +113 -4
  21. data/lib/httpx/plugins/aws_sdk_authentication.rb +0 -1
  22. data/lib/httpx/plugins/cookies/cookie.rb +1 -0
  23. data/lib/httpx/plugins/digest_auth.rb +4 -5
  24. data/lib/httpx/plugins/fiber_concurrency.rb +16 -1
  25. data/lib/httpx/plugins/grpc/grpc_encoding.rb +1 -1
  26. data/lib/httpx/plugins/grpc.rb +2 -2
  27. data/lib/httpx/plugins/internal_telemetry.rb +1 -1
  28. data/lib/httpx/plugins/ntlm_auth.rb +5 -3
  29. data/lib/httpx/plugins/oauth.rb +156 -57
  30. data/lib/httpx/plugins/persistent.rb +3 -5
  31. data/lib/httpx/plugins/proxy/http.rb +0 -4
  32. data/lib/httpx/plugins/proxy.rb +3 -1
  33. data/lib/httpx/plugins/query.rb +1 -1
  34. data/lib/httpx/plugins/rate_limiter.rb +20 -15
  35. data/lib/httpx/plugins/response_cache.rb +3 -7
  36. data/lib/httpx/plugins/retries.rb +60 -24
  37. data/lib/httpx/plugins/ssrf_filter.rb +1 -1
  38. data/lib/httpx/plugins/stream.rb +60 -9
  39. data/lib/httpx/plugins/stream_bidi.rb +84 -16
  40. data/lib/httpx/pool.rb +12 -3
  41. data/lib/httpx/request/body.rb +1 -1
  42. data/lib/httpx/request.rb +10 -1
  43. data/lib/httpx/resolver/cache/base.rb +136 -0
  44. data/lib/httpx/resolver/cache/memory.rb +42 -0
  45. data/lib/httpx/resolver/cache.rb +18 -0
  46. data/lib/httpx/resolver/https.rb +74 -20
  47. data/lib/httpx/resolver/multi.rb +10 -2
  48. data/lib/httpx/resolver/native.rb +32 -6
  49. data/lib/httpx/resolver/resolver.rb +3 -3
  50. data/lib/httpx/resolver.rb +36 -114
  51. data/lib/httpx/response/body.rb +5 -3
  52. data/lib/httpx/response.rb +22 -6
  53. data/lib/httpx/selector.rb +14 -3
  54. data/lib/httpx/session.rb +6 -6
  55. data/lib/httpx/timers.rb +6 -12
  56. data/lib/httpx/transcoder/body.rb +1 -1
  57. data/lib/httpx/transcoder/gzip.rb +7 -2
  58. data/lib/httpx/transcoder/json.rb +1 -1
  59. data/lib/httpx/transcoder/multipart/decoder.rb +5 -5
  60. data/lib/httpx/transcoder/multipart/encoder.rb +1 -1
  61. data/lib/httpx/transcoder/multipart.rb +17 -9
  62. data/lib/httpx/transcoder.rb +4 -6
  63. data/lib/httpx/utils.rb +13 -0
  64. data/lib/httpx/version.rb +1 -1
  65. data/sig/altsvc.rbs +9 -3
  66. data/sig/chainable.rbs +3 -3
  67. data/sig/connection.rbs +1 -3
  68. data/sig/loggable.rbs +1 -1
  69. data/sig/options.rbs +12 -4
  70. data/sig/plugins/auth/digest.rbs +6 -0
  71. data/sig/plugins/auth.rbs +37 -4
  72. data/sig/plugins/basic_auth.rbs +3 -3
  73. data/sig/plugins/digest_auth.rbs +2 -4
  74. data/sig/plugins/fiber_concurrency.rbs +6 -0
  75. data/sig/plugins/ntlm_auth.rbs +2 -2
  76. data/sig/plugins/oauth.rbs +44 -15
  77. data/sig/plugins/rate_limiter.rbs +4 -2
  78. data/sig/plugins/response_cache/file_store.rbs +2 -0
  79. data/sig/plugins/response_cache.rbs +4 -0
  80. data/sig/plugins/retries.rbs +12 -4
  81. data/sig/plugins/stream.rbs +13 -3
  82. data/sig/plugins/stream_bidi.rbs +2 -2
  83. data/sig/pool.rbs +1 -1
  84. data/sig/resolver/cache/base.rbs +28 -0
  85. data/sig/resolver/cache/memory.rbs +13 -0
  86. data/sig/resolver/cache.rbs +16 -0
  87. data/sig/resolver/https.rbs +24 -0
  88. data/sig/resolver/multi.rbs +8 -0
  89. data/sig/resolver/native.rbs +2 -0
  90. data/sig/resolver.rbs +5 -20
  91. data/sig/response.rbs +3 -0
  92. data/sig/session.rbs +3 -5
  93. data/sig/timers.rbs +1 -1
  94. data/sig/transcoder/multipart.rbs +4 -2
  95. data/sig/transcoder.rbs +5 -1
  96. data/sig/utils.rbs +2 -0
  97. metadata +11 -1
@@ -2,21 +2,38 @@
2
2
 
3
3
  module HTTPX
4
4
  module Plugins
5
+ #
6
+ # This plugin adds support for managing an OAuth Session associated with the given session.
7
+ #
8
+ # The scope of OAuth support is limited to the `client_crendentials` and `refresh_token` grants.
5
9
  #
6
10
  # https://gitlab.com/os85/httpx/wikis/OAuth
7
11
  #
8
12
  module OAuth
9
13
  class << self
10
- def load_dependencies(_klass)
14
+ def load_dependencies(klass)
11
15
  require_relative "auth/basic"
16
+ klass.plugin(:auth)
17
+ end
18
+
19
+ def subplugins
20
+ {
21
+ retries: OAuthRetries,
22
+ }
23
+ end
24
+
25
+ def extra_options(options)
26
+ options.merge(auth_header_type: "Bearer")
12
27
  end
13
28
  end
14
29
 
15
30
  SUPPORTED_GRANT_TYPES = %w[client_credentials refresh_token].freeze
16
31
  SUPPORTED_AUTH_METHODS = %w[client_secret_basic client_secret_post].freeze
17
32
 
33
+ # Implements the bulk of functionality and maintains the state associated with the
34
+ # management of the the lifecycle of an OAuth session.
18
35
  class OAuthSession
19
- attr_reader :grant_type, :client_id, :client_secret, :access_token, :refresh_token, :scope, :audience
36
+ attr_reader :access_token, :refresh_token
20
37
 
21
38
  def initialize(
22
39
  issuer:,
@@ -27,7 +44,6 @@ module HTTPX
27
44
  scope: nil,
28
45
  audience: nil,
29
46
  token_endpoint: nil,
30
- response_type: nil,
31
47
  grant_type: nil,
32
48
  token_endpoint_auth_method: nil
33
49
  )
@@ -35,7 +51,6 @@ module HTTPX
35
51
  @client_id = client_id
36
52
  @client_secret = client_secret
37
53
  @token_endpoint = URI(token_endpoint) if token_endpoint
38
- @response_type = response_type
39
54
  @scope = case scope
40
55
  when String
41
56
  scope.split
@@ -47,6 +62,8 @@ module HTTPX
47
62
  @refresh_token = refresh_token
48
63
  @token_endpoint_auth_method = String(token_endpoint_auth_method) if token_endpoint_auth_method
49
64
  @grant_type = grant_type || (@refresh_token ? "refresh_token" : "client_credentials")
65
+ @access_token = access_token
66
+ @refresh_token = refresh_token
50
67
 
51
68
  unless @token_endpoint_auth_method.nil? || SUPPORTED_AUTH_METHODS.include?(@token_endpoint_auth_method)
52
69
  raise Error, "#{@token_endpoint_auth_method} is not a supported auth method"
@@ -57,28 +74,75 @@ module HTTPX
57
74
  raise Error, "#{@grant_type} is not a supported grant type"
58
75
  end
59
76
 
77
+ # returns the URL where to request access and refresh tokens from.
60
78
  def token_endpoint
61
79
  @token_endpoint || "#{@issuer}/token"
62
80
  end
63
81
 
82
+ # returns the oauth-documented authorization method to use when requesting a token.
64
83
  def token_endpoint_auth_method
65
84
  @token_endpoint_auth_method || "client_secret_basic"
66
85
  end
67
86
 
68
- def load(http)
69
- return if @grant_type && @scope
87
+ def reset!
88
+ @access_token = nil
89
+ end
70
90
 
71
- metadata = http.get("#{@issuer}/.well-known/oauth-authorization-server").raise_for_status.json
91
+ # when not available, it uses the +http+ object to request new access and refresh tokens.
92
+ def fetch_access_token(http)
93
+ return access_token if access_token
72
94
 
73
- @token_endpoint = metadata["token_endpoint"]
74
- @scope = metadata["scopes_supported"]
75
- @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
76
- @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
77
- SUPPORTED_AUTH_METHODS.include?(am)
95
+ load(http)
96
+
97
+ # always prefer refresh token grant if a refresh token is available
98
+ grant_type = @refresh_token ? "refresh_token" : @grant_type
99
+
100
+ headers = {} # : Hash[String ,String]
101
+ form_post = {
102
+ "grant_type" => @grant_type,
103
+ "scope" => Array(@scope).join(" "),
104
+ "audience" => @audience,
105
+ }.compact
106
+
107
+ # auth
108
+ case token_endpoint_auth_method
109
+ when "client_secret_post"
110
+ form_post["client_id"] = @client_id
111
+ form_post["client_secret"] = @client_secret
112
+ when "client_secret_basic"
113
+ headers["authorization"] = Authentication::Basic.new(@client_id, @client_secret).authenticate
78
114
  end
79
- nil
115
+
116
+ case grant_type
117
+ when "client_credentials"
118
+ # do nothing
119
+ when "refresh_token"
120
+ raise Error, "cannot use the `\"refresh_token\"` grant type without a refresh token" unless refresh_token
121
+
122
+ form_post["refresh_token"] = refresh_token
123
+ end
124
+
125
+ # POST /token
126
+ token_request = http.build_request("POST", token_endpoint, headers: headers, form: form_post)
127
+
128
+ token_request.headers.delete("authorization") unless token_endpoint_auth_method == "client_secret_basic"
129
+
130
+ token_response = http.skip_auth_header { http.request(token_request) }
131
+
132
+ begin
133
+ token_response.raise_for_status
134
+ rescue HTTPError => e
135
+ @refresh_token = nil if e.response.status == 401 && (grant_type == "refresh_token")
136
+ raise e
137
+ end
138
+
139
+ payload = token_response.json
140
+
141
+ @refresh_token = payload["refresh_token"] || @refresh_token
142
+ @access_token = payload["access_token"]
80
143
  end
81
144
 
145
+ # TODO: remove this after deprecating the `:oauth_session` option
82
146
  def merge(other)
83
147
  obj = dup
84
148
 
@@ -97,12 +161,36 @@ module HTTPX
97
161
  end
98
162
  obj
99
163
  end
164
+
165
+ private
166
+
167
+ # uses +http+ to fetch for the oauth server metadata.
168
+ def load(http)
169
+ return if @grant_type && @scope
170
+
171
+ metadata = http.skip_auth_header { http.get("#{@issuer}/.well-known/oauth-authorization-server").raise_for_status.json }
172
+
173
+ @token_endpoint = metadata["token_endpoint"]
174
+ @scope = metadata["scopes_supported"]
175
+ @grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
176
+ @token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
177
+ SUPPORTED_AUTH_METHODS.include?(am)
178
+ end
179
+ nil
180
+ end
100
181
  end
101
182
 
183
+ # adds support for the following options:
184
+ #
185
+ # :oauth_options :: an hash of options to be used during session management.
186
+ # check the parameters to initialize the OAuthSession class.
102
187
  module OptionsMethods
103
188
  private
104
189
 
105
190
  def option_oauth_session(value)
191
+ warn "DEPRECATION WARNING: option `:oauth_session` is deprecated. " \
192
+ "Use `:oauth_options` instead."
193
+
106
194
  case value
107
195
  when Hash
108
196
  OAuthSession.new(**value)
@@ -112,69 +200,80 @@ module HTTPX
112
200
  raise TypeError, ":oauth_session must be a #{OAuthSession}"
113
201
  end
114
202
  end
115
- end
116
203
 
117
- module InstanceMethods
118
- def oauth_auth(**args)
119
- with(oauth_session: OAuthSession.new(**args))
204
+ def option_oauth_options(value)
205
+ value = Hash[value] unless value.is_a?(Hash)
206
+ value
120
207
  end
208
+ end
121
209
 
122
- def with_access_token
123
- oauth_session = @options.oauth_session
124
-
125
- oauth_session.load(self)
126
-
127
- grant_type = oauth_session.grant_type
210
+ module InstanceMethods
211
+ attr_reader :oauth_session
212
+ protected :oauth_session
128
213
 
129
- headers = {}
130
- form_post = {
131
- "grant_type" => grant_type,
132
- "scope" => Array(oauth_session.scope).join(" "),
133
- "audience" => oauth_session.audience,
134
- }.compact
214
+ def initialize(*)
215
+ super
135
216
 
136
- # auth
137
- case oauth_session.token_endpoint_auth_method
138
- when "client_secret_post"
139
- form_post["client_id"] = oauth_session.client_id
140
- form_post["client_secret"] = oauth_session.client_secret
141
- when "client_secret_basic"
142
- headers["authorization"] = Authentication::Basic.new(oauth_session.client_id, oauth_session.client_secret).authenticate
217
+ @oauth_session = if @options.oauth_options
218
+ OAuthSession.new(**@options.oauth_options)
219
+ elsif @options.oauth_session
220
+ @oauth_session = @options.oauth_session.dup
143
221
  end
222
+ end
144
223
 
145
- case grant_type
146
- when "client_credentials"
147
- # do nothing
148
- when "refresh_token"
149
- form_post["refresh_token"] = oauth_session.refresh_token
150
- end
224
+ def initialize_dup(other)
225
+ super
226
+ @oauth_session = other.instance_variable_get(:@oauth_session).dup
227
+ end
228
+
229
+ def oauth_auth(**args)
230
+ warn "DEPRECATION WARNING: `#{__method__}` is deprecated. " \
231
+ "Use `with(oauth_options: options)` instead."
151
232
 
152
- token_request = build_request("POST", oauth_session.token_endpoint, headers: headers, form: form_post)
153
- token_request.headers.delete("authorization") unless oauth_session.token_endpoint_auth_method == "client_secret_basic"
233
+ with(oauth_options: args)
234
+ end
154
235
 
155
- token_response = request(token_request)
156
- token_response.raise_for_status
236
+ # will eagerly negotiate new oauth tokens with the issuer
237
+ def refresh_oauth_tokens!
238
+ return unless @oauth_session
157
239
 
158
- payload = token_response.json
240
+ @oauth_session.reset!
241
+ @oauth_session.fetch_access_token(self)
242
+ end
159
243
 
160
- access_token = payload["access_token"]
161
- refresh_token = payload["refresh_token"]
244
+ # TODO: deprecate
245
+ def with_access_token
246
+ warn "DEPRECATION WARNING: `#{__method__}` is deprecated. " \
247
+ "The session will automatically handle token lifecycles for you."
162
248
 
163
- with(oauth_session: oauth_session.merge(access_token: access_token, refresh_token: refresh_token))
249
+ other_session = dup # : instance
250
+ oauth_session = other_session.oauth_session
251
+ oauth_session.fetch_access_token(other_session)
252
+ other_session
164
253
  end
165
254
 
166
- def build_request(*)
167
- request = super
255
+ private
168
256
 
169
- return request if request.headers.key?("authorization")
257
+ def generate_auth_token
258
+ return unless @oauth_session
170
259
 
171
- oauth_session = @options.oauth_session
260
+ @oauth_session.fetch_access_token(self)
261
+ end
172
262
 
173
- return request unless oauth_session && oauth_session.access_token
263
+ def dynamic_auth_token?(_)
264
+ @oauth_session
265
+ end
266
+ end
174
267
 
175
- request.headers["authorization"] = "Bearer #{oauth_session.access_token}"
268
+ module OAuthRetries
269
+ module InstanceMethods
270
+ private
176
271
 
177
- request
272
+ def prepare_to_retry(_request, response)
273
+ @oauth_session.reset! if @oauth_session
274
+
275
+ super
276
+ end
178
277
  end
179
278
  end
180
279
  end
@@ -55,10 +55,8 @@ module HTTPX
55
55
 
56
56
  private
57
57
 
58
- def repeatable_request?(request, _)
58
+ def retryable_request?(request, response, *)
59
59
  super || begin
60
- response = request.response
61
-
62
60
  return false unless response && response.is_a?(ErrorResponse)
63
61
 
64
62
  error = response.error
@@ -67,13 +65,13 @@ module HTTPX
67
65
  end
68
66
  end
69
67
 
70
- def retryable_error?(ex)
68
+ def retryable_error?(ex, options)
71
69
  super &&
72
70
  # under the persistent plugin rules, requests are only retried for connection related errors,
73
71
  # which do not include request timeout related errors. This only gets overriden if the end user
74
72
  # manually changed +:max_retries+ to something else, which means it is aware of the
75
73
  # consequences.
76
- (!ex.is_a?(RequestTimeoutError) || @options.max_retries != 1)
74
+ (!ex.is_a?(RequestTimeoutError) || options.max_retries != 1)
77
75
  end
78
76
  end
79
77
  end
@@ -43,10 +43,6 @@ module HTTPX
43
43
  end
44
44
 
45
45
  module ConnectionMethods
46
- def connecting?
47
- super || @state == :connecting || @state == :connected
48
- end
49
-
50
46
  def force_close(*)
51
47
  if @state == :connecting
52
48
  # proxy connect related requests should not be reenqueed
@@ -326,7 +326,9 @@ module HTTPX
326
326
 
327
327
  module ProxyRetries
328
328
  module InstanceMethods
329
- def retryable_error?(ex)
329
+ private
330
+
331
+ def retryable_error?(ex, *)
330
332
  super || ex.is_a?(ProxyConnectionError)
331
333
  end
332
334
  end
@@ -23,7 +23,7 @@ module HTTPX
23
23
  module InstanceMethods
24
24
  private
25
25
 
26
- def repeatable_request?(request, options)
26
+ def retryable_request?(request, *)
27
27
  super || request.verb == "QUERY"
28
28
  end
29
29
  end
@@ -12,22 +12,11 @@ module HTTPX
12
12
  # https://gitlab.com/os85/httpx/wikis/Rate-Limiter
13
13
  #
14
14
  module RateLimiter
15
- class << self
16
- RATE_LIMIT_CODES = [429, 503].freeze
17
-
18
- def configure(klass)
19
- klass.plugin(:retries,
20
- retry_change_requests: true,
21
- retry_on: method(:retry_on_rate_limited_response),
22
- retry_after: method(:retry_after_rate_limit))
23
- end
24
-
25
- def retry_on_rate_limited_response(response)
26
- return false unless response.is_a?(Response)
15
+ RATE_LIMIT_CODES = [429, 503].freeze
27
16
 
28
- status = response.status
29
-
30
- RATE_LIMIT_CODES.include?(status)
17
+ class << self
18
+ def load_dependencies(klass)
19
+ klass.plugin(:retries, retry_after: method(:retry_after_rate_limit))
31
20
  end
32
21
 
33
22
  # Servers send the "Retry-After" header field to indicate how long the
@@ -48,6 +37,22 @@ module HTTPX
48
37
  Utils.parse_retry_after(retry_after)
49
38
  end
50
39
  end
40
+
41
+ module InstanceMethods
42
+ private
43
+
44
+ def retryable_request?(request, response, options)
45
+ super || rate_limit_error?(response)
46
+ end
47
+
48
+ def retryable_response?(response, options)
49
+ rate_limit_error?(response) || super
50
+ end
51
+
52
+ def rate_limit_error?(response)
53
+ response.is_a?(Response) && RATE_LIMIT_CODES.include?(response.status)
54
+ end
55
+ end
51
56
  end
52
57
 
53
58
  register_plugin :rate_limiter, RateLimiter
@@ -150,7 +150,7 @@ module HTTPX
150
150
  request.headers.add("if-modified-since", last_modified)
151
151
  end
152
152
 
153
- if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"]) # rubocop:disable Style/GuardClause
153
+ if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"])
154
154
  request.headers.add("if-none-match", etag)
155
155
  end
156
156
  end
@@ -296,9 +296,7 @@ module HTTPX
296
296
  return @cache_control if defined?(@cache_control)
297
297
 
298
298
  @cache_control = begin
299
- return unless @headers.key?("cache-control")
300
-
301
- @headers["cache-control"].split(/ *, */)
299
+ @headers["cache-control"].split(/ *, */) if @headers.key?("cache-control")
302
300
  end
303
301
  end
304
302
 
@@ -307,9 +305,7 @@ module HTTPX
307
305
  return @vary if defined?(@vary)
308
306
 
309
307
  @vary = begin
310
- return unless @headers.key?("vary")
311
-
312
- @headers["vary"].split(/ *, */).map(&:downcase)
308
+ @headers["vary"].split(/ *, */).map(&:downcase) if @headers.key?("vary")
313
309
  end
314
310
  end
315
311
 
@@ -36,15 +36,33 @@ module HTTPX
36
36
  Parser::Error,
37
37
  TimeoutError,
38
38
  ]).freeze
39
- DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }
40
39
 
41
- if ENV.key?("HTTPX_NO_JITTER")
42
- def self.extra_options(options)
43
- options.merge(max_retries: MAX_RETRIES)
40
+ DEFAULT_JITTER = ->(interval) { interval * ((rand + 1) * 0.5) }.freeze
41
+
42
+ # list of supported backoff algorithms
43
+ BACKOFF_ALGORITHMS = %i[exponential_backoff polynomial_backoff].freeze
44
+
45
+ class << self
46
+ if ENV.key?("HTTPX_NO_JITTER")
47
+ def extra_options(options)
48
+ options.merge(max_retries: MAX_RETRIES)
49
+ end
50
+ else
51
+ def extra_options(options)
52
+ options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
53
+ end
44
54
  end
45
- else
46
- def self.extra_options(options)
47
- options.merge(max_retries: MAX_RETRIES, retry_jitter: DEFAULT_JITTER)
55
+
56
+ # returns the time to wait before resending +request+ as per the polynomial backoff retry strategy.
57
+ def retry_after_polynomial_backoff(request, _)
58
+ offset = request.options.max_retries - request.retries
59
+ 2 * (offset - 1)
60
+ end
61
+
62
+ # returns the time to wait before resending +request+ as per the exponential backoff retry strategy.
63
+ def retry_after_exponential_backoff(request, _)
64
+ offset = request.options.max_retries - request.retries
65
+ (offset - 1) * 2
48
66
  end
49
67
  end
50
68
 
@@ -53,6 +71,7 @@ module HTTPX
53
71
  # :max_retries :: max number of times a request will be retried (defaults to <tt>3</tt>).
54
72
  # :retry_change_requests :: whether idempotent requests are retried (defaults to <tt>false</tt>).
55
73
  # :retry_after:: seconds after which a request is retried; can also be a callable object (i.e. <tt>->(req, res) { ... } </tt>)
74
+ # or the name of a supported backoff algorithm (i.e. <tt>:exponential_backoff</tt>).
56
75
  # :retry_jitter :: number of seconds applied to *:retry_after* (must be a callable, i.e. <tt>->(retry_after) { ... } </tt>).
57
76
  # :retry_on :: callable which alternatively defines a different rule for when a response is to be retried
58
77
  # (i.e. <tt>->(res) { ... }</tt>).
@@ -60,10 +79,26 @@ module HTTPX
60
79
  private
61
80
 
62
81
  def option_retry_after(value)
63
- # return early if callable
64
- unless value.respond_to?(:call)
65
- value = Float(value)
66
- raise TypeError, ":retry_after must be positive" unless value.positive?
82
+ if value.respond_to?(:call)
83
+ value1 = value
84
+ value1 = value1.method(:call) unless value1.respond_to?(:arity)
85
+
86
+ # allow ->(*) arity as well, which is < 0
87
+ raise TypeError, "`:retry_after` proc has invalid number of parameters" unless value1.arity.negative? || value1.arity.between?(
88
+ 1, 2
89
+ )
90
+
91
+ else
92
+ case value
93
+ when Symbol
94
+ raise TypeError, "`retry_after`: `#{value}` is not a supported backoff algorithm" unless BACKOFF_ALGORITHMS.include?(value)
95
+
96
+ value = Retries.method(:"retry_after_#{value}")
97
+
98
+ else
99
+ value = Float(value)
100
+ raise TypeError, "`:retry_after` must be positive" unless value.positive?
101
+ end
67
102
  end
68
103
 
69
104
  value
@@ -107,19 +142,11 @@ module HTTPX
107
142
 
108
143
  if response &&
109
144
  request.retries.positive? &&
110
- repeatable_request?(request, options) &&
111
- (
112
- (
113
- response.is_a?(ErrorResponse) && retryable_error?(response.error)
114
- ) ||
115
- (
116
- options.retry_on && options.retry_on.call(response)
117
- )
118
- )
145
+ retryable_request?(request, response, options) &&
146
+ retryable_response?(response, options)
119
147
  try_partial_retry(request, response)
120
148
  log { "failed to get response, #{request.retries} tries to go..." }
121
- request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
122
- request.transition(:idle)
149
+ prepare_to_retry(request, response)
123
150
 
124
151
  retry_after = options.retry_after
125
152
  retry_after = retry_after.call(request, response) if retry_after.respond_to?(:call)
@@ -152,12 +179,16 @@ module HTTPX
152
179
  end
153
180
 
154
181
  # returns whether +request+ can be retried.
155
- def repeatable_request?(request, options)
182
+ def retryable_request?(request, _, options)
156
183
  IDEMPOTENT_METHODS.include?(request.verb) || options.retry_change_requests
157
184
  end
158
185
 
186
+ def retryable_response?(response, options)
187
+ (response.is_a?(ErrorResponse) && retryable_error?(response.error, options)) || options.retry_on&.call(response)
188
+ end
189
+
159
190
  # returns whether the +ex+ exception happend for a retriable request.
160
- def retryable_error?(ex)
191
+ def retryable_error?(ex, _)
161
192
  RETRYABLE_ERRORS.any? { |klass| ex.is_a?(klass) }
162
193
  end
163
194
 
@@ -165,6 +196,11 @@ module HTTPX
165
196
  super && !request.retries.positive?
166
197
  end
167
198
 
199
+ def prepare_to_retry(request, _response)
200
+ request.retries -= 1 unless request.ping? # do not exhaust retries on connection liveness probes
201
+ request.transition(:idle)
202
+ end
203
+
168
204
  #
169
205
  # Attempt to set the request to perform a partial range request.
170
206
  # This happens if the peer server accepts byte-range requests, and
@@ -16,7 +16,7 @@ module HTTPX
16
16
  mask_addr = @mask_addr
17
17
  raise "Invalid mask" if mask_addr.zero?
18
18
 
19
- mask_addr >>= 1 while (mask_addr & 0x1).zero?
19
+ mask_addr >>= 1 while mask_addr.nobits?(0x1)
20
20
 
21
21
  length = 0
22
22
  while mask_addr & 0x1 == 0x1