httpx 0.20.3 → 0.21.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/0_13_0.md +1 -1
  3. data/doc/release_notes/0_20_4.md +17 -0
  4. data/doc/release_notes/0_20_5.md +3 -0
  5. data/doc/release_notes/0_21_0.md +94 -0
  6. data/lib/httpx/connection/http1.rb +2 -1
  7. data/lib/httpx/connection.rb +41 -2
  8. data/lib/httpx/errors.rb +18 -0
  9. data/lib/httpx/extensions.rb +36 -13
  10. data/lib/httpx/io/unix.rb +1 -1
  11. data/lib/httpx/options.rb +7 -3
  12. data/lib/httpx/plugins/circuit_breaker/circuit.rb +76 -0
  13. data/lib/httpx/plugins/circuit_breaker/circuit_store.rb +44 -0
  14. data/lib/httpx/plugins/circuit_breaker.rb +115 -0
  15. data/lib/httpx/plugins/compression.rb +1 -1
  16. data/lib/httpx/plugins/cookies.rb +1 -1
  17. data/lib/httpx/plugins/expect.rb +1 -1
  18. data/lib/httpx/plugins/multipart/decoder.rb +1 -1
  19. data/lib/httpx/plugins/proxy.rb +7 -1
  20. data/lib/httpx/plugins/response_cache/store.rb +39 -19
  21. data/lib/httpx/plugins/response_cache.rb +98 -8
  22. data/lib/httpx/plugins/retries.rb +1 -1
  23. data/lib/httpx/plugins/webdav.rb +78 -0
  24. data/lib/httpx/pool.rb +1 -1
  25. data/lib/httpx/request.rb +15 -25
  26. data/lib/httpx/resolver/https.rb +2 -7
  27. data/lib/httpx/resolver/multi.rb +1 -1
  28. data/lib/httpx/resolver/native.rb +2 -1
  29. data/lib/httpx/resolver/resolver.rb +12 -2
  30. data/lib/httpx/response.rb +30 -9
  31. data/lib/httpx/timers.rb +3 -0
  32. data/lib/httpx/transcoder/body.rb +1 -1
  33. data/lib/httpx/transcoder/form.rb +1 -1
  34. data/lib/httpx/transcoder/json.rb +19 -3
  35. data/lib/httpx/transcoder/xml.rb +57 -0
  36. data/lib/httpx/transcoder.rb +1 -0
  37. data/lib/httpx/version.rb +1 -1
  38. data/sig/buffer.rbs +1 -1
  39. data/sig/chainable.rbs +1 -0
  40. data/sig/connection.rbs +12 -4
  41. data/sig/errors.rbs +13 -0
  42. data/sig/io.rbs +6 -0
  43. data/sig/options.rbs +4 -1
  44. data/sig/plugins/circuit_breaker.rbs +61 -0
  45. data/sig/plugins/compression/brotli.rbs +1 -1
  46. data/sig/plugins/compression/deflate.rbs +1 -1
  47. data/sig/plugins/compression/gzip.rbs +3 -3
  48. data/sig/plugins/compression.rbs +1 -1
  49. data/sig/plugins/multipart.rbs +1 -1
  50. data/sig/plugins/proxy/socks5.rbs +3 -2
  51. data/sig/plugins/proxy.rbs +1 -1
  52. data/sig/plugins/response_cache.rbs +24 -4
  53. data/sig/registry.rbs +5 -4
  54. data/sig/request.rbs +7 -1
  55. data/sig/resolver/native.rbs +5 -2
  56. data/sig/response.rbs +6 -1
  57. data/sig/timers.rbs +1 -1
  58. data/sig/transcoder/json.rbs +4 -1
  59. data/sig/transcoder/xml.rbs +21 -0
  60. data/sig/transcoder.rbs +2 -2
  61. data/sig/utils.rbs +2 -2
  62. metadata +17 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 543163347ad58a18829298d36113f88da710c5b139c14968962cdd88d9d36c86
