restify 2.0.2 → 3.0.0.rc2

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +31 -5
  4. data/lib/restify/adapter/base.rb +11 -2
  5. data/lib/restify/adapter/ethon.rb +462 -0
  6. data/lib/restify/adapter/loop_lock.rb +124 -0
  7. data/lib/restify/adapter/telemetry.rb +6 -4
  8. data/lib/restify/adapter/typhoeus.rb +48 -23
  9. data/lib/restify/cache.rb +1 -1
  10. data/lib/restify/context.rb +5 -5
  11. data/lib/restify/error.rb +13 -0
  12. data/lib/restify/global.rb +9 -30
  13. data/lib/restify/logging.rb +10 -4
  14. data/lib/restify/processors/base/parsing.rb +13 -1
  15. data/lib/restify/promise.rb +20 -2
  16. data/lib/restify/registry.rb +2 -2
  17. data/lib/restify/relation.rb +9 -17
  18. data/lib/restify/request.rb +2 -2
  19. data/lib/restify/resource.rb +20 -2
  20. data/lib/restify/response.rb +41 -5
  21. data/lib/restify/timeout.rb +11 -5
  22. data/lib/restify/version.rb +3 -3
  23. data/lib/restify/webmock.rb +126 -0
  24. data/lib/restify.rb +5 -1
  25. data/spec/restify/adapter/base_spec.rb +33 -0
  26. data/spec/restify/adapter/ethon_spec.rb +263 -0
  27. data/spec/restify/adapter/loop_lock_spec.rb +144 -0
  28. data/spec/restify/adapter/telemetry_spec.rb +61 -0
  29. data/spec/restify/adapter/typhoeus_spec.rb +26 -0
  30. data/spec/restify/cache_spec.rb +1 -3
  31. data/spec/restify/context_spec.rb +57 -2
  32. data/spec/restify/features/opentelemetry_spec.rb +1 -1
  33. data/spec/restify/features/protocols_spec.rb +28 -0
  34. data/spec/restify/features/redirects_spec.rb +45 -0
  35. data/spec/restify/features/request_errors_spec.rb +1 -1
  36. data/spec/restify/features/response_bodies_spec.rb +45 -0
  37. data/spec/restify/features/response_encoding_spec.rb +82 -0
  38. data/spec/restify/features/response_errors_spec.rb +22 -1
  39. data/spec/restify/features/webmock_spec.rb +92 -1
  40. data/spec/restify/global_spec.rb +118 -1
  41. data/spec/restify/link_spec.rb +13 -3
  42. data/spec/restify/processors/json_spec.rb +36 -0
  43. data/spec/restify/processors/msgpack_spec.rb +36 -0
  44. data/spec/restify/promise_spec.rb +82 -0
  45. data/spec/restify/relation_spec.rb +22 -7
  46. data/spec/restify/request_spec.rb +26 -0
  47. data/spec/restify/resource_spec.rb +6 -0
  48. data/spec/restify/timeout_spec.rb +11 -0
  49. data/spec/restify_spec.rb +1 -1
  50. data/spec/spec_helper.rb +10 -7
  51. data/spec/support/stub_server.rb +4 -4
  52. metadata +27 -105
  53. data/vendor/bundle/ruby/3.4.0/bundler/gems/my-rubocop-3bcd2110ca87/LICENSE +0 -21
  54. data/vendor/bundle/ruby/3.4.0/bundler/gems/my-rubocop-3bcd2110ca87/README.md +0 -23
  55. data/vendor/bundle/ruby/3.4.0/gems/activesupport-8.0.2/CHANGELOG.md +0 -255
  56. data/vendor/bundle/ruby/3.4.0/gems/activesupport-8.0.2/MIT-LICENSE +0 -20
  57. data/vendor/bundle/ruby/3.4.0/gems/activesupport-8.0.2/README.rdoc +0 -40
  58. data/vendor/bundle/ruby/3.4.0/gems/addressable-2.8.7/CHANGELOG.md +0 -301
  59. data/vendor/bundle/ruby/3.4.0/gems/addressable-2.8.7/LICENSE.txt +0 -202
  60. data/vendor/bundle/ruby/3.4.0/gems/addressable-2.8.7/README.md +0 -121
  61. data/vendor/bundle/ruby/3.4.0/gems/base64-0.2.0/LICENSE.txt +0 -22
  62. data/vendor/bundle/ruby/3.4.0/gems/base64-0.2.0/README.md +0 -48
  63. data/vendor/bundle/ruby/3.4.0/gems/benchmark-0.4.0/README.md +0 -138
  64. data/vendor/bundle/ruby/3.4.0/gems/bigdecimal-3.1.9/LICENSE +0 -56
  65. data/vendor/bundle/ruby/3.4.0/gems/concurrent-ruby-1.3.5/CHANGELOG.md +0 -603
  66. data/vendor/bundle/ruby/3.4.0/gems/concurrent-ruby-1.3.5/LICENSE.txt +0 -21
  67. data/vendor/bundle/ruby/3.4.0/gems/concurrent-ruby-1.3.5/README.md +0 -407
  68. data/vendor/bundle/ruby/3.4.0/gems/connection_pool-2.5.3/LICENSE +0 -20
  69. data/vendor/bundle/ruby/3.4.0/gems/connection_pool-2.5.3/README.md +0 -167
  70. data/vendor/bundle/ruby/3.4.0/gems/drb-2.2.1/LICENSE.txt +0 -22
  71. data/vendor/bundle/ruby/3.4.0/gems/ethon-0.16.0/CHANGELOG.md +0 -375
  72. data/vendor/bundle/ruby/3.4.0/gems/ethon-0.16.0/LICENSE +0 -20
  73. data/vendor/bundle/ruby/3.4.0/gems/ethon-0.16.0/README.md +0 -118
  74. data/vendor/bundle/ruby/3.4.0/gems/ffi-1.17.2-x86_64-linux-gnu/CHANGELOG.md +0 -473
  75. data/vendor/bundle/ruby/3.4.0/gems/ffi-1.17.2-x86_64-linux-gnu/LICENSE +0 -24
  76. data/vendor/bundle/ruby/3.4.0/gems/ffi-1.17.2-x86_64-linux-gnu/LICENSE.SPECS +0 -22
  77. data/vendor/bundle/ruby/3.4.0/gems/ffi-1.17.2-x86_64-linux-gnu/README.md +0 -137
  78. data/vendor/bundle/ruby/3.4.0/gems/hitimes-3.1.0/LICENSE.txt +0 -16
  79. data/vendor/bundle/ruby/3.4.0/gems/hitimes-3.1.0/README.md +0 -187
  80. data/vendor/bundle/ruby/3.4.0/gems/i18n-1.14.7/MIT-LICENSE +0 -20
  81. data/vendor/bundle/ruby/3.4.0/gems/i18n-1.14.7/README.md +0 -127
  82. data/vendor/bundle/ruby/3.4.0/gems/little-plugger-1.1.4/README.rdoc +0 -53
  83. data/vendor/bundle/ruby/3.4.0/gems/logger-1.7.0/README.md +0 -104
  84. data/vendor/bundle/ruby/3.4.0/gems/logging-2.4.0/LICENSE +0 -22
  85. data/vendor/bundle/ruby/3.4.0/gems/logging-2.4.0/README.md +0 -140
  86. data/vendor/bundle/ruby/3.4.0/gems/minitest-5.25.5/README.rdoc +0 -842
  87. data/vendor/bundle/ruby/3.4.0/gems/msgpack-1.8.0/ChangeLog +0 -368
  88. data/vendor/bundle/ruby/3.4.0/gems/msgpack-1.8.0/LICENSE +0 -177
  89. data/vendor/bundle/ruby/3.4.0/gems/msgpack-1.8.0/README.md +0 -302
  90. data/vendor/bundle/ruby/3.4.0/gems/multi_json-1.15.0/CHANGELOG.md +0 -275
  91. data/vendor/bundle/ruby/3.4.0/gems/multi_json-1.15.0/LICENSE.md +0 -20
  92. data/vendor/bundle/ruby/3.4.0/gems/multi_json-1.15.0/README.md +0 -121
  93. data/vendor/bundle/ruby/3.4.0/gems/opentelemetry-api-1.5.0/CHANGELOG.md +0 -193
  94. data/vendor/bundle/ruby/3.4.0/gems/opentelemetry-api-1.5.0/LICENSE +0 -201
  95. data/vendor/bundle/ruby/3.4.0/gems/opentelemetry-api-1.5.0/README.md +0 -68
  96. data/vendor/bundle/ruby/3.4.0/gems/opentelemetry-common-0.22.0/CHANGELOG.md +0 -98
  97. data/vendor/bundle/ruby/3.4.0/gems/opentelemetry-common-0.22.0/LICENSE +0 -201
  98. data/vendor/bundle/ruby/3.4.0/gems/opentelemetry-common-0.22.0/README.md +0 -62
  99. data/vendor/bundle/ruby/3.4.0/gems/public_suffix-6.0.2/CHANGELOG.md +0 -498
  100. data/vendor/bundle/ruby/3.4.0/gems/public_suffix-6.0.2/LICENSE.txt +0 -22
  101. data/vendor/bundle/ruby/3.4.0/gems/public_suffix-6.0.2/README.md +0 -222
  102. data/vendor/bundle/ruby/3.4.0/gems/rack-3.1.15/CHANGELOG.md +0 -1146
  103. data/vendor/bundle/ruby/3.4.0/gems/rack-3.1.15/MIT-LICENSE +0 -20
  104. data/vendor/bundle/ruby/3.4.0/gems/rack-3.1.15/README.md +0 -355
  105. data/vendor/bundle/ruby/3.4.0/gems/rake-13.2.1/MIT-LICENSE +0 -21
  106. data/vendor/bundle/ruby/3.4.0/gems/rake-13.2.1/README.rdoc +0 -155
  107. data/vendor/bundle/ruby/3.4.0/gems/rake-release-1.3.0/LICENSE +0 -21
  108. data/vendor/bundle/ruby/3.4.0/gems/rake-release-1.3.0/README.md +0 -107
  109. data/vendor/bundle/ruby/3.4.0/gems/securerandom-0.4.1/README.md +0 -72
  110. data/vendor/bundle/ruby/3.4.0/gems/typhoeus-1.4.1/CHANGELOG.md +0 -410
  111. data/vendor/bundle/ruby/3.4.0/gems/typhoeus-1.4.1/LICENSE +0 -22
  112. data/vendor/bundle/ruby/3.4.0/gems/typhoeus-1.4.1/README.md +0 -588
  113. data/vendor/bundle/ruby/3.4.0/gems/tzinfo-2.0.6/LICENSE +0 -19
  114. data/vendor/bundle/ruby/3.4.0/gems/tzinfo-2.0.6/README.md +0 -406
  115. data/vendor/bundle/ruby/3.4.0/gems/uri-1.0.3/README.md +0 -55
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rack/media_type'
3
4
  require 'rack/utils'
