restify 3.0.0.rc2 → 3.0.0.rc3

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.
@@ -1,95 +1,229 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Restify
4
- class Promise < Concurrent::IVar
5
- # A driver that can complete this promise in the waiting thread,
6
- # e.g. an adapter running its event loop. See `#wait`.
7
- #
4
+ class Promise
5
+ class AlreadyCompleteError < StandardError; end
6
+
8
7
  # @api private
9
8
  attr_writer :driver
10
9
 
10
+ # @api private
11
+ attr_reader :reason
12
+
11
13
  def initialize(*dependencies, &task)
12
- @task = task
13
- @dependencies = dependencies.flatten
14
+ @mutex = Mutex.new
15
+ @condition = nil
16
+ @observers = nil
14
17
  @driver = nil
18
+ @state = :pending
19
+ @value = nil
20
+ @reason = nil
21
+ @task = task
22
+ @dependencies = dependencies.empty? ? nil : dependencies.flatten
23
+
24
+ # When dependencies were passed in, but none are left after
25
+ # flattening, then we don't have to wait for explicit dependencies
26
+ # or resolution through a writer.
27
+ fulfill([]) if !@task && @dependencies&.empty?
28
+ end
15
29
 
16
- super(&nil)
30
+ def pending?
31
+ @state == :pending
32
+ end
33
+
34
+ def fulfilled?
35
+ @state == :fulfilled
36
+ end
37
+
38
+ def rejected?
39
+ @state == :rejected
40
+ end
41
+
42
+ def complete?
43
+ @state == :fulfilled || @state == :rejected
44
+ end
17
45
 
18
- # When dependencies were passed in, but none are left after flattening,
19
- # then we don't have to wait for explicit dependencies or resolution
20
- # through a writer.
21
- complete true, [], nil if !@task && @dependencies.empty? && dependencies.any?
46
+ def incomplete?
47
+ !complete?
22
48
  end
23
49
 
50
+ # Wait until the promise is complete.
51
+ #
52
+ # @param timeout [Numeric, Restify::Timeout, nil] Maximum seconds
53
+ # to wait, or `Restify::Timeout.default_timeout` if nil.
54
+ #
55
+ # @raise [Restify::Timeout::Error] When the timeout expired first.
56
+ #
57
+ # @return [self]
58
+ #
24
59
  def wait(timeout = nil)
25
- t = Timeout.new(timeout, self)
60
+ return self if complete?
26
61
 
27
- execute(t) if pending?
62
+ timeout = Timeout.new(timeout, self)
28
63
 
29
64
  # Let the driver run on the current thread instead of sleeping and
30
- # switching to another thread if possible. If unsupported, the
31
- # driver returns false.
32
- super unless incomplete? && @driver&.drive(self, t)
65
+ # switching to another thread if possible. The driver returns when
66
+ # the promise is complete, the timeout expired, or it cannot run
67
+ # here.
68
+ @driver&.drive(self, timeout)
69
+
70
+ until complete?
71
+ remaining = timeout.remaining
72
+ raise timeout unless remaining.positive?
33
73
 
34
- raise t if incomplete?
74
+ run(timeout) if claim(remaining)
75
+ end
35
76
 
36
77
  self
37
78
  end
38
79
 
80
+ # Wait until the promise is complete, and return its value, or nil
81
+ # if it was rejected.
82
+ #
83
+ # @see #wait
84
+ #
85
+ def value(timeout = nil)
86
+ wait(timeout)
87
+ @value
88
+ end
89
+
90
+ # Wait until the promise is complete, and return its value, or raise
91
+ # the reason it was rejected with.
92
+ #
93
+ # @see #wait
94
+ #
95
+ def value!(timeout = nil)
96
+ wait(timeout)
97
+ raise @reason if rejected?
98
+
99
+ @value
100
+ end
101
+
102
+ # Return a new promise that will be fulfilled with the result of the
103
+ # given block once this promise is complete.
104
+ #
39
105
  def then(&)
40
- Promise.new([self], &)
106
+ Promise.new(self, &)
41
107
  end
42
108
 
43
- def execute(timeout = nil)
44
- synchronize { ns_execute timeout }
109
+ # @api private
110
+ #
111
+ def add_observer(&block)
112
+ complete = @mutex.synchronize do
113
+ (@observers ||= []) << block unless complete?
114
+ complete?
115
+ end
116
+
117
+ yield(@value, @reason) if complete
118
+
119
+ self
120
+ end
121
+
122
+ private
123
+
124
+ def fulfill(value)
125
+ complete(:fulfilled, value, nil)
45
126
  end
