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.
@@ -3,6 +3,11 @@
3
3
  require 'ethon'
4
4
  require 'nio'
5
5
 
6
+ require 'restify/adapter/ethon/options'
7
+ require 'restify/adapter/ethon/easy'
8
+ require 'restify/adapter/ethon/event_loop'
9
+ require 'restify/adapter/ethon/pool'
10
+
6
11
  module Restify
7
12
  module Adapter
8
13
  #
@@ -27,6 +32,16 @@ module Restify
27
32
  class Ethon < Base
28
33
  include Logging
29
34
 
35
+ # Restify follows relations from URLs in server responses, and
36
+ # libcurl supports far more than HTTP, e.g. `file://` or `scp://`.
37
+ # Therefore only HTTP-like protocols must ever be used, both for
38
+ # the request itself and when following redirects.
39
+ #
40
+ # This is intentionally not configurable.
41
+ PROTOCOLS = %i[http https].freeze
42
+
43
+ # Set headers bypassing the wait for 100-continue responses many
44
+ # servers do not send correctly.
30
45
  DEFAULT_HEADERS = {
31
46
  'Expect' => '',
32
47
  'Transfer-Encoding' => '',
@@ -39,38 +54,31 @@ module Restify
39
54
  tcp_keepintvl: 5,
40
55
  }.freeze
41
56
 