4
5
  require 'json'
5
6
 
@@ -30,6 +31,10 @@ module Restify
30
31
 
31
32
  # Response body as string.
32
33
  #
34
+ # The body is encoded according to the charset from the response's
35
+ # `Content-Type` header. Without a charset, or with one unknown to
36
+ # Ruby, the body is returned as binary.
37
+ #
33
38
  # @return [String] Response body.
34
39
  #
35
40
  attr_reader :body
@@ -81,7 +86,7 @@ module Restify
81
86
  @code = code
82
87
  @status = STATUS_CODE_TO_SYMBOL[code]
83
88
  @headers = convert_headers(headers)
84
- @body = body
89
+ @body = encode_body(body)
85
90
  @message = Rack::Utils::HTTP_STATUS_CODES[code]
86
91
  end
87
92
 
@@ -112,6 +117,24 @@ module Restify
112
117
  headers['CONTENT_TYPE']
113
118
  end
114
119
 
120
+ # Return the encoding from the response's content type, if any.
121
+ #
122
+ # @return [Encoding, nil] Encoding, or nil if the content type
123
+ # carries no charset or an encoding unknown to Ruby.
124
+ #
125
+ def charset
126
+ return @charset if defined?(@charset)
127
+
128
+ charset = Rack::MediaType.params(content_type.to_s)['charset']
129
+
130
+ @charset = begin
131
+ Encoding.find(charset) if charset
132
+ rescue ArgumentError
133
+ # Unknown: keep the body as binary rather than guessing.
134
+ nil
135
+ end
136
+ end
137
+
115
138
  # Check if response is successful e.g. the status code
