typhoeus 1.6.0 → 1.7.0

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +217 -1
  3. data/README.md +338 -160
  4. data/UPGRADE.md +29 -0
  5. data/lib/rack/typhoeus/middleware/params_decoder/helper.rb +67 -14
  6. data/lib/rack/typhoeus/middleware/params_decoder.rb +8 -19
  7. data/lib/rack/typhoeus.rb +2 -0
  8. data/lib/typhoeus/adapters/faraday.rb +89 -28
  9. data/lib/typhoeus/cache/dalli.rb +2 -0
  10. data/lib/typhoeus/cache/rails.rb +3 -1
  11. data/lib/typhoeus/cache/redis.rb +2 -0
  12. data/lib/typhoeus/config.rb +51 -7
  13. data/lib/typhoeus/easy_factory.rb +129 -23
  14. data/lib/typhoeus/errors/invalid_option.rb +23 -0
  15. data/lib/typhoeus/errors/no_response.rb +18 -0
  16. data/lib/typhoeus/errors/no_stub.rb +8 -1
  17. data/lib/typhoeus/errors/typhoeus_error.rb +2 -0
  18. data/lib/typhoeus/errors.rb +4 -0
  19. data/lib/typhoeus/expectation.rb +33 -1
  20. data/lib/typhoeus/hydra/queueable.rb +29 -4
  21. data/lib/typhoeus/hydra.rb +349 -21
  22. data/lib/typhoeus/pool.rb +80 -17
  23. data/lib/typhoeus/railtie.rb +4 -2
  24. data/lib/typhoeus/request/actions.rb +2 -0
  25. data/lib/typhoeus/request/callbacks.rb +11 -4
  26. data/lib/typhoeus/request/marshal.rb +5 -2
  27. data/lib/typhoeus/request/streamable.rb +2 -0
  28. data/lib/typhoeus/request.rb +317 -30
  29. data/lib/typhoeus/response/cacheable.rb +2 -0
  30. data/lib/typhoeus/response/header.rb +234 -14
  31. data/lib/typhoeus/response/informations.rb +75 -12
  32. data/lib/typhoeus/response/status.rb +4 -13
  33. data/lib/typhoeus/response.rb +35 -0
  34. data/lib/typhoeus/version.rb +3 -1
  35. data/lib/typhoeus.rb +89 -26
  36. data/typhoeus.gemspec +15 -3
  37. metadata +19 -22
  38. data/lib/typhoeus/hydra/addable.rb +0 -23
  39. data/lib/typhoeus/hydra/before.rb +0 -31
  40. data/lib/typhoeus/hydra/block_connection.rb +0 -35
  41. data/lib/typhoeus/hydra/cacheable.rb +0 -15
  42. data/lib/typhoeus/hydra/memoizable.rb +0 -56
  43. data/lib/typhoeus/hydra/runnable.rb +0 -19
  44. data/lib/typhoeus/hydra/stubbable.rb +0 -28
  45. data/lib/typhoeus/request/before.rb +0 -30
  46. data/lib/typhoeus/request/block_connection.rb +0 -52
  47. data/lib/typhoeus/request/cacheable.rb +0 -38
  48. data/lib/typhoeus/request/memoizable.rb +0 -38
  49. data/lib/typhoeus/request/operations.rb +0 -40
  50. data/lib/typhoeus/request/responseable.rb +0 -29
  51. data/lib/typhoeus/request/stubbable.rb +0 -30
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'set'
4
+ require 'typhoeus/errors/invalid_option'
2
5
 
3
6
  module Typhoeus
4
7
 
@@ -35,8 +38,9 @@ module Typhoeus
35
38
 
36
39
  REMOVED_OPTIONS = Set.new([:cache_key_basis, :cache_timeout, :user_agent])
37
40
 
38
- SANITIZE_IGNORE = Set.new([:method, :cache_ttl, :cache])
41
+ SANITIZE_IGNORE = Set.new([:method, :cache_ttl, :cache, :set_read_callback])
39
42
  SANITIZE_TIMEOUT = Set.new([:timeout_ms, :connecttimeout_ms])
43
+ URL_OPTIONS = Set.new([:params, :params_encoding, :escape])
40
44
 
41
45
  # Returns the request provided.
42
46
  #
@@ -58,6 +62,16 @@ module Typhoeus
58
62
  def initialize(request, hydra = nil)
59
63
  @request = request
60
64
  @hydra = hydra