46
127
 
47
- protected
128
+ def reject(reason)
129
+ complete(:rejected, nil, reason)
130
+ end
131
+
132
+ def complete(state, value, reason)
133
+ observers = @mutex.synchronize do
134
+ raise AlreadyCompleteError.new("Promise already #{@state}") if complete?
135
+
136
+ # Set the state last, as it is read without holding the lock:
137
+ @value = value
138
+ @reason = reason
139
+ @state = state
140
+
141
+ # Release everything not needed anymore:
142
+ @task = @dependencies = nil
48
143
 
49
- # @!visibility private
50
- def ns_execute(timeout = nil)
51
- return unless compare_and_set_state(:processing, :pending)
52
- return unless @task || @dependencies.any?
144
+ @condition&.broadcast
53
145
 
54
- executor = Concurrent::SafeTaskExecutor.new \
55
- method(:ns_exec), rescue_exception: true
146
+ @observers.tap { @observers = nil }
147
+ end
56
148
 
57
- success, value, reason = executor.execute(timeout)
149
+ observers&.each do |observer|
150
+ observer.call(value, reason)
151
+ rescue StandardError => e
152
+ Restify.logger&.error(self.class.name) { e }
153
+ end
58
154
 
59
- complete success, value, reason
155
+ self
60
156
  end
61
157
 
62
- # @!visibility private
63
- def ns_exec(timeout = nil)
64
- t = Timeout.new(timeout, self)
158
+ # Claim running the task for the current thread, or sleep until the
159
+ # promise is complete, or can be claimed, e.g. because another
160
+ # thread gave up running it.
161
+ #
162
+ # The mutex is only held to claim, not while running the task. Other
163
+ # threads sleep on the condition variable with their own remaining
164
+ # time, whereas blocking on the mutex could not time out.
165
+ #
166
+ def claim(remaining)
167
+ @mutex.synchronize do
168
+ next false if complete?
169
+
170
+ if @state == :pending && (@task || @dependencies)
171
+ @state = :processing
172
+ next true
173
+ end
65
174
 
66
- args = @dependencies.map do |d|
67
- t.wait_on!(d)
175
+ (@condition ||= ConditionVariable.new).wait(@mutex, remaining)
176
+ false
68
177
  end
178
+ end
69
179
 
70
- value = @task ? @task.call(*args) : args
71
- value = t.wait_on!(value) while value.is_a?(Promise)
72
- value
180
+ def run(timeout)
181
+ until complete?
182
+ if (rejected = @dependencies&.find {|d| d.wait(timeout).rejected? })
183
+ return reject(rejected.reason)
184
+ end
185
+
186
+ args = @dependencies&.map(&:value)
187
+
188
+ begin
189
+ value = @task ? @task.call(*args) : args
190
+ rescue Exception => e # rubocop:disable Lint/RescueException
191
+ return reject(e)
192
+ end
193
+
194
+ if value.is_a?(Promise)
195
+ @dependencies = [value]
196
+ @task = ->(v) { v }
197
+ else
198
+ fulfill(value)
199
+ end
200
+ end
201
+ ensure
202
+ # Give up the claim when the timeout expired, or the thread was
203
+ # killed. Another waiting thread can take over, and continue with
204
+ # the dependencies completed so far.
205
+ if @state == :processing
206
+ @mutex.synchronize do
207
+ @state = :pending
208
+ @condition&.broadcast
209
+ end
210
+ end
73
211
  end
74
212
 
75
213
  class << self
76
214
  def create(driver: nil)
77
- promise = Promise.new
215
+ promise = new
78
216
  promise.driver = driver
79
217
  yield Writer.new(promise)
80
218
  promise
81
219
  end
82
220
 
83
221
  def fulfilled(value)
84
- create do |writer|
85
- writer.fulfill value
86
- end
222
+ new.send(:fulfill, value)
87
223
  end
88
224
 
89
- def rejected(value)
90
- create do |writer|
91
- writer.reject value
92
- end
225
+ def rejected(reason)
226
+ new.send(:reject, reason)
93
227
  end
94
228
  end
95
229
 
@@ -99,11 +233,11 @@ module Restify
99
233
  end
100
234
 