116
139
  # is on of 2XX.
117
140
  #
@@ -130,13 +153,20 @@ module Restify
130
153
  (400...600).cover? code
131
154
  end
132
155
 
156
+ # Decoded body for error messages, or nil if the body is empty or
157
+ # could not be parsed.
158
+ #
133
159
  # @api private
134
160
  def decoded_body
135
- @decoded_body ||= begin
161
+ return @decoded_body if defined?(@decoded_body)
162
+
163
+ @decoded_body = begin
136
164
  case content_type
137
165
  when %r{\Aapplication/json($|;)}
138
- ::JSON.parse(body)
166
+ ::JSON.parse(body) unless body.nil? || body.empty?
139
167
  end
168
+ rescue ::JSON::ParserError
169
+ nil
140
170
  end
141
171
  end
142
172
 
@@ -147,9 +177,15 @@ module Restify
147
177
 
148
178
  private
149
179
 
180
+ def encode_body(body)
181
+ return body unless body && charset
182
+
183
+ (+body).force_encoding(charset)
184
+ end
185
+
150
186
  def convert_headers(headers)
151
- headers.each_with_object({}) do |pair, hash|
152
- hash[pair[0].upcase] = pair[1]
187
+ headers.each.to_h do |pair|
188
+ [pair[0].upcase, pair[1]]
153
189
  end