65
+ @released = false
66
+ end
67
+
68
+ # Returns whether the easy was already returned to the pool.
69
+ #
70
+ # @return [ Boolean ]
71
+ #
72
+ # @api private
73
+ def released?
74
+ @released
61
75
  end
62
76
 
63
77
  # Return the easy in question.
@@ -77,26 +91,53 @@ module Typhoeus
77
91
  #
78
92
  # @return [ Ethon::Easy ] The easy.
79
93
  def get
94
+ @released = false
80
95
  begin
81
- read_callback_body = request.options.delete(:set_read_callback)
96
+ read_callback_body = request.options[:set_read_callback]
97
+ easy = self.easy
98
+ url = request.base_url.to_s
99
+ options = sanitize(request.options)
100
+ options.delete(:params) if url.empty?
82
101
 
83
102
  easy.http_request(
84
- request.base_url.to_s,
103
+ url,
85
104
  request.options.fetch(:method, :get),
86
- sanitize(request.options)
105
+ options
87
106
  )
88
107
 
89
108
  # this needs to happen after http_request because
90
109
  # ethon will set infilesize to zero if form.empty?
91
110
  set_read_callback(read_callback_body) if !read_callback_body.nil?
92
- rescue Ethon::Errors::InvalidOption => e
93
- help = provide_help(e.message.match(/:\s(\w+)/)[1])
94
- raise $!, "#{$!}#{help}", $!.backtrace
111
+ set_callback
112
+ rescue Exception
113
+ release_easy(@easy) if @easy
114
+ raise
95
115
  end
96
- set_callback
97
116
  easy
98
117
  end
99
118
 
119
+ # Returns the full request URL without applying unrelated request options.
120
+ # This keeps introspection such as Request#url and the NoStub error working
121
+ # even when another option would be rejected by Ethon.
122
+ #
123
+ # @return [ String ] The URL including params.
124
+ def url
125
+ @released = false
126
+ easy = self.easy
127
+ begin
128
+ url = request.base_url.to_s
129
+ options = sanitize(
130
+ request.options.select { |key, _| URL_OPTIONS.include?(key.to_sym) }
131
+ )
132
+ options.delete(:params) if url.empty?
133
+
134
+ easy.http_request(url, :get, options)
135
+ easy.url
136
+ ensure
137
+ release_easy(easy)
138
+ end
139
+ end
140
+
100
141
  private
101
142
 
102
143
  def sanitize(options)
@@ -109,8 +150,21 @@ module Typhoeus
109
150
  if new_option = RENAMED_OPTIONS[k.to_sym]
110
151
  warn("Deprecated option #{k}. Please use #{new_option} instead.")
111
152
  sanitized[new_option] = v
153
+ elsif new_option = CHANGED_OPTIONS[s]
154
+ raise Errors::InvalidOption.new(
155
+ s,
156
+ "\nPlease try #{new_option} instead of #{s}."
157
+ )
158
+ elsif REMOVED_OPTIONS.include?(s)
159
+ raise Errors::InvalidOption.new(s, "\nThe option #{s} was removed.")
112
160
  # sanitize timeouts
113
161
  elsif SANITIZE_TIMEOUT.include?(s)
162
+ # An unset value means "no timeout" and never reaches Ethon.
163
+ next if v.nil?
164
+
165
+ unless v.is_a?(Numeric)
166
+ raise ArgumentError, "Value '#{v}' for option '#{k}' must be a number."
167
+ end
114
168
  if !v.integer?
115
169
  warn("Value '#{v}' for option '#{k}' must be integer.")
116
170
  end
@@ -128,15 +182,52 @@ module Typhoeus
128
182
 
129
183
  def sanitize_timeout!(options, timeout)
130
184
  timeout_ms = :"#{timeout}_ms"
131
- if options[timeout] && options[timeout].round != options[timeout]
185
+ value = options[timeout]
186
+ return options if value.nil?
187
+
188
+ unless value.is_a?(Numeric)
189
+ raise ArgumentError, "Value '#{value}' for option '#{timeout}' must be a number."
190
+ end
191
+
192
+ if value.round != value
132
193
  if !options[timeout_ms]
133
- options[timeout_ms] = (options[timeout]*1000).ceil
194
+ options[timeout_ms] = (value*1000).ceil
134
195
  end
135
- options[timeout] = options[timeout].ceil
196
+ options[timeout] = value.ceil
197
+ end
198
+
199
+ # CURLOPT_TIMEOUT and CURLOPT_TIMEOUT_MS set the same value, so libcurl
200
+ # uses the one that is applied last. Keep the millisecond option last:
201
+ # an explicit timeout_ms must also win over a timeout added from Config.
202
+ if options.key?(timeout_ms)
203
+ options[timeout_ms] = options.delete(timeout_ms)
136
204
  end