4
- data.tar.gz: 1a460e4e2f079f6b08ede7ad7076039ead2b6fc31cffb590e4c245d38e838dc5
3
+ metadata.gz: c62cb1d027ee7e62770de459b00411614372578117ea56354df0d9114cf4397b
4
+ data.tar.gz: 18275725fc8adeac596f02f83f034dfcfe24a7e4c96ec34bc11400e8ca0e9567
5
5
  SHA512:
6
- metadata.gz: 7bf4ccdbefc71b868b51ff130cb0892791935dade7829ffe09b3401dbba112fd354a8781da8d58015afb254206ac8a5187477ac65bdaf09b996909ef2e42f93c
7
- data.tar.gz: a06649e2a51aebe113e3604e739916f164a30aa643eb7e2a5f82871929d2ce1c9bb8b9f234c2ac2ce07f3bd995058e8ba7f1b42e5eaf5cb63fe205b753184b0b
6
+ metadata.gz: 840bf59a4cbbb45d26a836ccd4060007470d380314b4aedea58e49cb434b050a8252f7db3823daf9c2dfdb5d54075215e4ee1216c80852afbb7abfd57e1dfea2
7
+ data.tar.gz: 35a666b92236240e8ccd09b1dc29a2ccf83061222b0511d35eee6fb7f3794dc737ca86ac9ca7d87ba01e03d8e648b07e6e52290b02b25674bd783b7f9d2cfbce
@@ -34,7 +34,7 @@ HTTPX.get("http://example.com", addresses: %w[172.5.3.1 172.5.3.2]))
34
34
  You should also use it to connect to HTTP servers bound to a UNIX socket, in which case you'll have to provide a path:
35
35
 
36
36
  ```ruby
37
- HTTPX.get("http://example.com", addresses: %w[/path/to/usocket]))
37
+ HTTPX.get("http://example.com", transport: "unix", addresses: %w[/path/to/usocket]))
38
38
  ```
39
39
 
40
40
  The `:transport_options` are therefore deprecated, and will be moved in a major version.