154
190
  end
155
191
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'hitimes'
3
+ require 'timeout'
4
4
 
5
5
  module Restify
6
6
  class Timeout
@@ -14,9 +14,7 @@ module Restify
14
14
  def initialize(timeout, target = nil)
15
15
  @timeout = parse_timeout(timeout)
16
16
  @target = target
17
-
18
- @interval = ::Hitimes::Interval.new
19
- @interval.start
17
+ @deadline = now + @timeout
20
18
  end
21
19
 
22
20
  def wait_on!(ivar)
@@ -31,6 +29,10 @@ module Restify
31
29
  raise self if wait_interval <= 0
32
30
  end
33
31
 
32
+ def remaining
33
+ wait_interval
34
+ end
35
+
34
36
  def exception
35
37
  Error.new(@target)
36
38
  end
@@ -38,7 +40,11 @@ module Restify
38
40
  private
39
41
 
40
42
  def wait_interval
41
- @timeout - @interval.to_f
43
+ @deadline - now
44
+ end
45
+
46
+ def now
47
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
48
  end
43
49
 
44
50
  def parse_timeout(value)
@@ -2,10 +2,10 @@
2
2
 
3
3
  module Restify
4
4
  module VERSION
5
- MAJOR = 2
5
+ MAJOR = 3
6
6
  MINOR = 0
7
- PATCH = 2
8
- STAGE = nil
7
+ PATCH = 0
8
+ STAGE = :rc2
9
9
  STRING = [MAJOR, MINOR, PATCH, STAGE].compact.join('.').freeze
10
10
 
