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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99d1fd08757bc62db3bd58386310893f58224c6f0424980c412448c821021b20
4
- data.tar.gz: 97ea53432dcee6d6089332eb1783dfd04108f62f88ce3070f1c06b03d7cb1360
3
+ metadata.gz: d8fa974df7fe0cd06afd87ffe8bef89d87559ece784a801070aa0b8342b883eb
4
+ data.tar.gz: 1f3cc52e1b4bf7995f50dbe5ef9360986d71c7d157b47b1527f79cca2ff66179
5
5
  SHA512:
6
- metadata.gz: 705892e87eeedcbc9bda88352e2444b9217c01fce181f59fd1e228870c5b812cfc28208bf4d93db436ff65fb94ccc87782320ecd4bb0f17c260e6fc8e90446c5
7
- data.tar.gz: d9a471e407b564ce115c1e197f0b0e74d74a5553e174529837b36d81d1e936d84121be97406d0ef075138d04a010d32e541eab9f1bf8d7fc1862b975acf7eed4
6
+ metadata.gz: cdc59e5ca15e38eb8224b638e675807f669cf0ee00802427334238107ce8a5cddc16b71cbec12e0013055fe5431b38033509381f4c1a1727e07c38cfed1be4fc
7
+ data.tar.gz: 7da86c8fb8dbf2829da84e9f3e0c5d47d226e360a35cc7b6b33e1da02d7507ef4570d703d31c3ef7448fb3586592218d5924b926e70d1694abfdb916de6e1c4f
data/CHANGELOG.md CHANGED
@@ -17,6 +17,33 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
17
17
 
18
18
  ### Breaks
19
19
 
20
+ ## 3.0.0-rc3 - (2026-09-27)
21
+
22
+ ---
23
+
24
+ ### Changes
25
+
26
+ - Perf: Avoid parsing/joining URIs when not necessary, e.g., for relations without variables or absolute URIs.
27
+ - Perf: Reuse libcurl handles in the Ethon adapter
28
+ - Perf: Optimize libcurl option preparation in the Ethon adapter
29
+ - Perf: Optimize parsing of data and relations
30
+ - Perf: Replace `Concurrent::IVar` with a lightweight promise implementation
31
+ - Remove `concurrent-ruby` dependency
32
+
33
+ ### Fixes
34
+
35
+ - Apply `Restify::Timeout.default_timeout` when waiting on a promise without
36
+ an explicit timeout, e.g. with the Typhoeus adapter, instead of waiting forever
37
+ - A timeout while waiting on a promise does not reject the promise anymore.
38
+ Waiting again, e.g. with a longer timeout, continues where the last wait gave up.
39
+ - Honor the timeout of all threads waiting on the same promise, not only of the
40
+ thread running its `#then` blocks
41
+ - Only create relations from `*_url` fields with string values, e.g. not from numbers, objects, or arrays
42
+
43
+ ### Breaks
44
+
45
+ - `Restify::Promise` does not inherit from `Concurrent::IVar` anymore
46
+
20
47
  ## 3.0.0.rc2 - (2026-09-25)
21
48
 
22
49
  ---
data/README.md CHANGED
@@ -11,7 +11,6 @@ Restify can be used to consume hypermedia REST APIs (like GitHubs), to build a s
11
11
 
12
12
  Restify is build upon the following libraries:
13
13
 