137
205
  options
138
206
  end
139
207
 
208
+ public
209
+
210
+ # Releases the easy into the pool once. Easies that used a custom
211
+ # resolve list are cleaned up instead because libcurl keeps the
212
+ # resolved addresses in its DNS cache across resets.
213
+ #
214
+ # @param [ Ethon::Easy ] easy The easy to release.
215
+ #
216
+ # @return [ void ]
217
+ #
218
+ # @api private
219
+ def release_easy(easy)
220
+ return if released?
221
+
222
+ begin
223
+ Typhoeus::Pool.release(easy, :cleanup => !request.options[:resolve].nil?)
224
+ ensure
225
+ @released = true
226
+ end
227
+ end
228
+
229
+ private
230
+
140
231
  # Sets on_complete callback on easy in order to be able to
141
232
  # track progress.
142
233
  #
@@ -161,14 +252,16 @@ module Typhoeus
161
252
  request.execute_headers_callbacks(Response.new(Ethon::Easy::Mirror.from_easy(easy).options))
162
253
  end
163
254
  end
164
- request.on_progress.each do |callback|
165
- easy.on_progress do |dltotal, dlnow, ultotal, ulnow, easy|
166
- callback.call(dltotal, dlnow, ultotal, ulnow, response)
167
- end
255
+ # Ethon calls progress callbacks with the four transfer counters.
256
+ (Typhoeus.on_progress + request.on_progress).each do |callback|
257
+ easy.on_progress(&callback)
168
258
  end
169
259
  easy.on_complete do |easy|
170
- request.finish(Response.new(easy.mirror.options))
171
- Typhoeus::Pool.release(easy)
260
+ begin
261
+ request.finish(Response.new(easy.mirror.options))
262
+ ensure
263
+ release_easy(easy)
264
+ end
172
265
  if hydra && !hydra.queued_requests.empty?
173
266
  hydra.dequeue_many
174
267
  end
@@ -182,6 +275,11 @@ module Typhoeus
182
275
  #
183
276
  # @return [ Ethon::Easy ] The easy.
184
277
  def set_read_callback(body)
278
+ # Uploading a file means sending it from the beginning. An IO that was
279
+ # already read from would otherwise be shorter than the advertised
280
+ # infilesize and libcurl would fail with a read error.
281
+ rewind_body(body)
282
+
185
283
  easy.infilesize_large =
186
284
  if body.respond_to?(:bytesize)
187
285
  body.bytesize
@@ -195,12 +293,20 @@ module Typhoeus
195
293
  easy
196
294
  end
197
295
 
198
- def provide_help(option)
199
- if new_option = CHANGED_OPTIONS[option.to_sym]
200
- "\nPlease try #{new_option} instead of #{option}." if new_option
201
- elsif REMOVED_OPTIONS.include?(option.to_sym)
202
- "\nThe option #{option} was removed."
203
- end
296
+ # Rewind a seekable body so the read callback starts at its first byte.
297
+ # Pipes and other non-seekable bodies do not respond to rewind.
298
+ #
299
+ # @param [ Object ] body The body to rewind.
300
+ #
301
+ # @return [ void ]
302
+ def rewind_body(body)
303
+ return unless body.respond_to?(:rewind)
304
+
305
+ body.rewind
306
+ rescue IOError, SystemCallError
307
+ # A body that cannot be rewound is still usable from its current
308
+ # position. Do not turn that into a request setup error.
309
+ nil
204
310
  end
205
311
  end
206
312
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typhoeus
4
+ module Errors
5
+
6
+ # Raised for options Typhoeus knows about but Ethon does not accept.
7
+ #
8
+ # It stays compatible with Ethon's InvalidOption while carrying the
9
+ # replacement or removal hint that Typhoeus adds.
10
+ class InvalidOption < Ethon::Errors::InvalidOption
11
+ def initialize(option, help = nil)
12
+ @help = help
13
+ super(option)
14
+ end
15
+
16
+ # Exception#message calls #to_s, so the hint is included everywhere the
17
+ # error is rendered and not only when #message is called explicitly.
18
+ def to_s
19
+ "#{super}#{@help}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typhoeus/errors/typhoeus_error'
4
+
5
+ module Typhoeus
6
+ module Errors
7
+
8
+ # Raised when a request matches a stub that does not provide a usable
9
+ # response.
10
+ class NoResponse < TyphoeusError
11
+ def initialize(base_url, reason = nil)
12
+ message = "No response defined for stub: #{base_url.inspect}"
13
+ message = "#{message} (#{reason})" if reason
14
+ super(message)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Typhoeus
2
4
  module Errors