11
11
  def self.to_s
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'restify'
4
+ require 'webmock'
5
+
6
+ module WebMock
7
+ module HttpLibAdapters
8
+ #
9
+ # WebMock support for Restify (independent of the adapter)
10
+ #
11
+ # Requests are intercepted before they are handed over to an
12
+ # adapter. Stubbed responses are returned as they are, including
13
+ # redirects, like WebMock does for other libcurl-based libraries.
14
+ # Requests not stubbed are passed on to the adapter, if allowed.
15
+ #
16
+ # Must be loaded before WebMock is enabled, e.g. in
17
+ # `spec_helper.rb`:
18
+ #
19
+ # require 'webmock/rspec'
20
+ # require 'restify/webmock'
21
+ #
22
+ class RestifyAdapter < HttpLibAdapter
23
+ adapter_for :restify
24
+
25
+ class << self
26
+ def enable!
27
+ @enabled = true
28
+ end
29
+
30
+ def disable!
31
+ @enabled = false
32
+ end
33
+
34
+ def enabled?
35
+ @enabled == true
36
+ end
37
+
38
+ # Respond to a request with a stubbed response, or pass it on to
39
+ # the given block if allowed.
40
+ #
41
+ # @api private
42
+ def call(request, &)
43
+ signature = signature(request)
44
+ RequestRegistry.instance.requested_signatures.put(signature)
45
+
46
+ stub = StubRegistry.instance.response_for_request(signature)
47
+ return passthrough(signature, &) unless stub
48
+
49
+ CallbackRegistry.invoke_callbacks({lib: :restify}, signature, stub)
50
+
51
+ Restify::Promise.fulfilled(respond(request, stub))
52
+ rescue Exception => e # rubocop:disable Lint/RescueException
53
+ # e.g. `NetConnectNotAllowedError`
54
+ Restify::Promise.rejected(e)
55
+ end
56
+
57
+ private
58
+
59
+ def signature(request)
60
+ RequestSignature.new(
61
+ request.method.to_sym,
62
+ request.uri.to_s,
63
+ body: request.body,
64
+ headers: request.headers,
65
+ )
66
+ end
67
+
68
+ def passthrough(signature)
69
+ unless ::WebMock.net_connect_allowed?(signature.uri)
70
+ raise NetConnectNotAllowedError.new(signature)
71
+ end
72
+
73
+ yield.tap do |promise|
74
+ # Observers run when the request is complete, even when
75
+ # nobody waits on the promise.
76
+ promise.add_observer do |_, response, _|
77
+ next unless response
78
+
79
+ CallbackRegistry.invoke_callbacks(
80
+ {lib: :restify, real_request: true},
81
+ signature,
82
+ webmock_response(response),
83
+ )
84
+ end
85
+ end
86
+ end
87
+
88
+ def respond(request, stub)
89
+ raise Restify::NetworkError.new(request, 'Timeout was reached') if stub.should_timeout
90
+
91
+ stub.raise_error_if_any
92
+
93
+ headers = (stub.headers || {}).to_h do |name, value|
94
+ [name.upcase.tr('-', '_'), value]
95
+ end
96
+
97
+ Restify::Response.new(request, request.uri, stub.status[0], headers, stub.body)
98
+ end
99
+
100
+ def webmock_response(response)
101
+ Response.new.tap do |webmock|
102
+ webmock.status = [response.code, response.message.to_s]
103
+ webmock.headers = response.headers.to_h do |name, value|
104
+ [name.split('_').map(&:capitalize).join('-'), value]
105
+ end
106
+ webmock.body = response.body.to_s
107
+ end
108
+ end
109
+ end
110
+
111
+ # Intercept requests after `Adapter::Telemetry` but before
112
+ # `Adapter::Base#call` so that telemetry is included.
113
+ #
114
+ # @api private
115
+ module Intercept
116
+ def call(request)
117
+ return super unless RestifyAdapter.enabled?
118
+
119
+ RestifyAdapter.call(request) { super }
120
+ end
121
+ end
122
+
123
+ ::Restify::Adapter::Telemetry.include(Intercept)
124
+ end
125
+ end
126
+ end
data/lib/restify.rb CHANGED
@@ -28,7 +28,11 @@ module Restify
28
28
 
29
29
  module Adapter
30
30
  require 'restify/adapter/base'
31
- require 'restify/adapter/typhoeus'
31
+ require 'restify/adapter/loop_lock'
32
+ require 'restify/adapter/ethon'
33
+
34
+ # Optional, requires the `typhoeus` gem.
35
+ autoload :Typhoeus, 'restify/adapter/typhoeus'
32
36
  end
33
37
 