14
- * [concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby)
15
14
  * [addressable](https://github.com/sporkmonger/addressable)
16
15
  * [ethon](https://github.com/typhoeus/ethon), using libcurl
17
16
 
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Restify
4
+ module Adapter
5
+ class Ethon < Base
6
+ #
7
+ # A libcurl easy handle for Restify requests, translating a
8
+ # request into libcurl options, and a completed transfer back into
9
+ # a response.
10
+ #
11
+ # Handles are reused for many requests, see `Pool`.
12
+ #
13
+ # @api private
14
+ #
15
+ class Easy < ::Ethon::Easy
16
+ attr_accessor :_otel_span, :_restify_request, :_restify_writer
17
+
18
+ TIMEOUT_MS = Options.number(:timeout_ms)
19
+ CONNECTTIMEOUT_MS = Options.number(:connecttimeout_ms)
20
+
21
+ def reset
22
+ @_otel_span = @_restify_request = @_restify_writer = nil
23
+
24
+ super
25
+ end
26
+
27
+ def prepare(request, options)
28
+ options.apply(self)
29
+
30
+ self.url = request.uri.to_s
31
+ self.headers = DEFAULT_HEADERS.merge(request.headers)
32
+
33
+ if request.timeout
34
+ # libcurl requires millisecond-based timeouts so fractional
35
+ # seconds must be converted and rounded.
36
+ timeout = (request.timeout * 1000).ceil
37
+ ::Ethon::Curl.easy_setopt(handle, TIMEOUT_MS, :long, timeout)
38
+ ::Ethon::Curl.easy_setopt(handle, CONNECTTIMEOUT_MS, :long, timeout)
39
+ end
40
+
41
+ prepare_method(request.method, request.body)
42
+
43
+ # The OpenTelemetry instrumentation for Ethon takes the method
44
+ # from `#http_request`, which is not used here.
45
+ @otel_method = request.method
46
+ end
47
+
48
+ def response(request)
49
+ code = return_code
50
+ if code != :ok
51
+ raise Restify::NetworkError.new(request, ::Ethon::Curl.easy_strerror(code))
52
+ end
53
+
54
+ # Non-HTTP protocols are refused before a transfer is started,
55
+ # but libcurl still reports success with a zero status code
56
+ # whenever no HTTP status was received, and no status code at
57
+ # all when it cannot be read back.
58
+ status = response_code
59
+ if status.nil? || status.zero?
60
+ raise Restify::NetworkError.new(request, 'Response without HTTP status')
61
+ end
62
+
63
+ ::Restify::Response.new(
64
+ request,
65
+ effective_uri(request),
66
+ status,
67
+ parse_headers(response_headers),
68
+ response_body,
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ # Like Ethon's actions for each method, see `Ethon::Easy::Http`.
75
+ # An empty body counts as no body.
76
+ def prepare_method(method, body)
77
+ body = nil if body.nil? || body.empty?
78
+
79
+ case method
80
+ when 'get'
81
+ if body
82
+ postfields(body)
83
+ self.customrequest = 'GET'
84
+ end
85
+ when 'post'
86
+ postfields(body || '')
87
+ when 'put'
88
+ self.upload = true
89
+ self.infilesize = body ? body.bytesize : 0
90
+ set_read_callback(body) if body
91
+ when 'head'
92
+ postfields(body) if body
93
+ self.nobody = true
94
+ else
95
+ postfields(body) if body
96
+ self.customrequest = method.upcase
97
+ end
98
+ end
99
+
100
+ def postfields(body)
101
+ self.postfieldsize = body.bytesize
102
+ self.copypostfields = body
103
+ end
104
+
105
+ def effective_uri(request)
106
+ url = effective_url
107
+
108
+ # Only parse the URL after redirects, as parsing is expensive.
109
+ return request.uri if url.nil? || url == request.uri.to_s
110
+
111
+ Addressable::URI.parse(url)
112
+ end
113
+
114
+ def parse_headers(raw)
115
+ headers = {}
116
+ return headers if raw.nil?
117
+
118
+ # The raw headers can contain multiple blocks, e.g. from
119
+ # informational responses or when following redirects; only
120
+ # use the latest ones:
121
+ block = raw.split(/\r?\n\r?\n/).reject {|b| b.strip.empty? }.last
122
+
123
+ # Split on newlines that are not followed by whitespace to
124
+ # keep folded header values together.
125
+ block.to_s.split(/\r?\n(?!\s)/).each do |line|
126
+ line = line.strip
127
+ next if line.empty? || line.start_with?('HTTP/')
128
+
129
+ key, value = line.split(':', 2)
130
+ next if value.nil?
131
+
132
+ key = key.strip.upcase.tr('-', '_')
133
+ value = value.strip.gsub(/\r?\n\s*/, ' ')
134
+
135
+ case (current = headers[key])
136
+ when nil then headers[key] = value
137
+ when Array then current << value
138
+ else headers[key] = [current, value]
139
+ end
140
+ end
141
+
142
+ headers
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Restify
4
+ module Adapter
5
+ class Ethon < Base
6
+ #
7
+ # An event loop running libcurl's multi interface in
8
+ # `socket_action` mode, waiting for sockets with nio4r.
9
+ #
10
+ # Can woken up to add new easy handles immediately but can run
11
+ # from any thread. `LoopLock` ensures all interaction with libcurl
12
+ # is still serialized and threads are correctly prioritized.
13
+ #
14
+ # @api private
15
+ #
16
+ class EventLoop
17
+ include Logging
18
+
19
+ INTERESTS = {
20
+ none: nil,
21
+ in: :r,
22
+ out: :w,
23
+ inout: :rw,
24
+ }.freeze
25
+
26
+ def initialize(**multi)
27
+ @selector = NIO::Selector.new
28
+ @monitors = {}
29
+ @timer = nil
30
+ @queue = Queue.new
31
+
32
+ # libcurl only stores the function pointers, therefore the procs
33
+ # must be referenced here too, or they would be garbage
34
+ # collected.
35
+ @socketfunction = method(:on_socket).to_proc
36
+ @timerfunction = method(:on_timer).to_proc
37
+
38
+ @multi = ::Ethon::Multi.new(execution_mode: :socket_action, **multi)
39
+ @multi.socketfunction = @socketfunction
40
+ @multi.timerfunction = @timerfunction
41
+
42
+ # Wake up the background thread from `select` when a waiting
43
+ # thread wants to run the loop.
44
+ @lock = LoopLock.new { @selector.wakeup }
45
+ end
46
+
47
+ def enqueue(easy)
48
+ @queue << easy
49
+ @selector.wakeup
50
+ end
51
+
52
+ # Wake up threads waiting for the loop, e.g. after completing a
53
+ # transfer, to check if their result is available now.
54
+ def notify
55
+ @lock.notify
56
+ end
57
+
58
+ # Run the loop in the calling thread until the promise is
59
+ # complete or the timeout expires.
60
+ #
61
+ # Several threads can wait at the same time, but only one runs
62
+ # the loop. The others sleep until either their promise
63
+ # completes or the loop is released.
64
+ #
65
+ # @return [Boolean] false when the loop cannot be run in the
66
+ # calling thread, i.e. when waiting from within the loop
67
+ # itself.
68
+ #
69
+ def drive(promise, timeout) # rubocop:disable Naming/PredicateMethod
70
+ return false if @lock.owned?
71
+
72
+ while @lock.acquire(timeout) { promise.complete? }
73
+ begin
74
+ step(timeout.remaining) until promise.complete? || !timeout.remaining.positive?
75
+ rescue StandardError => e
76
+ error(e)
77
+ ensure
78
+ @lock.release
79
+ end
80
+ end
81
+
82
+ true
83
+ end
84
+
85
+ # Run the loop whenever no thread waits for it. Blocks forever,
86
+ # as meant for a background thread.
87
+ def run
88
+ loop do
89
+ @lock.acquire_background
90
+
91
+ begin
92
+ step
93
+ ensure
94
+ @lock.release
95
+ end
96
+ rescue StandardError => e
97
+ error(e)
98
+ end
99
+ ensure
100
+ debug 'loop:exit'
101
+ end
102
+
103
+ # Forget the loop in a forked child process, without releasing
104
+ # its libcurl handles, as they belong to the parent process.
105
+ # Transfers not completed yet are rejected, as they were started
106
+ # by the parent process too.
107
+ #
108
+ def forked!
109
+ pending = @multi.easy_handles.dup
110
+ pending << @queue.pop(true) until @queue.empty?
111
+
112
+ @multi.handle.autorelease = false
113
+
114
+ pending.each do |easy|
115
+ easy.handle.autorelease = false
116
+ easy._restify_writer.reject(
117
+ Restify::NetworkError.new(
118
+ easy._restify_request,
119
+ 'Request started before fork',
120
+ ),
121
+ )
122
+ end
123
+
124
+ nil
125
+ end
126
+
127
+ private
128
+
129
+ def step(limit = nil)
130
+ dequeue_all
131
+
132
+ # libcurl needs to be notified about its own timeouts, e.g. to
133
+ # start newly added transfers or to time out stalled ones.
134
+ timeout!
135
+
136
+ timeout = select_timeout
137
+ timeout = limit if limit && (timeout.nil? || timeout > limit)
138
+ debug 'loop:select', timeout: timeout
139
+
140
+ # nil on timeout; empty array when woken up
141
+ @selector.select(timeout)&.each do |monitor|
142
+ # libcurl can remove sockets while earlier events of the
143
+ # same batch are processed, e.g. when a completed transfer
144
+ # tears down other connections. Skip monitors that are gone
145
+ # by now.
146
+ next unless @monitors[monitor.value].equal?(monitor)
147
+
148
+ socket_action(monitor.value, readiness(monitor))
149
+ end
150
+ end
151
+
152
+ def dequeue_all
153
+ loop do
154
+ easy = begin
155
+ @queue.pop(true)
156
+ rescue ThreadError
157
+ break
158
+ end
159
+
160
+ add(easy)
161
+ end
162
+ end
163
+
164
+ def add(easy)
165
+ # Restore the OpenTelemetry span the request originated from,
166
+ # so that the Ethon instrumentation picks up the correct
167
+ # parent when the easy handle is added to libcurl.
168
+ #
169
+ # Handle exceptions from Ethon or WebMock too, and reject the
170
+ # promise, so that the errors can be handled in user code.
171
+ OpenTelemetry::Trace.with_span(easy._otel_span) do
172
+ @multi.add(easy)
173
+ rescue Exception => e # rubocop:disable Lint/RescueException
174
+ easy._restify_writer.reject(e)
175
+ end
176
+ end
177
+
178
+ # Seconds until libcurl wants to be called again, or nil to
179
+ # block until a socket becomes ready or the loop is woken up.
180
+ def select_timeout
181
+ return nil unless @timer
182
+
183
+ [@timer - now, 0].max
184
+ end
185
+
186
+ def timeout!
187
+ return unless @timer
188
+ return if now < @timer
189
+
190
+ # Reset before invoking libcurl, as it will set a new timeout
191
+ # from within the socket action.
192
+ @timer = nil
193
+
194
+ socket_action
195
+ end
196
+
197
+ def readiness(monitor)
198
+ readiness = []
199
+ readiness << :in if monitor.readable?
200
+ readiness << :out if monitor.writable?
201
+ readiness
202
+ end
203
+
204
+ def socket_action(socket = nil, readiness = 0)
205
+ @multi.socket_action(socket, readiness)
206
+ end
207
+
208
+ # libcurl: called when asking for readiness monitoring
209
+ def on_socket(_easy, socket, what, _userp, _socketp)
210
+ debug 'socket:action', tag: socket, what: what
211
+
212
+ if what == :remove
213
+ @monitors.delete(socket)&.close
214
+ else
215
+ monitor = (@monitors[socket] ||= register(socket))
216
+ monitor.interests = INTERESTS.fetch(what)
217
+ end
218
+
219
+ :ok
220
+ rescue StandardError => e
221
+ error(e)
222
+ :ok
223
+ end
224
+
225
+ # libcurl: called on timeout changes
226
+ def on_timer(_handle, timeout_ms, _userp)
227
+ debug 'timer:set', timeout: timeout_ms
228
+ @timer = timeout_ms.negative? ? nil : now + (timeout_ms / 1000.0)
229
+
230
+ :ok
231
+ rescue StandardError => e
232
+ error(e)
233
+ :ok
234
+ end
235
+
236
+ def register(socket)
237
+ # FD owned by libcurl: IO/ruby must never close it
238
+ io = IO.for_fd(socket, autoclose: false)
239
+
240
+ @selector.register(io, :r).tap do |monitor|
241
+ monitor.value = socket
242
+ end
243
+ end
244
+
245
+ def now
246
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
247
+ end
248
+
249
+ def _log_prefix
250
+ "[#{object_id}/#{Thread.current.object_id}]"
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Restify
4
+ module Adapter
5
+ class Ethon < Base
6
+ #
7
+ # libcurl options, converted to the arguments for
8
+ # `curl_easy_setopt` once for performance reasons.
9
+ #
10
+ # @api private
11
+ #
12
+ class Options
13
+ DEFINITIONS = ::Ethon::Curl.easy_options(nil)
14
+
15
+ class << self
16
+ def number(name)
17
+ definition(name).fetch(:opt)
18
+ end
19
+
20
+ def definition(name)
21
+ DEFINITIONS.fetch(name) do
22
+ raise ArgumentError.new("Unknown libcurl option: #{name}")
23
+ end
24
+ end
25
+ end
26
+
27
+ # Options by name, e.g. `followlocation: true`, nil values are
28
+ # skipped.
29
+ #
30
+ def initialize(options)
31
+ @options = options.filter_map do |name, value|
32
+ definition = self.class.definition(name)
33
+ next if value.nil?
34
+
35
+ compile(name, definition, value)
36
+ end.freeze
37
+ end
38
+
39
+ def apply(easy)
40
+ handle = easy.handle
41
+
42
+ @options.each do |opt, type, value|
43
+ if type
44
+ ::Ethon::Curl.easy_setopt(handle, opt, type, value)
45
+ else
46
+ ::Ethon::Curl.set_option(opt, value, handle)
47
+ end
48
+ end
49
+ end
50
+
51
+ def to_a
52
+ @options
53
+ end
54
+
55
+ private
56
+
57
+ # Like `Ethon::Curls::Options#set_option` for the supported types.
58
+ def compile(name, definition, value)
59
+ opt = definition[:opt]
60
+
61
+ case definition[:type]
62
+ when :bool then [opt, :long, value && value != 0 ? 1 : 0]
63
+ when :int, :time then [opt, :long, Integer(value)]
64
+ when :enum then [opt, :long, lookup(name, definition, value)]
65
+ when :bitmask then [opt, :long, Array(value).reduce(0) {|mask, v| mask | lookup(name, definition, v) }]
66
+ when :string then [opt, :string, value.to_s]
67
+ else [name, nil, value]
68
+ end
69
+ end
70
+
71
+ def lookup(name, definition, value)
72
+ return Integer(value) unless value.is_a?(Symbol) || value.is_a?(String)
73
+
74
+ definition[:opts].fetch(value.to_sym) do
75
+ raise ArgumentError.new("Unknown value for libcurl option #{name}: #{value.inspect}")
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Restify
4
+ module Adapter
5
+ class Ethon < Base
6
+ #
7
+ # Idle libcurl easy handles, kept for reuse.
8
+ #
9
+ # Handles can be taken and released from any thread. A handle must
10
+ # only be released once libcurl is done with it.
11
+ #
12
+ # @api private
13
+ #
14
+ class Pool
15
+ def initialize(size:, &factory)
16
+ @size = size
17
+ @factory = factory
18
+ @mutex = Mutex.new
19
+ @idle = []
20
+ end
21
+
22
+ def checkout
23
+ @mutex.synchronize { @idle.pop } || @factory.call
24
+ end
25
+
26
+ def release(easy)
27
+ easy.reset
28
+
29
+ @mutex.synchronize { @idle << easy if @idle.size < @size }
30
+ end
31
+
32
+ # Forget all handles without releasing them as they belong to
33
+ # the parent process.
34
+ def forked!
35
+ @mutex.synchronize do
36
+ @idle.each {|easy| easy.handle.autorelease = false }
37
+ @idle.clear
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end