@@ -0,0 +1,17 @@
1
+ # 0.20.4
2
+
3
+ ## Improvements
4
+
5
+ The `:response_cache` plugin is now more compliant with how the RFC 2616 defines which behaviour caches shall have:
6
+
7
+ * it caches only responses with one of the following status codes: 200, 203, 300, 301, 410.
8
+ * it discards cached responses which become stale.
9
+ * it supports "cache-control" header directives to decided when to cache, to store, what the response "age" is.
10
+ * it can cache more than one response for the same request, provided that the request presents different header values for the headers declared in the "vary" response header (previously, it was only caching the first response, and discarding the remainder).
11
+
12
+
13
+
14
+ ## Bugfixes
15
+
16
+ * fixed DNS resolution bug which caused a loop when a failed connection attempt would cause a new DNS request to be triggered for the same domain, filling up and giving preference to the very IP which failed the attempt.
17
+ * response_cache: request verb is now taken into account, not causing HEAD/GET confusion for the same URL.
@@ -0,0 +1,3 @@
1
+ # 0.20.5
2
+
3
+ The `intersect?` refinement introduced in the previous version had a wrong variable name.
@@ -0,0 +1,94 @@
1
+ # 0.21.0
2
+
3
+ ## Features
4
+
5
+ ### `:write_timeout`, `:read_timeout` and `:request_timeout`
6
+
7
+ https://gitlab.com/honeyryderchuck/httpx/-/wikis/Timeouts
8
+
9
+ The following timeouts are now supported:
10
+
11
+ * `:write_timeout`: total time (in seconds) to write a request to the server;
12
+ * `:read_timeout`: total time (in seconds) to read aa response from the server;
13
+ * `:request_timeout`: tracks both of the above (time to write the request and read a response);
14
+
15
+ ```ruby
16
+ HTTPX.with(timeout: { request_timeout: 60}).get(...
17
+ ```
18
+
19
+ Just like `:connect_timeout`, the new timeouts are deadline-oriented, rather than op-oriented, meaning that they do not reset on each socket operation (as most ruby HTTP clients do).
20
+
21
+ None of them has a default value, in order not to break integrations, but that'll change in a future v1, where they'll become the default timeouts.
22
+
23
+ ### Circuit Breaker plugin
24
+
25
+ https://gitlab.com/honeyryderchuck/httpx/-/wikis/Circuit-Breaker
26
+
27
+ The `:circuit_breaker` plugin wraps around errors happening when performing HTTP requests, and support options for setting maximum number of attempts before circuit opens (`:circuit_breaker_max_attempts`), period after which attempts should be reset (`:circuit_breaker_reset_attempts_in`), timespan until circuit half-opens (`circuit_breaker_break_in`), respective half-open drip rate (`:circuit_breaker_half_open_drip_rate`), and a callback to do your own check on whether a response has failed, in case you want HTTP level errors to be marked as failed attempts (`:circuit_breaker_break_on`).
28
+
29
+ ```ruby
30
+ http = HTTPX.plugin(:circuit_breaker)
31
+ # that's it!
32
+ http.get(...
33
+ ```
34
+
35
+ ### WebDAV plugin
36
+
37
+ https://gitlab.com/honeyryderchuck/httpx/-/wikis/WebDav
38
+
39
+ The `:webdav` introduces some "convenience" methods to perform common WebDAV operations.
40
+
41
+ ```ruby
42
+ webdav = HTTPX.plugin(:webdav, origin: "http://webdav-server")
43
+ .plugin(:digest_authentication).digest_auth("user", "pass")
44
+
45
+ res = webdav.put("/file.html", body: "this is the file body")
46
+ res = webdav.copy("/file.html", "/newdir/copy.html")
47
+ # ...
48
+ ```
49
+
50
+ ### XML transcoder, `:xml` option and `response.xml`
51
+
52
+ A new transcoder was added fot the XML mime type, which requires `"nokogiri"` to be installed; it can both serialize Nokogiri nodes in a request, and parse response content into nokogiri nodes:
53
+
54
+ ```ruby
55
+ response = HTTPX.post("https://xml-server.com", xml: Nokogiri::XML("<xml ..."))
56
+ response.xml #=> #(Document:0x16e4 { name = "document", children = ...
57
+ ```
58
+
59
+ ## Improvements
60
+
61
+ ### `:proxy` plugin: `:no_proxy` option
62
+
63
+ Support was added, in the `:proxy` plugin, to declare domains, either via regexp patterns, or strings, for which requests should bypass the proxy.
64
+
65
+ ```ruby
66
+ http = HTTPX.plugin(:proxy).with_proxy(
67
+ uri: "http://10.10.0.1:51432",
68
+ no_proxy: ["gitlab.local", /*.google.com/]
69
+ )
70
+ http.get("https://duckduckgo.com/?q=httpx") #=> proxied
71
+ http.get("https://google.com/?q=httpx") #=> not proxied
72
+ http.get("https://gitlab.com") #=> proxied
73
+ http.get("https://gitlab.local") #=> not proxied
74
+ ```
75
+
76
+ ### OOTB support for other JSON libraries
77
+
78
+ If one of `multi_json`, `oj` or `yajl` is available, all `httpx` operations doing JSON parsing or dumping will use it (the `json` standard library will be used otherwise).
79
+
80
+ ```ruby
81
+ require "oj"
82
+ require "httpx"
83
+
84
+ response = HTTPX.post("https://somedomain.json", json: { "foo" => "bar" }) # will use "oj"
85
+ puts response.json # will use "oj"
86
+ ```
87
+
88
+ ## Bugfixes
89
+
90
+ * `:expect` plugin: `:expect_timeout` can accept floats (not just integers).
91
+
92
+ ## Chore
93
+
94
+ * DoH `:https` resolver: support was removed for the "application/dns-json" mime-type (it was only supported in practice by the Google DoH resolver, which has since added support for the standardized "application/dns-message").
@@ -118,7 +118,7 @@ module HTTPX
118
118
  log(color: :yellow) { response.headers.each.map { |f, v| "-> HEADER: #{f}: #{v}" }.join("\n") }