101
235
  def fulfill(value)
102
- @promise.send :complete, true, value, nil
236
+ @promise.send(:fulfill, value)
103
237
  end
104
238
 
105
239
  def reject(reason)
106
- @promise.send :complete, false, nil, reason
240
+ @promise.send(:reject, reason)
107
241
  end
108
242
 
109
243
  def set
@@ -55,13 +55,15 @@ module Restify
55
55
  end
56
56
 
57
57
  def expand(params)
58
- params = convert params
59
- variables = extract! params
58
+ params = convert(params)
59
+ variables = extract!(params)
60
60
 
61
- uri = template.expand variables
62
- uri.query_values = (uri.query_values || {}).merge params if params.any?
61
+ return expand_static(params) if template.variables.empty?
63
62
 
64
- context.join uri
63
+ uri = template.expand(variables)
64
+ uri.query_values = (uri.query_values || {}).merge(params) if params.any?
65
+
66
+ context.join(uri)
65
67
  end
66
68
 
67
69
  def pattern
@@ -74,6 +76,18 @@ module Restify
74
76
 
75
77
  private
76
78
 
79
+ # A template without variables always expands to the same URI. It
80
+ # can be parsed and joined only once. The URI is frozen, as all
81
+ # requests share it.
82
+ def expand_static(params)
83
+ @static_uri ||= context.join(template.pattern).freeze
84
+ return @static_uri if params.empty?
85
+
86
+ @static_uri.dup.tap do |uri|
87
+ uri.query_values = (uri.query_values || {}).merge(params)
88
+ end
89
+ end
90
+
77
91
  def convert(params)
78
92
  params.each_pair.with_object({}) do |param, hash|
79
93
  hash[param[0]] = convert_param param[1]
@@ -8,62 +8,44 @@ module Restify
8
8
  attr_accessor :default_timeout
9
9
  end
10
10
 
11
- # Default wait timeout of 300 seconds.
12
11
  self.default_timeout = 300
13
12
 
13
+ # @return [Float] Seconds to wait in total.
14
+ attr_reader :duration
15
+
14
16
  def initialize(timeout, target = nil)
15
- @timeout = parse_timeout(timeout)
16
17
  @target = target
17
- @deadline = now + @timeout
18
- end
19
-
20
- def wait_on!(ivar)
21
- ivar.value!(wait_interval).tap do
22
- raise self unless ivar.complete?
23
- end
24
- rescue ::Timeout::Error
25
- raise self
26
- end
27
-
28
- def timeout!
29
- raise self if wait_interval <= 0
18
+ @duration = parse_timeout(timeout)
19
+ @deadline = now + @duration
30
20
  end
31
21
 
32
22
  def remaining
33
- wait_interval
23
+ @deadline - now
34
24
  end
35
25
 
36
26
  def exception
37
- Error.new(@target)
27
+ Error.new(@target, @duration)
38
28
  end
39
29
 
40
30
  private
41
31
 
42
- def wait_interval
43
- @deadline - now
44
- end
45
-
46
32
  def now
47
33
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
34
  end
49
35
 
50
36
  def parse_timeout(value)
51
- return self.class.default_timeout if value.nil?
52
-
53
- begin
54
- value = Float(value)
55
- rescue ArgumentError
56
- raise ArgumentError.new \
57
- "Timeout must be an number but is #{value}"
58
- end
37
+ value = self.class.default_timeout if value.nil?
38
+ timeout = Float(value, exception: false)
59
39
 
60
- raise ArgumentError.new "Timeout must be > 0 but is #{value.inspect}." unless value.positive?
40
+ raise ArgumentError.new("Timeout must be a number but is #{value.inspect}") unless timeout
41
+ raise ArgumentError.new("Timeout must be > 0 but is #{value.inspect}.") unless timeout.positive?
42
+ raise ArgumentError.new("Timeout must be finite but is #{value.inspect}.") unless timeout.finite?
61
43
 
62
- value
44
+ timeout
63
45
  end
64
46
 
65
47
  class << self
66
- def new(timeout, *args)
48
+ def new(timeout, target = nil)
67
49
  return timeout if timeout.is_a?(self)
68
50
 
69
51
  super
@@ -71,15 +53,16 @@ module Restify
71
53
  end
72
54
 
73
55
  class Error < ::Timeout::Error
74
- attr_reader :target
56
+ attr_reader :target, :duration
75
57
 