3
5
 
@@ -5,7 +7,12 @@ module Typhoeus
5
7
  # and making a real request.
6
8
  class NoStub < TyphoeusError
7
9
  def initialize(request)
8
- super("The connection is blocked and no stub defined: #{request.url}")
10
+ url = begin
11
+ request.url
12
+ rescue StandardError
13
+ request.base_url
14
+ end
15
+ super("The connection is blocked and no stub defined: #{url}")
9
16
  end
10
17
  end
11
18
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Typhoeus
2
4
  module Errors
3
5
 
@@ -1,5 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'typhoeus/errors/typhoeus_error'
2
4
  require 'typhoeus/errors/no_stub'
5
+ require 'typhoeus/errors/no_response'
6
+ require 'typhoeus/errors/invalid_option'
3
7
 
4
8
  module Typhoeus
5
9
 
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typhoeus/errors/no_response'
4
+
1
5
  module Typhoeus
2
6
 
3
7
  # This class represents an expectation. It is part
@@ -49,6 +53,16 @@ module Typhoeus
49
53
  # @api private
50
54
  attr_reader :from
51
55
 
56
+ # Whenever an expectation is triggered this counter increases which can be used in testing to ensure that a defined stub is actually used.
57
+ #
58
+ # @example Stub a request and check if it was used
59
+ # expectation = Typhoeus.stub("www.example.com").and_return(Typhoeus::Response.new)
60
+ #
61
+ # Typhoeus.get("www.example.com")
62
+ # expectation.response_counter
63
+ # #=> 1
64
+ attr_reader :response_counter
65
+
52
66
  class << self
53
67
 
54
68
  # Returns all expectations.
@@ -179,11 +193,29 @@ module Typhoeus
179
193
  # @api private
180
194
  def response(request)
181
195
  response = responses.fetch(@response_counter, responses.last)
196
+ raise Errors::NoResponse.new(base_url) if response.nil?
197
+
182
198
  if response.respond_to?(:call)
183
199
  response = response.call(request)
200
+ if response.nil?
201
+ raise Errors::NoResponse.new(
202
+ base_url,
203
+ "the response block returned nil"
204
+ )
205
+ end
184
206
  end
207
+
208
+ unless response.is_a?(Response)
209
+ raise Errors::NoResponse.new(
210
+ base_url,
211
+ "expected a Typhoeus::Response, got #{response.class}"
212
+ )
213
+ end
214
+
185
215
  @response_counter += 1
186
- response.mock = @from || true
216
+ # A stub block can return the response of a real request, which must keep
217
+ # its real mock state and must not be finished a second time.
218
+ response.mock = @from || true unless response.request
187
219
  response
188
220
  end
189
221
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Typhoeus
2
4
  class Hydra
3
5
 
@@ -67,17 +69,40 @@ module Typhoeus
67
69
  # adds them to the hydra until max_concurrency
68
70
  # is reached.
69
71
  #
72
+ # Short-circuited requests call this method while it is already
73
+ # draining the queue. The guard keeps that from recursing.
74
+ #
70
75
  # @example Dequeue requests.
71
76
  # hydra.dequeue_many
72
77
  #
73
78
  # @since 0.6.8
74
79
  def dequeue_many
75
- number = multi.easy_handles.count
76
- until number == max_concurrency || queued_requests.empty?
77
- add(queued_requests.shift)
78
- number += 1
80
+ return if @dequeuing_many
81
+
82
+ begin
83
+ @dequeuing_many = true
84
+ until queued_requests.empty?
85
+ break if easy_handle_count >= max_concurrency
86
+
87
+ add(queued_requests.shift)
88
+ end
89
+ ensure
90
+ @dequeuing_many = false
79
91
  end
80
92
  end
93
+
94
+ private
95
+
96
+ # Count the attached easies without creating the native multi handle.
97
+ # Requests that a before callback, a stub, or the cache answers never
98
+ # need a multi, and Ethon 0.19 rejects a handle created before a fork.
99
+ #
100
+ # @return [ Integer ] The number of running easy handles.
101
+ def easy_handle_count
102
+ return 0 unless defined?(@multi) && @multi
103
+
104
+ multi.easy_handles.count
105
+ end
81
106
  end
82
107
  end
83
108
  end