119
119
 
120
120
  @request.response = response
121
- on_complete if response.complete?
121
+ on_complete if response.finished?
122
122
  end
123
123
 
124
124
  def on_trailers(h)
@@ -158,6 +158,7 @@ module HTTPX
158
158
  @request = nil
159
159
  @requests.shift
160
160
  response = request.response
161
+ response.finish!
161
162
  emit(:response, request, response)
162
163
 
163
164
  if @parser.upgrade?
@@ -34,6 +34,7 @@ module HTTPX
34
34
  include Callbacks
35
35
 
36
36
  using URIExtensions
37
+ using NumericExtensions
37
38
 
38
39
  require "httpx/connection/http2"
39
40
  require "httpx/connection/http1"
@@ -233,6 +234,7 @@ module HTTPX
233
234
  # when pushing a request into an existing connection, we have to check whether there
234
235
  # is the possibility that the connection might have extended the keep alive timeout.
235
236
  # for such cases, we want to ping for availability before deciding to shovel requests.
237
+ log(level: 3) { "keep alive timeout expired, pinging connection..." }
236
238
  @pending << request
237
239
  parser.ping
238
240
  transition(:active) if @state == :inactive
@@ -430,6 +432,8 @@ module HTTPX
430
432
  @inflight += 1
431
433
  parser.send(request)
432
434
 
435
+ set_request_timeouts(request)
436
+
433
437
  return unless @state == :inactive
434
438
 
435
439
  transition(:active)
@@ -573,7 +577,7 @@ module HTTPX
573
577
  error = ex
574
578
  else
575
579
  # inactive connections do not contribute to the select loop, therefore
576
- # they should fail due to such errors.
580
+ # they should not fail due to such errors.
577
581
  return if @state == :inactive
578
582
 
579
583
  if @timeout
@@ -591,10 +595,45 @@ module HTTPX
591
595
  def handle_error(error)
592
596
  parser.handle_error(error) if @parser && parser.respond_to?(:handle_error)
593
597
  while (request = @pending.shift)
594
- response = ErrorResponse.new(request, error, @options)
598
+ response = ErrorResponse.new(request, error, request.options)
595
599
  request.response = response
596
600
  request.emit(:response, response)
597
601
  end
598
602
  end
603
+
604
+ def set_request_timeouts(request)
605
+ write_timeout = request.write_timeout
606
+ request.once(:headers) do
607
+ @timers.after(write_timeout) { write_timeout_callback(request, write_timeout) }
608
+ end unless write_timeout.nil? || write_timeout.infinite?
609
+
610
+ read_timeout = request.read_timeout
611
+ request.once(:done) do
612
+ @timers.after(read_timeout) { read_timeout_callback(request, read_timeout) }
613
+ end unless read_timeout.nil? || read_timeout.infinite?
614
+
615
+ request_timeout = request.request_timeout
616
+ request.once(:headers) do
617
+ @timers.after(request_timeout) { read_timeout_callback(request, request_timeout, RequestTimeoutError) }
618
+ end unless request_timeout.nil? || request_timeout.infinite?
619
+ end
620
+
621
+ def write_timeout_callback(request, write_timeout)
622
+ return if request.state == :done
623
+
624
+ @write_buffer.clear
625
+ error = WriteTimeoutError.new(request, nil, write_timeout)
626
+ on_error(error)
627
+ end
628
+
629
+ def read_timeout_callback(request, read_timeout, error_type = ReadTimeoutError)
630
+ response = request.response
631
+
632
+ return if response && response.finished?
633
+
634
+ @write_buffer.clear
635
+ error = error_type.new(request, request.response, read_timeout)
636
+ on_error(error)
637
+ end
599
638
  end