76
- def initialize(target)
58
+ def initialize(target, duration)
77
59
  @target = target
60
+ @duration = duration
78
61
 
79
62
  if @target
80
- super("Operation on #{@target} timed out")
63
+ super("Operation on #{@target} timed out after #{duration}s")
81
64
  else
82
- super('Operation timed out')
65
+ super("Operation timed out after #{duration}s")
83
66
  end
84
67
  end
85
68
  end
@@ -5,7 +5,7 @@ module Restify
5
5
  MAJOR = 3
6
6
  MINOR = 0
7
7
  PATCH = 0
8
- STAGE = :rc2
8
+ STAGE = :rc3
9
9
  STRING = [MAJOR, MINOR, PATCH, STAGE].compact.join('.').freeze
10
10
 
11
11
  def self.to_s
@@ -73,7 +73,7 @@ module WebMock
73
73
  yield.tap do |promise|
74
74
  # Observers run when the request is complete, even when
75
75
  # nobody waits on the promise.
76
- promise.add_observer do |_, response, _|
76
+ promise.add_observer do |response, _|
77
77
  next unless response
78
78
 
79
79
  CallbackRegistry.invoke_callbacks(
data/lib/restify.rb CHANGED
@@ -4,7 +4,6 @@ require 'forwardable'
4
4
 
5
5
  require 'restify/version'
6
6
 
7
- require 'concurrent'
8
7
  require 'addressable/uri'
9
8
  require 'addressable/template'
10
9
 
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Restify::Adapter::Ethon::Easy do
6
+ let(:easy) { described_class.new }
7
+
8
+ describe '#response' do
9
+ subject(:response) { easy.response(request) }
10
+
11
+ let(:request) { Restify::Request.new(uri: 'http://example.org/base') }
12
+
13
+ # A transfer is handed back from libcurl with its return code and, for
14
+ # HTTP, the status code of the response.
15
+ let(:info) do
16
+ {
17
+ return_code: :ok,
18
+ response_code: 200,
19
+ effective_url: 'http://example.org/base',
20
+ response_headers: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n",
21
+ response_body: '{}',
22
+ }
23
+ end
24
+
25
+ before { allow(easy).to receive_messages(info) }
26
+
27
+ it 'returns the response' do
28
+ expect(response).to be_a Restify::Response
29
+ expect(response).to have_attributes(code: 200, body: '{}', request:)
30
+ end
31
+
32
+ it 'reuses the request URI without redirects' do
33
+ expect(response.uri).to be request.uri
34
+ end
35
+
36
+ context 'when redirected' do
37
+ let(:info) { super().merge(effective_url: 'http://example.org/other/base') }
38
+
39
+ it 'uses the effective URL as the response URI' do
40
+ expect(response.uri.to_s).to eq 'http://example.org/other/base'
41
+ end
42
+ end
43
+
44
+ context 'when the transfer failed' do
45
+ let(:info) { super().merge(return_code: :couldnt_connect, response_code: 0) }
46
+
47
+ it { expect { response }.to raise_error Restify::NetworkError, /connect/i }
48
+ end
49
+
50
+ # Non-HTTP protocols are refused before a transfer is started, but
51
+ # libcurl still reports success with a zero status code whenever no
52
+ # HTTP status was received, and no status code at all when it cannot
53
+ # be read back. Neither has a response to process.
54
+ context 'without an HTTP status' do
55
+ let(:info) { super().merge(response_code: 0) }
56
+
57
+ it { expect { response }.to raise_error Restify::NetworkError, /without HTTP status/ }
58
+ end
59
+
60
+ context 'with an unavailable HTTP status' do
61
+ let(:info) { super().merge(response_code: nil) }
62
+
63
+ it { expect { response }.to raise_error Restify::NetworkError, /without HTTP status/ }
64
+ end
65
+
66
+ describe 'headers' do
67
+ subject(:headers) { response.headers }
68
+
69
+ context 'with multiple values' do
70
+ let(:info) { super().merge(response_headers: "HTTP/1.1 200 OK\r\nLink: <a>\r\nLink: <b>\r\n\r\n") }
71
+
72
+ it { is_expected.to eq('LINK' => ['<a>', '<b>']) }
73
+ end
74
+
75
+ context 'with a folded value' do
76
+ let(:info) { super().merge(response_headers: "HTTP/1.1 200 OK\r\nX-Long: first\r\n\tsecond\r\n\r\n") }
77
+
78
+ it { is_expected.to eq('X_LONG' => 'first second') }
79
+ end
80
+
81
+ context 'with multiple responses, e.g. after a redirect' do
82
+ let(:info) do
83
+ super().merge(
84
+ response_headers: "HTTP/1.1 302 Found\r\nLocation: /b\r\n\r\n" \
85
+ "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n",
86
+ )
87
+ end
88
+
89
+ it { is_expected.to eq('CONTENT_TYPE' => 'text/plain') }
90
+ end
91
+
92
+ context 'without headers' do
93
+ let(:info) { super().merge(response_headers: nil) }
94
+
95
+ it { is_expected.to eq({}) }
96
+ end
97
+ end
98
+ end
99
+
100
+ describe '#reset' do
101
+ it 'forgets the previous request' do
102
+ easy._otel_span = Object.new
103
+ easy._restify_request = Object.new
104
+ easy._restify_writer = Object.new
105
+
106
+ easy.reset
107
+
108
+ expect([easy._otel_span, easy._restify_request, easy._restify_writer]).to all be_nil
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Restify::Adapter::Ethon::EventLoop do
6
+ subject(:events) { described_class.new }
7
+
8
+ let(:threads) { [] }
9
+
10
+ let(:options) do
11
+ Restify::Adapter::Ethon::Options.new(Restify::Adapter::Ethon::ENFORCED_OPTIONS)
12
+ end
13
+
14
+ before do
15
+ stub_request(:get, 'http://stubserver/base').to_return(status: 200, body: '{}')
16
+ end
17
+
18
+ after { threads.each(&:kill).each(&:join) }
19
+
20
+ # A prepared easy handle with its promise, and a queue recording the
21
+ # thread completing it.
22
+ def transfer
23
+ request = Restify::Request.new(uri: 'http://localhost:9292/base')
24
+ writer = nil
25
+ promise = Restify::Promise.create {|w| writer = w }
26
+ completed_by = Queue.new
27
+
28
+ easy = Restify::Adapter::Ethon::Easy.new
29
+ easy._restify_request = request
30
+ easy._restify_writer = writer
31
+ easy.prepare(request, options)
32
+ easy.on_complete do |completed|
33
+ completed_by << Thread.current
34
+ writer.set { completed.response(request) }
35
+ events.notify
36
+ end
37
+
38
+ [easy, promise, completed_by]
39
+ end
40
+
41
+ describe '#drive' do
42
+ it 'runs transfers in the calling thread' do
43
+ easy, promise, completed_by = transfer
44
+ events.enqueue(easy)
45
+
46
+ expect(events.drive(promise, Restify::Timeout.new(1))).to be true
47
+ expect(promise.value.code).to eq 200
48
+ expect(completed_by.pop(timeout: 1)).to eq Thread.current
49
+ end
50
+
51
+ it 'gives up when the timeout expires' do
52
+ expect(events.drive(Restify::Promise.new, Restify::Timeout.new(0.1))).to be true
53
+ end
54
+
55
+ it 'does not run the loop again from within the loop' do
56
+ events.instance_variable_get(:@lock).acquire(0.1)
57
+
58
+ expect(events.drive(Restify::Promise.new, Restify::Timeout.new(0.1))).to be false
59
+ end
60
+ end
61
+
62
+ describe '#run' do
63
+ it 'completes transfers enqueued from other threads' do
64
+ background = Thread.new { events.run }
65
+ threads << background
66
+
67
+ easy, promise, completed_by = transfer
68
+ events.enqueue(easy)
69
+
70
+ expect(completed_by.pop(timeout: 1)).to eq background
71
+ expect(promise).to be_fulfilled
72
+ end
73
+ end
74
+
75
+ describe '#forked!' do
76
+ it 'rejects pending transfers' do
77
+ easy, promise, = transfer
78
+ events.enqueue(easy)
79
+
80
+ events.forked!
81
+
82
+ expect(promise).to be_rejected
83
+ expect(promise.reason).to be_a Restify::NetworkError
84
+ expect(promise.reason.message).to include 'Request started before fork'
85
+ end
86
+
87
+ it 'does not release pending handles' do
88
+ easy, = transfer
89
+ events.enqueue(easy)
90
+
91
+ events.forked!
92
+
93
+ expect(easy.handle.autorelease?).to be false
94
+ end
95
+ end
96
+ end