34
38
  module Processors
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Restify::Adapter::Base do
6
+ subject(:adapter) { described_class.new }
7
+
8
+ let(:request) { Restify::Request.new(uri: 'https://example.org') }
9
+
10
+ describe '#call' do
11
+ subject(:call) { adapter.call(request) }
12
+
13
+ it 'delegates to call_native with a promise' do
14
+ expect(adapter).to receive(:call_native) do |req, writer| # rubocop:disable RSpec/SubjectStub
15
+ expect(req).to be request
16
+ expect(writer).to be_a Restify::Promise::Writer
17
+ end
18
+
19
+ call.tap do |promise|
20
+ expect(promise).to be_a Restify::Promise
21
+ expect(promise).to be_pending
22
+ end
23
+ end
24
+ end
25
+
26
+ describe '#call_native' do
27
+ subject(:call_native) { adapter.call_native(nil, nil) }
28
+
29
+ it 'must be implemented in a subclass' do
30
+ expect { call_native }.to raise_error(NotImplementedError)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Restify::Adapter::Ethon do
6
+ describe 'completed transfers' do
7
+ subject(:value) do
8
+ Restify::Promise.create do |writer|
9
+ described_class.new.send(
10
+ :complete,
11
+ easy,
12
+ request,
13
+ writer,
14
+ )
15
+ end.value!
16
+ end
17
+
18
+ let(:request) { Restify::Request.new(uri: 'http://example.org/base') }
19
+ let(:return_code) { :ok }
20
+ let(:response_code) { 200 }
21
+ let(:effective_url) { 'http://example.org/base' }
22
+
23
+ # A transfer is handed back from libcurl with its return code and,
24
+ # for HTTP, the status code of the response.
25
+ def easy
26
+ instance_double(
27
+ described_class::Easy,
28
+ return_code:,
29
+ response_code:,
30
+ effective_url:,
31
+ response_headers: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n",
32
+ response_body: '{}',
33
+ )
34
+ end
35
+
36
+ it 'fulfills the promise with the response' do
37
+ expect(value.code).to eq 200
38
+ expect(value.uri.to_s).to eq 'http://example.org/base'
39
+ end
40
+
41
+ context 'when redirected' do
42
+ let(:effective_url) { 'http://example.org/other/base' }
43
+
44
+ it 'uses the effective URL as the response URI' do
45
+ expect(value.uri.to_s).to eq 'http://example.org/other/base'
46
+ end
47
+ end
48
+
49
+ context 'when the transfer failed' do
50
+ let(:return_code) { :couldnt_connect }
51
+ let(:response_code) { 0 }
52
+
53
+ it 'rejects the promise' do
54
+ expect { value }.to raise_error Restify::NetworkError, /connect/i
55
+ end
56
+ end
57
+
58
+ # Non-HTTP protocols are refused before a transfer is started, but
59
+ # libcurl still reports success with a zero status code whenever no
60
+ # HTTP status was received, and no status code at all when it cannot
61
+ # be read back. Neither has a response to process.
62
+ context 'without an HTTP status' do
63
+ let(:response_code) { 0 }
64
+
65
+ it 'rejects the promise' do
66
+ expect { value }.to raise_error Restify::NetworkError, /without HTTP status/
67
+ end
68
+ end
69
+
70
+ context 'with an unavailable HTTP status' do
71
+ let(:response_code) { nil }
72
+
73
+ it 'rejects the promise' do
74
+ expect { value }.to raise_error Restify::NetworkError, /without HTTP status/
75
+ end
76
+ end
77
+
78
+ # Unexpected errors must reject the promise too, as the caller would
79
+ # otherwise keep waiting for it until it times out.
80
+ context 'when building the response fails' do
81
+ def easy
82
+ super.tap do |double|
83
+ allow(double).to receive(:response_headers).and_raise('kaboom')
84
+ end
85
+ end
86
+
87
+ it 'rejects the promise' do
88
+ expect { value }.to raise_error 'kaboom'
89
+ end
90
+ end
91
+ end
92
+
93
+ describe 'waiting on requests' do
94
+ let(:adapter) { described_class.new }
95
+
96
+ # Threads that completed a transfer, i.e. ran the event loop.
97
+ let(:completed_by) { Queue.new }
98
+
99
+ before do
100
+ stub_request(:get, 'http://stubserver/fast')
101
+ .to_return(status: 200, body: '{}')
102
+ stub_request(:get, 'http://stubserver/slow')
103
+ .to_return { sleep 0.5 and {status: 200, body: '{}'} }
104
+
105
+ allow(adapter).to receive(:complete).and_wrap_original do |m, *args|
106
+ completed_by << Thread.current
107
+ m.call(*args)
108
+ end
109
+ end
110
+
111
+ def request(path)
112
+ Restify::Request.new(uri: "http://localhost:9292/#{path}")
113
+ end
114
+
115
+ def completed_by_threads
116
+ Array.new(completed_by.size) { completed_by.pop }
117
+ end
118
+
119
+ it 'runs the event loop in the waiting thread' do
120
+ expect(adapter.call(request('fast')).value!.code).to eq 200
121
+ expect(completed_by_threads).to eq [Thread.current]
122
+ end
123
+
124
+ it 'completes requests nobody waits on in the background' do
125
+ adapter.call(request('fast'))
126
+
127
+ # Poll without waiting on the promise:
128
+ Timeout.timeout(1) { sleep 0.01 while completed_by.empty? }
129
+
130
+ expect(completed_by_threads).not_to include Thread.current
131
+ end
132
+
133
+ it 'times out without blocking other requests' do
134
+ expect { adapter.call(request('slow')).value!(0.1) }.to raise_error Timeout::Error
135
+ expect(adapter.call(request('fast')).value!.code).to eq 200
136
+ end
137
+
138
+ it 'processes requests from many threads' do
139
+ threads = Array.new(8) do
140
+ Thread.new do
141
+ Array.new(5) { adapter.call(request('fast')).value!.code }
142
+ end
143
+ end
144
+
145
+ expect(threads.flat_map(&:value)).to all eq 200
146
+ end
147
+
148
+ # Only the adapter's own promises are completed by whichever thread
149
+ # runs the event loop. Callbacks chained to them must still run in
150
+ # the thread waiting on them, and only when waiting on them.
151
+ describe 'chained callbacks' do
152
+ it 'does not run them before waiting on them' do
153
+ ran_in = Queue.new
154
+ promise = adapter.call(request('fast'))
155
+ chained = promise.then { ran_in << Thread.current }
156
+
157
+ promise.value!
158
+ expect(ran_in).to be_empty
159
+
160
+ chained.value!
161
+ expect(ran_in.pop(timeout: 1)).to eq Thread.current
162
+ end
163
+
164
+ it 'runs them in the waiting thread when another thread runs the loop' do
165
+ # Keep the loop busy in another thread, until after the fast
166
+ # request completed.
167
+ driver = Thread.new { adapter.call(request('slow')).value! }
168
+ sleep 0.1
169
+
170
+ chained = adapter.call(request('fast')).then do |response|
171
+ [Thread.current, response.code]
172
+ end
173
+
174
+ expect(chained.value!).to eq [Thread.current, 200]
175
+ expect(completed_by_threads.first).to eq driver
176
+ ensure
177
+ driver&.join
178
+ end
179
+ end
180
+
181
+ context 'when waiting from within the event loop' do
182
+ it 'does not run the loop again' do
183
+ adapter.instance_variable_get(:@loop).acquire(0.1)
184
+
185
+ expect(adapter.drive(Restify::Promise.new, Restify::Timeout.new(0.1))).to be false
186
+ end
187
+ end
188
+ end
189
+
190
+ describe 'after fork' do
191
+ let(:adapter) { described_class.new }
192
+ let(:connects) { Queue.new }
193
+
194
+ before do
195
+ stub_request(:get, 'http://stubserver/fast')
196
+ .to_return(status: 200, body: '{}')
197
+ stub_request(:get, 'http://stubserver/slow')
198
+ .to_return { sleep 0.5 and {status: 200, body: '{}'} }
199
+
200
+ allow(adapter).to receive(:complete).and_wrap_original do |m, easy, *args|
201
+ connects << Ethon::Curl.get_info_long(:num_connects, easy.handle)
202
+ m.call(easy, *args)
203
+ end
204
+ end
205
+
206
+ def request(path)
207
+ Restify::Request.new(uri: "http://localhost:9292/#{path}")
208
+ end
209
+
210
+ def in_child
211
+ reader, writer = IO.pipe
212
+
213
+ pid = fork do
214
+ reader.close
215
+ result = begin
216
+ yield
217
+ rescue Exception => e # rubocop:disable Lint/RescueException
218
+ e
219
+ end
220
+ writer.write(Marshal.dump(result))
221
+ ensure
222
+ # Skip at exit handlers of the parent
223
+ exit!(0)
224
+ end
225
+
226
+ writer.close
227
+ Marshal.load(reader.read) # rubocop:disable Security/MarshalLoad
228
+ ensure
229
+ reader&.close
230
+ Process.wait(pid) if pid
231
+ end
232
+
233
+ it 'does not use connections of the parent process' do
234
+ # The parent process pools and reuses its connection.
235
+ 2.times { adapter.call(request('fast')).value! }
236
+ expect(Array.new(2) { connects.pop(timeout: 1) }).to eq [1, 0]
237
+
238
+ result = in_child do
239
+ code = adapter.call(request('fast')).value!.code
240
+ [code, connects.pop(timeout: 1)]
241
+ end
242
+
243
+ # A new connection, not the one pooled by the parent process
244
+ expect(result).to eq [200, 1]
245
+
246
+ # The parent process can still use its connections
247
+ expect(adapter.call(request('fast')).value!.code).to eq 200
248
+ end
249
+
250
+ it 'rejects requests started before the fork in the child process' do
251
+ promise = adapter.call(request('slow'))
252
+
253
+ result = in_child do
254
+ promise.value!
255
+ rescue Restify::NetworkError => e
256
+ e.message
257
+ end
258
+
259
+ expect(result).to include 'before fork'
260
+ expect(promise.value!.code).to eq 200
261
+ end
262
+ end
263
+ end