600
639
  end
data/lib/httpx/errors.rb CHANGED
@@ -24,6 +24,24 @@ module HTTPX
24
24
 
25
25
  class ConnectTimeoutError < TimeoutError; end
26
26
 
27
+ class RequestTimeoutError < TimeoutError
28
+ attr_reader :request
29
+
30
+ def initialize(request, response, timeout)
31
+ @request = request
32
+ @response = response
33
+ super(timeout, "Timed out after #{timeout} seconds")
34
+ end
35
+
36
+ def marshal_dump
37
+ [message]
38
+ end
39
+ end
40
+
41
+ class ReadTimeoutError < RequestTimeoutError; end
42
+
43
+ class WriteTimeoutError < RequestTimeoutError; end
44
+
27
45
  class SettingsTimeoutError < TimeoutError; end
28
46
 
29
47
  class ResolveTimeoutError < TimeoutError; end
@@ -54,6 +54,14 @@ module HTTPX
54
54
  Numeric.__send__(:include, NegMethods)
55
55
  end
56
56
 
57
+ module NumericExtensions
58
+ refine Numeric do
59
+ def infinite?
60
+ self == Float::INFINITY
61
+ end unless Numeric.method_defined?(:infinite?)
62
+ end
63
+ end
64
+
57
65
  module StringExtensions
58
66
  refine String do
59
67
  def delete_suffix!(suffix)
@@ -83,22 +91,41 @@ module HTTPX
83
91
  end
84
92
 
85
93
  module ArrayExtensions
86
- refine Array do
94
+ module FilterMap
95
+ refine Array do
87
96
 
88
- def filter_map
89
- return to_enum(:filter_map) unless block_given?
97
+ def filter_map
98
+ return to_enum(:filter_map) unless block_given?
90
99
 
91
- each_with_object([]) do |item, res|
92
- processed = yield(item)
93
- res << processed if processed
100
+ each_with_object([]) do |item, res|
101
+ processed = yield(item)
102
+ res << processed if processed
103
+ end
94
104
  end
95
105
  end unless Array.method_defined?(:filter_map)
106
+ end
96
107
 
97
- def sum(accumulator = 0, &block)
98
- values = block_given? ? map(&block) : self
99
- values.inject(accumulator, :+)
108
+ module Sum
109
+ refine Array do
110
+ def sum(accumulator = 0, &block)
111
+ values = block_given? ? map(&block) : self
112
+ values.inject(accumulator, :+)
113
+ end
100
114
  end unless Array.method_defined?(:sum)
101
115
  end
116
+
117
+ module Intersect
118
+ refine Array do
119
+ def intersect?(arr)
120
+ if size < arr.size
121
+ smaller = self
122
+ else
123
+ smaller, arr = arr, self
124
+ end
125
+ (arr & smaller).size > 0
126
+ end
127
+ end unless Array.method_defined?(:intersect?)
128
+ end
102
129
  end
103
130
 
104
131
  module IOExtensions
@@ -116,10 +143,6 @@ module HTTPX
116
143
  end
117
144
 
118
145
  module RegexpExtensions
119
- # If you wonder why this is there: the oauth feature uses a refinement to enhance the
120
- # Regexp class locally with #match? , but this is never tested, because ActiveSupport
121
- # monkey-patches the same method... Please ActiveSupport, stop being so intrusive!
122
- # :nocov:
123
146
  refine(Regexp) do
124
147
  def match?(*args)
125
148
  !match(*args).nil?
data/lib/httpx/io/unix.rb CHANGED
@@ -33,7 +33,7 @@ module HTTPX
33
33
  else