42
- # Restify follows relations from URLs in server responses, and
43
- # libcurl supports far more than HTTP, e.g. `file://` or `scp://`.
44
- # Therefore only HTTP-like protocols must ever be used, both for
45
- # the request itself and when following redirects.
46
- #
47
- # This is intentionally not configurable.
48
- PROTOCOLS = %i[http https].freeze
57
+ ENFORCED_OPTIONS = {
58
+ # Do not use signal handlers in libcurl, as it would interfere
59
+ # with the threads.
60
+ nosignal: true,
49
61
 
50
- INTERESTS = {
51
- none: nil,
52
- in: :r,
53
- out: :w,
54
- inout: :rw,
62
+ # Do not allow using and redirecting to unexpected (e.g. local)
63
+ # protocols.
64
+ protocols: PROTOCOLS,
65
+ redir_protocols: PROTOCOLS,
55
66
  }.freeze
56
67
 
68
+ # Maximum number of idle easy handles kept for reuse.
69
+ POOL_SIZE = 64
70
+
57
71
  def initialize(options: {}, **multi)
58
- @options = DEFAULT_OPTIONS.merge(options)
72
+ @options = Options.new({
73
+ **DEFAULT_OPTIONS,
74
+ **options,
75
+ **ENFORCED_OPTIONS,
76
+ })
59
77
  @multi_options = multi
60
78
 
61
- # libcurl only stores the function pointers, therefore the procs
62
- # must be referenced here too, or they would be garbage
63
- # collected.
64
- @socketfunction = method(:on_socket).to_proc
65
- @timerfunction = method(:on_timer).to_proc
66
-
67
79
  @mutex = Mutex.new
68
80
  @thread = nil
69
81
 
70
- # Wake up the background thread from `select` when a waiting
71
- # thread wants to run the loop.
72
- @loop = LoopLock.new { @selector.wakeup }
73
-
74
82
  setup
75
83
 
76
84
  super()
@@ -87,22 +95,16 @@ module Restify
87
95
  url: request.uri,
88
96
  timeout: request.timeout
89
97
 
90
- @queue << easy
91
-
92
98
  # Ensure the event loop is running and let it pick up the new
93
99
  # request.
94
100
  thread
95
- @selector.wakeup
101
+
102
+ @events.enqueue(easy)
96
103
  end
97
104
 
98
105
  # Run the event loop in the calling thread until the promise is
99
- # complete or the timeout expires, see `Promise#wait`.
100
- #
101
- # Several threads can wait at the same time, but only one runs the
102
- # loop. The others sleep until either their promise completes or
103
- # the loop is released. The background thread keeps processing
104
- # requests nobody waits on, but releases the loop as soon as a
105
- # thread starts waiting.
106
+ # complete or the timeout expires, see `Promise#wait` and
107
+ # `EventLoop#drive`.
106
108
  #
107
109
  # This only processes transfers, i.e. fulfills or rejects the
108
110
  # adapter's promises. Callbacks chained with `Promise#then` still
@@ -111,22 +113,10 @@ module Restify
111
113
  # Returns false when the loop cannot be run in the calling thread,
112
114
  # i.e. when waiting from within the loop itself.
113
115
  #
114
- def drive(promise, timeout) # rubocop:disable Naming/PredicateMethod
116
+ def drive(promise, timeout)
115
117
  check_fork!
116
118
 
117
- return false if @loop.owned?
118
-
119
- while @loop.acquire(timeout) { promise.complete? }
120
- begin
121
- step(timeout.remaining) until promise.complete? || !timeout.remaining.positive?
122
- rescue StandardError => e
123
- error(e)
124
- ensure
125
- @loop.release
126
- end
127
- end
128
-
129
- true
119
+ @events.drive(promise, timeout)
130
120
  end
131
121
 
132
122
  private
@@ -136,15 +126,9 @@ module Restify
136
126
  end
137
127
 
138
128
  def setup
139
- @pid = Process.pid
140
- @selector = NIO::Selector.new
141
- @monitors = {}
142
- @timer = nil
143
- @queue = Queue.new
144
-
145
- @multi = ::Ethon::Multi.new(execution_mode: :socket_action, **@multi_options)
146
- @multi.socketfunction = @socketfunction
147
- @multi.timerfunction = @timerfunction
129
+ @pid = Process.pid
130
+ @pool = Pool.new(size: POOL_SIZE) { Easy.new }
131
+ @events = EventLoop.new(**@multi_options)
148
132
  end
149
133
 
150
134
  def check_fork!
@@ -160,91 +144,43 @@ module Restify
160
144
  # Therefore, abandon the state without releasing it, and set up
161
145
  # everything again.
162
146
  def forked!
163
- abandoned = @multi.easy_handles.dup
164
- abandoned << @queue.pop(true) until @queue.empty?
165
-
166
- @multi.handle.autorelease = false
167
- abandoned.each {|easy| easy.handle.autorelease = false }
147
+ @events.forked!
148
+ @pool.forked!
168
149
 
169
150
  @thread = nil
170
- @loop.reset!
171
151
  setup
172
-
173
- abandoned.each do |easy|
174
- easy._restify_writer.reject(
175
- Restify::NetworkError.new(easy._restify_request, 'Request started before fork'),
176
- )
177
- end
178
152
  end
179
153
 
180
154
  def convert(request, writer)
181
- Easy.new.tap do |easy|
155
+ @pool.checkout.tap do |easy|
182
156
  easy._otel_span = OpenTelemetry::Trace.current_span
183
157
  easy._restify_request = request
184
158
  easy._restify_writer = writer
185
159
 
186
- easy.http_request(
187
- request.uri.to_s,
188
- request.method,
189
- request_options(request),
190
- )
160
+ easy.prepare(request, @options)
191
161
 
192
162
  easy.on_complete do |completed|
193
163
  complete(completed, request, writer)
194
164
 
165
+ @pool.release(completed)
166
+
195
167
  # Wake up threads waiting on the loop to check if their
196
168
  # result is available now.
197
- @loop.notify
169
+ @events.notify
198
170
  end
199
171
  end
200
172
  end
201
173
 
202
- def request_options(request)
203
- # libcurl requires millisecond-based timeouts so fractional
204
- # seconds must be converted and rounded.
205
- timeout = request.timeout && (request.timeout * 1000).ceil
206
-
207
- @options.merge(
208
- headers: DEFAULT_HEADERS.merge(request.headers),
209
- body: request.body,
210
- timeout_ms: timeout,
211
- connecttimeout_ms: timeout,
212
-
213
- # Merged last, so that neither can be overridden via `options`.
214
- protocols: PROTOCOLS,
215
- redir_protocols: PROTOCOLS,
216
-
217
- # Do not use signal handlers in libcurl, as it would interfere
218
- # with the threads.
219
- nosignal: true,
220
- )
221
- end
222
-
223
174
  def complete(easy, request, writer)
224
175
  writer.set do
225
- code = easy.return_code
226
- status = easy.response_code
227
-
228
- debug 'request:complete',
229
- tag: request.object_id,
230
- status: status,
231
- message: code
232
-
233
- if code != :ok
234
- raise Restify::NetworkError.new(
235
- request,
236
- ::Ethon::Curl.easy_strerror(code),
237
- )
176
+ if logger&.debug?
177
+ debug 'request:complete',
178
+ tag: request.object_id,
179
+ status: easy.response_code,
180
+ message: easy.return_code
238
181
  end
239
182
 
240
- if status.nil? || status.zero?
241
- raise Restify::NetworkError.new(
242
- request,
243
- 'Response without HTTP status',
244
- )
245
- end
246
-
247
- convert_back(easy, request)
183
+ easy.response(request)
248
184
  end
249
185
  rescue StandardError => e
250
186
  # This runs inside a libcurl callback, therefore no exception
@@ -253,210 +189,23 @@ module Restify
253
189
  error(e)
254
190
  end
255
191
 
256
- def convert_back(easy, request)
257
- ::Restify::Response.new(
258
- request,
259
- effective_uri(easy, request),
260
- easy.response_code,
261
- convert_headers(easy.response_headers),
262
- easy.response_body,
263
- )
264
- end
265
-
266
- def effective_uri(easy, request)
267
- url = easy.effective_url
268
- url ? Addressable::URI.parse(url) : request.uri
269
- end
270
-
271
- def convert_headers(raw)
272
- headers = {}
273
- return headers if raw.nil?
274
-
275
- # The raw headers can contain multiple blocks, e.g. from
276
- # informational responses or when following redirects; only use
277
- # the latest ones:
278
- block = raw.split(/\r?\n\r?\n/).reject {|b| b.strip.empty? }.last
279
-
280
- # Split on newlines that are not followed by whitespace to keep
281
- # folded header values together.
282
- block.to_s.split(/\r?\n(?!\s)/).each do |line|
283
- line = line.strip
284
- next if line.empty? || line.start_with?('HTTP/')
285
-
286
- key, value = line.split(':', 2)
287
- next if value.nil?
288
-
289
- key = key.strip.upcase.tr('-', '_')
290
- value = value.strip.gsub(/\r?\n\s*/, ' ')
291
-
292
- case (current = headers[key])
293
- when nil then headers[key] = value
294
- when Array then current << value
295
- else headers[key] = [current, value]
296
- end
297
- end
298
-
299
- headers
300
- end
301
-
302
192
  def thread
303
193
  @mutex.synchronize do
304
194
  # Spawn thread if not yet started, or recreate it if it died.
305
195
  if @thread.nil? || !@thread.status
306
196
  debug 'loop:spawn'
307
- @thread = Thread.new { run }
308
- end
309
-
310
- @thread
311
- end
312
- end
313
-
314
- def run
315
- loop do
316
- @loop.acquire_background
317
197
 
318
- begin
319
- step
320
- ensure
321
- @loop.release
198
+ events = @events
199
+ @thread = Thread.new { events.run }
322
200
  end
323
- rescue StandardError => e
324
- error(e)
325
- end
326
- ensure
327
- debug 'loop:exit'
328
- end
329
-
330
- # Run one iteration of the event loop. Must only be called by the
331
- # thread owning the loop.
332
- def step(limit = nil)
333
- dequeue_all
334
-
335
- # libcurl needs to be notified about its own timeouts, e.g. to
336
- # start newly added transfers or to time out stalled ones.
337
- timeout!
338
-
339
- timeout = select_timeout
340
- timeout = limit if limit && (timeout.nil? || timeout > limit)
341
- debug 'loop:select', timeout: timeout
342
-
343
- # nil on timeout; empty array when woken up
344
- @selector.select(timeout)&.each do |monitor|
345
- # libcurl can remove sockets while earlier events of the same
346
- # batch are processed, e.g. when a completed transfer tears
347
- # down other connections. Skip monitors that are gone by now.
348
- next unless @monitors[monitor.value].equal?(monitor)
349
-
350
- socket_action(monitor.value, readiness(monitor))
351
- end
352
- end
353
-
354
- def dequeue_all
355
- loop do
356
- easy = begin
357
- @queue.pop(true)
358
- rescue ThreadError
359
- break
360
- end
361
-
362
- add(easy)
363
- end
364
- end
365
-
366
- def add(easy)
367
- # Restore the OpenTelemetry span the request originated from, so
368
- # that the Ethon instrumentation picks up the correct parent
369
- # when the easy handle is added to libcurl.
370
- #
371
- # Handle exceptions from Ethon or WebMock too, and reject the
372
- # promise, so that the errors can be handled in user code.
373
- OpenTelemetry::Trace.with_span(easy._otel_span) do
374
- @multi.add(easy)
375
- rescue Exception => e # rubocop:disable Lint/RescueException
376
- easy._restify_writer.reject(e)
377
- end
378
- end
379
-
380
- # Seconds until libcurl wants to be called again, or nil to block
381
- # until a socket becomes ready or the loop is woken up.
382
- def select_timeout
383
- return nil unless @timer
384
-
385
- [@timer - now, 0].max
386
- end
387
-
388
- def timeout!
389
- return unless @timer
390
- return if now < @timer
391
201
 
392
- # Reset before invoking libcurl, as it will set a new timeout
393
- # from within the socket action.
394
- @timer = nil
395
-
396
- socket_action
397
- end
398
-
399
- def readiness(monitor)
400
- readiness = []
401
- readiness << :in if monitor.readable?
402
- readiness << :out if monitor.writable?
403
- readiness
404
- end
405
-
406
- def socket_action(socket = nil, readiness = 0)
407
- @multi.socket_action(socket, readiness)
408
- end
409
-
410
- # libcurl: called when asking for readiness monitoring
411
- def on_socket(_easy, socket, what, _userp, _socketp)
412
- debug 'socket:action', tag: socket, what: what
413
-
414
- if what == :remove
415
- @monitors.delete(socket)&.close
416
- else
417
- monitor = (@monitors[socket] ||= register(socket))
418
- monitor.interests = INTERESTS.fetch(what)
419
- end
420
-
421
- :ok
422
- rescue StandardError => e
423
- error(e)
424
- :ok
425
- end
426
-
427
- # libcurl: called on timeout changes
428
- def on_timer(_handle, timeout_ms, _userp)
429
- debug 'timer:set', timeout: timeout_ms
430
- @timer = timeout_ms.negative? ? nil : now + (timeout_ms / 1000.0)
431
-
432
- :ok
433
- rescue StandardError => e
434
- error(e)
435
- :ok
436
- end
437
-
438
- def register(socket)
439
- # FD owned by libcurl: IO/ruby must never close it
440
- io = IO.for_fd(socket, autoclose: false)
441
-
442
- @selector.register(io, :r).tap do |monitor|
443
- monitor.value = socket
202
+ @thread
444
203
  end
445
204
  end
446
205
 
447
- def now
448
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
449
- end
450
-
451
206
  def _log_prefix
452
207
  "[#{object_id}/#{Thread.current.object_id}]"
453
208
  end
454
-
455
- # Keep track of the OTEL span, the request and the promise writer to
456
- # reject on errors in the background thread or after fork
457
- class Easy < ::Ethon::Easy
458
- attr_accessor :_otel_span, :_restify_request, :_restify_writer
459
- end
460
209
  end
461
210
  end
462
211
  end
@@ -26,7 +26,7 @@ module Restify
26
26
  OpenTelemetry.propagation.inject(request.headers)
27
27
 
28
28
  super.tap do |x|
29
- x.add_observer do |_, response, err|
29
+ x.add_observer do |response, err|
30
30
  if response
31
31
  span.set_attribute('http.response.status_code', response&.code)
32
32
  span.status = OpenTelemetry::Trace::Status.error unless (100..399).cover?(response&.code)
@@ -144,7 +144,11 @@ module Restify
144
144
 
145
145
  def effective_uri(response, request)
146
146
  url = response.effective_url
147
- url ? Addressable::URI.parse(url) : request.uri
147
+
148
+ # Only parse the URL after redirects, as parsing is expensive.
149
+ return request.uri if url.nil? || url == request.uri.to_s
150
+
151
+ Addressable::URI.parse(url)
148
152
  end
149
153
 
150
154
  def convert_headers(headers)
@@ -30,8 +30,18 @@ module Restify
30
30
  @options = kwargs
31
31
  end
32
32
 
33
+ # Resolve a URI against the context URI. Absolute URIs are returned
34
+ # as they are, e.g. when already joined by a relation.
35
+ #
36
+ # @param uri [Addressable::URI, String] URI to resolve.
37
+ # @return [Addressable::URI] Absolute URI.
38
+ #
33
39
  def join(uri)
34
- self.uri.join uri
40
+ if uri.is_a?(Addressable::URI) && uri.absolute?
41
+ uri
42
+ else
43
+ self.uri.join(uri)
44
+ end
35
45
  end
36
46
 
37
47
  def inherit(uri, **)
@@ -9,6 +9,8 @@ module Restify
9
9
  # Parses generic data structures into resources
10
10
  #
11
11
  module Parsing
12
+ RELATION_NAME = /\A\w+_url\z/i
13
+
12
14
  def load
13
15
  # No data, e.g. when a server sets a content type but sends an
14
16
  # empty body.
@@ -27,41 +29,44 @@ module Restify
27
29
 
28
30
  def parse(object, root: false)
29
31
  case object
30
- when Hash
31
- data = object.each_with_object({}) {|each, obj| parse_data(each, obj) }
32
- relations = object.each_with_object({}) {|each, obj| parse_rels(each, obj) }
33
-
34
- Resource.new context,
35
- data:,
36
- response: root ? response : nil,
37
- relations:
38
-
39
- when Array
40
- object.map {|each| parse(each) }
41
- else
42
- object
32
+ when Hash then build_resource(object, root)
33
+ when Array then object.map {|each| parse(each) }
34
+ else object
43
35
  end
44
36
  end
45
37
 
46
38
  private
47
39
 
48
- def parse_data(pair, data)
49
- data[pair[0].to_s] = parse pair[1]
40
+ # Build data and relations of a resource in one pass.
41
+ def build_resource(object, root)
42
+ data = {}
43
+ relations = {}
44
+
45
+ object.each_pair do |key, value|
46
+ key = key.to_s
47
+ data[key] = parse(value)
48
+ parse_relation(relations, key, value) if value.is_a?(String)
49
+ end
50
+
51
+ Resource.new(
52
+ context,
53
+ data:,
54
+ response: root ? response : nil,
55
+ relations:,
56
+ )
50
57
  end
51
58
 
52
- def parse_rels(pair, relations)
53
- name = case pair[0].to_s.downcase
54
- when /\A(\w+)_url\z/
55
- Regexp.last_match[1]
56
- when 'url'
57
- 'self'
58
- else
59
- return
59
+ def parse_relation(relations, key, value)
60
+ name = if key.match?(RELATION_NAME)
61
+ key[0, key.length - 4].downcase
62
+ elsif key.casecmp?('url')
63
+ 'self'
60
64
  end
61
65
 
62
- return if relations.key?(name) || pair[1].nil? || pair[1].to_s =~ /\A\w*\z/
66
+ return if name.nil? || relations.key?(name)
67
+ return if value.empty?
63
68
 
64
- relations[name] = pair[1].to_s
69
+ relations[name] = value
65
70
  end
66
71
  end
67
72
  end