34
34
  if @options.transport_options
35
35
  # :nocov:
36
- warn ":#{__method__} is deprecated, use :addresses instead"
36
+ warn ":transport_options is deprecated, use :addresses instead"
37
37
  @path = @options.transport_options[:path]
38
38
  # :nocov:
39
39
  else
data/lib/httpx/options.rb CHANGED
@@ -10,6 +10,7 @@ module HTTPX
10
10
  OPERATION_TIMEOUT = 60
11
11
  KEEP_ALIVE_TIMEOUT = 20
12
12
  SETTINGS_TIMEOUT = 10
13
+ READ_TIMEOUT = WRITE_TIMEOUT = REQUEST_TIMEOUT = Float::INFINITY
13
14
 
14
15
  # https://github.com/ruby/resolv/blob/095f1c003f6073730500f02acbdbc55f83d70987/lib/resolv.rb#L408
15
16
  ip_address_families = begin
@@ -34,6 +35,9 @@ module HTTPX
34
35
  settings_timeout: SETTINGS_TIMEOUT,
35
36
  operation_timeout: OPERATION_TIMEOUT,
36
37
  keep_alive_timeout: KEEP_ALIVE_TIMEOUT,
38
+ read_timeout: READ_TIMEOUT,
39
+ write_timeout: WRITE_TIMEOUT,
40
+ request_timeout: REQUEST_TIMEOUT,
37
41
  },
38
42
  :headers => {},
39
43
  :window_size => WINDOW_SIZE,
@@ -197,7 +201,7 @@ module HTTPX
197
201
  end
198
202
 
199
203
  %i[
200
- params form json body ssl http2_settings
204
+ params form json xml body ssl http2_settings
201
205
  request_class response_class headers_class request_body_class
202
206
  response_body_class connection_class options_class
203
207
  io fallback_protocol debug debug_level transport_options resolver_class resolver_options
@@ -206,7 +210,7 @@ module HTTPX
206
210
  def_option(method_name)
207
211
  end
208
212
 
209
- REQUEST_IVARS = %i[@params @form @json @body].freeze
213
+ REQUEST_IVARS = %i[@params @form @xml @json @body].freeze
210
214
  private_constant :REQUEST_IVARS
211
215
 
212
216
  def ==(other)
@@ -263,7 +267,7 @@ module HTTPX
263
267
  instance_variables.each do |ivar|
264
268
  value = other.instance_variable_get(ivar)
265
269
  value = case value
266
- when Symbol, Fixnum, TrueClass, FalseClass # rubocop:disable Lint/UnifiedInteger
270
+ when Symbol, Numeric, TrueClass, FalseClass
267
271
  value
268
272
  else
269
273
  value.dup
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins::CircuitBreaker
5
+ #
6
+ # A circuit is assigned to a given absoolute url or origin.
7
+ #
8
+ # It sets +max_attempts+, the number of attempts the circuit allows, before it is opened.
9
+ # It sets +reset_attempts_in+, the time a circuit stays open at most, before it resets.
10
+ # It sets +break_in+, the time that must elapse before an open circuit can transit to the half-open state.
11
+ # It sets +circuit_breaker_half_open_drip_rate+, the rate of requests a circuit allows to be performed when in an half-open state.
12
+ #
13
+ class Circuit
14
+ def initialize(max_attempts, reset_attempts_in, break_in, circuit_breaker_half_open_drip_rate)
15
+ @max_attempts = max_attempts
16
+ @reset_attempts_in = reset_attempts_in
17
+ @break_in = break_in
18
+ @circuit_breaker_half_open_drip_rate = 1 - circuit_breaker_half_open_drip_rate
19
+ @attempts = 0
20
+ @state = :closed
21
+ end
22
+
23
+ def respond
24
+ try_close
25
+
26
+ case @state
27
+ when :closed
28
+ nil
29
+ when :half_open
30
+ # return nothing or smth based on ratio
31
+ return if Random::DEFAULT.rand >= @circuit_breaker_half_open_drip_rate
32
+
33
+ @response
34
+ when :open
35
+
36
+ @response
37
+ end
38
+ end
39
+
40
+ def try_open(response)
41
+ return unless @state == :closed
42
+
43
+ now = Utils.now
44
+
45
+ if @attempts.positive?
46
+ @attempts = 0 if now - @attempted_at > @reset_attempts_in
47
+ else
48
+ @attempted_at = now
49
+ end
50
+
51
+ @attempts += 1
52
+
53
+ return unless @attempts >= @max_attempts
54
+
55
+ @state = :open
56
+ @opened_at = now
57
+ @response = response
58
+ end
59
+
60
+ def try_close
61
+ case @state
62
+ when :closed
63
+ nil
64
+ when :half_open
65
+ # reset!
66
+ @attempts = 0
67
+ @opened_at = @attempted_at = @response = nil
68
+ @state = :closed
69
+
70
+ when :open
71
+ @state = :half_open if Utils.elapsed_time(@opened_at) > @break_in
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX::Plugins::CircuitBreaker
4
+ using HTTPX::URIExtensions
5
+
6
+ class CircuitStore
7
+ def initialize(options)
8
+ @circuits = Hash.new do |h, k|
9
+ h[k] = Circuit.new(
10
+ options.circuit_breaker_max_attempts,
11
+ options.circuit_breaker_reset_attempts_in,
12
+ options.circuit_breaker_break_in,
13
+ options.circuit_breaker_half_open_drip_rate
14
+ )
15
+ end
16
+ end
17
+
18
+ def try_open(uri, response)
19
+ circuit = get_circuit_for_uri(uri)
20
+
21
+ circuit.try_open(response)
22
+ end
23
+
24
+ # if circuit is open, it'll respond with the stored response.
25
+ # if not, nil.
26
+ def try_respond(request)
27
+ circuit = get_circuit_for_uri(request.uri)
28
+
29
+ circuit.respond
30
+ end
31
+
32
+ private
33
+
34
+ def get_circuit_for_uri(uri)
35
+ uri = URI(uri)
36
+
37
+ if @circuits.key?(uri.origin)
38
+ @circuits[uri.origin]
39
+ else
40
+ @circuits[uri.to_s]
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTPX
4
+ module Plugins
5
+ #
6
+ # This plugin implements a circuit breaker around connection errors.
7
+ #
8
+ # https://gitlab.com/honeyryderchuck/httpx/wikis/Circuit-Breaker
9
+ #
10
+ module CircuitBreaker
11
+ using URIExtensions
12
+
13
+ def self.load_dependencies(*)
14
+ require_relative "circuit_breaker/circuit"
15
+ require_relative "circuit_breaker/circuit_store"
16
+ end
17
+
18
+ def self.extra_options(options)
19
+ options.merge(circuit_breaker_max_attempts: 3, circuit_breaker_reset_attempts_in: 60, circuit_breaker_break_in: 60,
20
+ circuit_breaker_half_open_drip_rate: 1)
21
+ end
22
+
23
+ module InstanceMethods
24
+ def initialize(*)
25
+ super
26
+ @circuit_store = CircuitStore.new(@options)
27
+ end
28
+
29
+ def initialize_dup(orig)
30
+ super
31
+ @circuit_store = orig.instance_variable_get(:@circuit_store).dup
32
+ end
33
+
34
+ def send_requests(*requests)
35
+ # @type var short_circuit_responses: Array[response]
36
+ short_circuit_responses = []
37
+
38
+ # run all requests through the circuit breaker, see if the circuit is
39
+ # open for any of them.
40
+ real_requests = requests.each_with_object([]) do |req, real_reqs|
41
+ short_circuit_response = @circuit_store.try_respond(req)
42
+ if short_circuit_response.nil?
43
+ real_reqs << req
44
+ next
45
+ end
46
+ short_circuit_responses[requests.index(req)] = short_circuit_response
47
+ end
48
+
49
+ # run requests for the remainder
50
+ unless real_requests.empty?
51
+ responses = super(*real_requests)
52
+
53
+ real_requests.each_with_index do |request, idx|
54
+ short_circuit_responses[requests.index(request)] = responses[idx]
55
+ end
56
+ end
57
+
58
+ short_circuit_responses
59
+ end
60
+
61
+ def on_response(request, response)
62
+ if response.is_a?(ErrorResponse)
63
+ case response.error
64
+ when RequestTimeoutError
65
+ @circuit_store.try_open(request.uri, response)
66
+ else
67
+ @circuit_store.try_open(request.origin, response)
68
+ end
69
+ elsif (break_on = request.options.circuit_breaker_break_on) && break_on.call(response)
70
+ @circuit_store.try_open(request.uri, response)
71
+ end
72
+
73
+ super
74
+ end
75
+ end
76
+
77
+ module OptionsMethods
78
+ def option_circuit_breaker_max_attempts(value)
79
+ attempts = Integer(value)
80
+ raise TypeError, ":circuit_breaker_max_attempts must be positive" unless attempts.positive?
81
+
82
+ attempts
83
+ end
84
+
85
+ def option_circuit_breaker_reset_attempts_in(value)
86
+ timeout = Float(value)
87
+ raise TypeError, ":circuit_breaker_reset_attempts_in must be positive" unless timeout.positive?
88
+
89
+ timeout
90
+ end
91
+
92
+ def option_circuit_breaker_break_in(value)
93
+ timeout = Float(value)
94
+ raise TypeError, ":circuit_breaker_break_in must be positive" unless timeout.positive?
95
+
96
+ timeout
97
+ end
98
+
99
+ def option_circuit_breaker_half_open_drip_rate(value)
100
+ ratio = Float(value)
101
+ raise TypeError, ":circuit_breaker_half_open_drip_rate must be a number between 0 and 1" unless (0..1).cover?(ratio)
102
+
103
+ ratio
104
+ end
105
+
106
+ def option_circuit_breaker_break_on(value)
107
+ raise TypeError, ":circuit_breaker_break_on must be called with the response" unless value.respond_to?(:call)
108
+
109
+ value
110
+ end
111
+ end
112
+ end
113
+ register_plugin :circuit_breaker, CircuitBreaker
114
+ end
115
+ end
@@ -72,7 +72,7 @@ module HTTPX
72
72
  end
73
73
 
74
74
  module ResponseBodyMethods
75
- using ArrayExtensions
75
+ using ArrayExtensions::FilterMap
76
76
 
77
77
  attr_reader :encodings
78
78
 
@@ -42,7 +42,7 @@ module HTTPX
42
42
 
43
43
  private
44
44
 
45
- def on_response(reuest, response)
45
+ def on_response(_request, response)
46
46
  if response && response.respond_to?(:headers) && (set_cookie = response.headers["set-cookie"])
47
47
 
48
48
  log { "cookies: set-cookie is over #{Cookie::MAX_LENGTH}" } if set_cookie.bytesize > Cookie::MAX_LENGTH
@@ -22,7 +22,7 @@ module HTTPX
22
22
 
23
23
  module OptionsMethods
24
24
  def option_expect_timeout(value)
25
- seconds = Integer(value)
25
+ seconds = Float(value)
26
26
  raise TypeError, ":expect_timeout must be positive" unless seconds.positive?
27
27
 
28
28
  seconds
@@ -61,7 +61,7 @@ module HTTPX::Plugins
61
61
  @state = :idle
62
62
  end
63
63
 
64
- def call(response, _)
64
+ def call(response, *)
65
65
  response.body.each do |chunk|
66
66
  @buffer << chunk
67
67