amqp-client 1.0.1 → 1.1.2
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 +4 -4
- data/.github/workflows/main.yml +2 -2
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +29 -12
- data/CHANGELOG.md +21 -0
- data/Gemfile +4 -0
- data/README.md +63 -28
- data/Rakefile +3 -1
- data/amqp-client.gemspec +1 -1
- data/lib/amqp/client/channel.rb +39 -32
- data/lib/amqp/client/connection.rb +107 -64
- data/lib/amqp/client/errors.rb +18 -1
- data/lib/amqp/client/{frames.rb → frame_bytes.rb} +34 -36
- data/lib/amqp/client/message.rb +101 -44
- data/lib/amqp/client/properties.rb +143 -76
- data/lib/amqp/client/queue.rb +15 -21
- data/lib/amqp/client/table.rb +51 -32
- data/lib/amqp/client/version.rb +1 -1
- data/lib/amqp/client.rb +27 -9
- data/sig/amqp-client.rbs +264 -0
- metadata +5 -4
@@ -3,7 +3,7 @@
|
|
3
3
|
require "socket"
|
4
4
|
require "uri"
|
5
5
|
require "openssl"
|
6
|
-
require_relative "./
|
6
|
+
require_relative "./frame_bytes"
|
7
7
|
require_relative "./channel"
|
8
8
|
require_relative "./errors"
|
9
9
|
|
@@ -13,58 +13,50 @@ module AMQP
|
|
13
13
|
class Connection
|
14
14
|
# Establish a connection to an AMQP broker
|
15
15
|
# @param uri [String] URL on the format amqp://username:password@hostname/vhost, use amqps:// for encrypted connection
|
16
|
-
# @param read_loop_thread [Boolean]
|
16
|
+
# @param read_loop_thread [Boolean] If true run {#read_loop} in a background thread,
|
17
|
+
# otherwise the user have to run it explicitly, without {#read_loop} the connection won't function
|
17
18
|
# @option options [Boolean] connection_name (PROGRAM_NAME) Set a name for the connection to be able to identify
|
18
19
|
# the client from the broker
|
19
20
|
# @option options [Boolean] verify_peer (true) Verify broker's TLS certificate, set to false for self-signed certs
|
21
|
+
# @option options [Integer] connect_timeout (30) TCP connection timeout
|
20
22
|
# @option options [Integer] heartbeat (0) Heartbeat timeout, defaults to 0 and relies on TCP keepalive instead
|
21
23
|
# @option options [Integer] frame_max (131_072) Maximum frame size,
|
22
24
|
# the smallest of the client's and the broker's values will be used
|
23
25
|
# @option options [Integer] channel_max (2048) Maxium number of channels the client will be allowed to have open.
|
24
26
|
# Maxium allowed is 65_536. The smallest of the client's and the broker's value will be used.
|
25
27
|
# @return [Connection]
|
26
|
-
def
|
28
|
+
def initialize(uri = "", read_loop_thread: true, **options)
|
27
29
|
uri = URI.parse(uri)
|
28
30
|
tls = uri.scheme == "amqps"
|
29
31
|
port = port_from_env || uri.port || (tls ? 5671 : 5672)
|
30
32
|
host = uri.host || "localhost"
|
31
33
|
user = uri.user || "guest"
|
32
34
|
password = uri.password || "guest"
|
33
|
-
vhost = URI.decode_www_form_component(uri.path[1
|
35
|
+
vhost = URI.decode_www_form_component(uri.path[1..] || "/")
|
34
36
|
options = URI.decode_www_form(uri.query || "").map! { |k, v| [k.to_sym, v] }.to_h.merge(options)
|
35
37
|
|
36
|
-
socket =
|
37
|
-
enable_tcp_keepalive(socket)
|
38
|
-
if tls
|
39
|
-
cert_store = OpenSSL::X509::Store.new
|
40
|
-
cert_store.set_default_paths
|
41
|
-
context = OpenSSL::SSL::SSLContext.new
|
42
|
-
context.cert_store = cert_store
|
43
|
-
context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless [false, "false", "none"].include? options[:verify_peer]
|
44
|
-
socket = OpenSSL::SSL::SSLSocket.new(socket, context)
|
45
|
-
socket.sync_close = true # closing the TLS socket also closes the TCP socket
|
46
|
-
socket.hostname = host # SNI host
|
47
|
-
socket.connect
|
48
|
-
socket.post_connection_check(host) || raise(Error, "TLS certificate hostname doesn't match requested")
|
49
|
-
end
|
38
|
+
socket = open_socket(host, port, tls, options)
|
50
39
|
channel_max, frame_max, heartbeat = establish(socket, user, password, vhost, options)
|
51
|
-
Connection.new(socket, channel_max, frame_max, heartbeat, read_loop_thread: read_loop_thread)
|
52
|
-
end
|
53
40
|
|
54
|
-
# Requires an already established TCP/TLS socket
|
55
|
-
# @api private
|
56
|
-
def initialize(socket, channel_max, frame_max, heartbeat, read_loop_thread: true)
|
57
41
|
@socket = socket
|
58
42
|
@channel_max = channel_max.zero? ? 65_536 : channel_max
|
59
43
|
@frame_max = frame_max
|
60
44
|
@heartbeat = heartbeat
|
61
45
|
@channels = {}
|
62
|
-
@closed =
|
46
|
+
@closed = nil
|
63
47
|
@replies = ::Queue.new
|
64
48
|
@write_lock = Mutex.new
|
49
|
+
@blocked = nil
|
65
50
|
Thread.new { read_loop } if read_loop_thread
|
66
51
|
end
|
67
52
|
|
53
|
+
# Alias for {#initialize}
|
54
|
+
# @see #initialize
|
55
|
+
# @deprecated
|
56
|
+
def self.connect(uri, read_loop_thread: true, **options)
|
57
|
+
new(uri, read_loop_thread: read_loop_thread, **options)
|
58
|
+
end
|
59
|
+
|
68
60
|
# The max frame size negotiated between the client and the broker
|
69
61
|
# @return [Integer]
|
70
62
|
attr_reader :frame_max
|
@@ -115,17 +107,21 @@ module AMQP
|
|
115
107
|
def close(reason: "", code: 200)
|
116
108
|
return if @closed
|
117
109
|
|
118
|
-
@closed =
|
119
|
-
|
120
|
-
@
|
121
|
-
|
110
|
+
@closed = [code, reason]
|
111
|
+
@channels.each_value { |ch| ch.closed!(:connection, code, reason, 0, 0) }
|
112
|
+
if @blocked
|
113
|
+
@socket.close
|
114
|
+
else
|
115
|
+
write_bytes FrameBytes.connection_close(code, reason)
|
116
|
+
expect(:close_ok)
|
117
|
+
end
|
122
118
|
nil
|
123
119
|
end
|
124
120
|
|
125
121
|
# True if the connection is closed
|
126
122
|
# @return [Boolean]
|
127
123
|
def closed?
|
128
|
-
|
124
|
+
!@closed.nil?
|
129
125
|
end
|
130
126
|
|
131
127
|
# Write byte array(s) directly to the socket (thread-safe)
|
@@ -133,10 +129,19 @@ module AMQP
|
|
133
129
|
# @return [Integer] number of bytes written
|
134
130
|
# @api private
|
135
131
|
def write_bytes(*bytes)
|
132
|
+
blocked = @blocked
|
133
|
+
warn "AMQP-Client blocked by broker: #{blocked}" if blocked
|
136
134
|
@write_lock.synchronize do
|
137
|
-
|
135
|
+
warn "AMQP-Client unblocked by broker" if blocked
|
136
|
+
if RUBY_ENGINE == "truffleruby"
|
137
|
+
bytes.each { |b| @socket.write b }
|
138
|
+
else
|
139
|
+
@socket.write(*bytes)
|
140
|
+
end
|
138
141
|
end
|
139
|
-
rescue
|
142
|
+
rescue *READ_EXCEPTIONS => e
|
143
|
+
raise Error::ConnectionClosed.new(*@closed) if @closed
|
144
|
+
|
140
145
|
raise Error, "Could not write to socket, #{e.message}"
|
141
146
|
end
|
142
147
|
|
@@ -151,12 +156,12 @@ module AMQP
|
|
151
156
|
frame_start = String.new(capacity: 7)
|
152
157
|
frame_buffer = String.new(capacity: frame_max)
|
153
158
|
loop do
|
154
|
-
socket.read(7, frame_start)
|
159
|
+
socket.read(7, frame_start) || raise(IOError)
|
155
160
|
type, channel_id, frame_size = frame_start.unpack("C S> L>")
|
156
161
|
frame_max >= frame_size || raise(Error, "Frame size #{frame_size} larger than negotiated max frame size #{frame_max}")
|
157
162
|
|
158
163
|
# read the frame content
|
159
|
-
socket.read(frame_size, frame_buffer)
|
164
|
+
socket.read(frame_size, frame_buffer) || raise(IOError)
|
160
165
|
|
161
166
|
# make sure that the frame end is correct
|
162
167
|
frame_end = socket.readchar.ord
|
@@ -166,21 +171,26 @@ module AMQP
|
|
166
171
|
parse_frame(type, channel_id, frame_buffer) || return
|
167
172
|
end
|
168
173
|
nil
|
169
|
-
rescue
|
170
|
-
|
174
|
+
rescue *READ_EXCEPTIONS => e
|
175
|
+
@closed ||= [400, "read error: #{e.message}"]
|
171
176
|
nil # ignore read errors
|
172
177
|
ensure
|
173
|
-
@closed
|
178
|
+
@closed ||= [400, "unknown"]
|
174
179
|
@replies.close
|
175
180
|
begin
|
176
|
-
@
|
177
|
-
|
181
|
+
@write_lock.synchronize do
|
182
|
+
@socket.close
|
183
|
+
end
|
184
|
+
rescue *READ_EXCEPTIONS
|
178
185
|
nil
|
179
186
|
end
|
180
187
|
end
|
181
188
|
|
182
189
|
private
|
183
190
|
|
191
|
+
READ_EXCEPTIONS = [IOError, OpenSSL::OpenSSLError, SystemCallError,
|
192
|
+
RUBY_ENGINE == "jruby" ? java.lang.NullPointerException : nil].compact.freeze
|
193
|
+
|
184
194
|
def parse_frame(type, channel_id, buf)
|
185
195
|
case type
|
186
196
|
when 1 # method frame
|
@@ -191,11 +201,11 @@ module AMQP
|
|
191
201
|
|
192
202
|
case method_id
|
193
203
|
when 50 # connection#close
|
194
|
-
@closed = true
|
195
204
|
code, text_len = buf.unpack("@4 S> C")
|
196
205
|
text = buf.byteslice(7, text_len).force_encoding("utf-8")
|
197
206
|
error_class_id, error_method_id = buf.byteslice(7 + text_len, 4).unpack("S> S>")
|
198
|
-
@
|
207
|
+
@closed = [code, text, error_class_id, error_method_id]
|
208
|
+
@channels.each_value { |ch| ch.closed!(:connection, code, text, error_class_id, error_method_id) }
|
199
209
|
begin
|
200
210
|
write_bytes FrameBytes.connection_close_ok
|
201
211
|
rescue Error
|
@@ -203,9 +213,16 @@ module AMQP
|
|
203
213
|
end
|
204
214
|
return false
|
205
215
|
when 51 # connection#close-ok
|
206
|
-
@closed = true
|
207
216
|
@replies.push [:close_ok]
|
208
217
|
return false
|
218
|
+
when 60 # connection#blocked
|
219
|
+
reason_len = buf.getbyte(4)
|
220
|
+
reason = buf.byteslice(5, reason_len).force_encoding("utf-8")
|
221
|
+
@blocked = reason
|
222
|
+
@write_lock.lock
|
223
|
+
when 61 # connection#unblocked
|
224
|
+
@blocked = nil
|
225
|
+
@write_lock.unlock
|
209
226
|
else raise Error::UnsupportedMethodFrame, class_id, method_id
|
210
227
|
end
|
211
228
|
when 20 # channel
|
@@ -217,7 +234,7 @@ module AMQP
|
|
217
234
|
reply_text = buf.byteslice(7, reply_text_len).force_encoding("utf-8")
|
218
235
|
classid, methodid = buf.byteslice(7 + reply_text_len, 4).unpack("S> S>")
|
219
236
|
channel = @channels.delete(channel_id)
|
220
|
-
channel.closed!(reply_code, reply_text, classid, methodid)
|
237
|
+
channel.closed!(:channel, reply_code, reply_text, classid, methodid)
|
221
238
|
write_bytes FrameBytes.channel_close_ok(channel_id)
|
222
239
|
when 41 # channel#close-ok
|
223
240
|
channel = @channels.delete(channel_id)
|
@@ -239,7 +256,7 @@ module AMQP
|
|
239
256
|
when 50 # queue
|
240
257
|
case method_id
|
241
258
|
when 11 # declare-ok
|
242
|
-
queue_name_len = buf.
|
259
|
+
queue_name_len = buf.getbyte(4)
|
243
260
|
queue_name = buf.byteslice(5, queue_name_len).force_encoding("utf-8")
|
244
261
|
message_count, consumer_count = buf.byteslice(5 + queue_name_len, 8).unpack("L> L>")
|
245
262
|
@channels[channel_id].reply [:queue_declare_ok, queue_name, message_count, consumer_count]
|
@@ -259,17 +276,17 @@ module AMQP
|
|
259
276
|
when 11 # qos-ok
|
260
277
|
@channels[channel_id].reply [:basic_qos_ok]
|
261
278
|
when 21 # consume-ok
|
262
|
-
tag_len = buf.
|
279
|
+
tag_len = buf.getbyte(4)
|
263
280
|
tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
|
264
281
|
@channels[channel_id].reply [:basic_consume_ok, tag]
|
265
282
|
when 30 # cancel
|
266
|
-
tag_len = buf.
|
283
|
+
tag_len = buf.getbyte(4)
|
267
284
|
tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
|
268
|
-
no_wait = buf
|
285
|
+
no_wait = buf.getbyte(5 + tag_len) == 1
|
269
286
|
@channels[channel_id].close_consumer(tag)
|
270
287
|
write_bytes FrameBytes.basic_cancel_ok(@id, tag) unless no_wait
|
271
288
|
when 31 # cancel-ok
|
272
|
-
tag_len = buf.
|
289
|
+
tag_len = buf.getbyte(4)
|
273
290
|
tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
|
274
291
|
@channels[channel_id].reply [:basic_cancel_ok, tag]
|
275
292
|
when 50 # return
|
@@ -277,23 +294,23 @@ module AMQP
|
|
277
294
|
pos = 7
|
278
295
|
reply_text = buf.byteslice(pos, reply_text_len).force_encoding("utf-8")
|
279
296
|
pos += reply_text_len
|
280
|
-
exchange_len = buf
|
297
|
+
exchange_len = buf.getbyte(pos)
|
281
298
|
pos += 1
|
282
299
|
exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
|
283
300
|
pos += exchange_len
|
284
|
-
routing_key_len = buf
|
301
|
+
routing_key_len = buf.getbyte(pos)
|
285
302
|
pos += 1
|
286
303
|
routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
|
287
304
|
@channels[channel_id].message_returned(reply_code, reply_text, exchange, routing_key)
|
288
305
|
when 60 # deliver
|
289
|
-
ctag_len = buf
|
306
|
+
ctag_len = buf.getbyte(4)
|
290
307
|
consumer_tag = buf.byteslice(5, ctag_len).force_encoding("utf-8")
|
291
308
|
pos = 5 + ctag_len
|
292
309
|
delivery_tag, redelivered, exchange_len = buf.byteslice(pos, 10).unpack("Q> C C")
|
293
310
|
pos += 8 + 1 + 1
|
294
311
|
exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
|
295
312
|
pos += exchange_len
|
296
|
-
rk_len = buf
|
313
|
+
rk_len = buf.getbyte(pos)
|
297
314
|
pos += 1
|
298
315
|
routing_key = buf.byteslice(pos, rk_len).force_encoding("utf-8")
|
299
316
|
@channels[channel_id].message_delivered(consumer_tag, delivery_tag, redelivered == 1, exchange, routing_key)
|
@@ -302,11 +319,11 @@ module AMQP
|
|
302
319
|
pos = 14
|
303
320
|
exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
|
304
321
|
pos += exchange_len
|
305
|
-
routing_key_len = buf
|
322
|
+
routing_key_len = buf.getbyte(pos)
|
306
323
|
pos += 1
|
307
324
|
routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
|
308
|
-
pos += routing_key_len
|
309
|
-
|
325
|
+
# pos += routing_key_len
|
326
|
+
# message_count = buf.byteslice(pos, 4).unpack1("L>")
|
310
327
|
@channels[channel_id].message_delivered(nil, delivery_tag, redelivered == 1, exchange, routing_key)
|
311
328
|
when 72 # get-empty
|
312
329
|
@channels[channel_id].basic_get_empty
|
@@ -340,7 +357,7 @@ module AMQP
|
|
340
357
|
end
|
341
358
|
when 2 # header
|
342
359
|
body_size = buf.unpack1("@4 Q>")
|
343
|
-
properties = Properties.decode(buf
|
360
|
+
properties = Properties.decode(buf, 12)
|
344
361
|
@channels[channel_id].header_delivered body_size, properties
|
345
362
|
when 3 # body
|
346
363
|
@channels[channel_id].body_delivered buf
|
@@ -360,21 +377,44 @@ module AMQP
|
|
360
377
|
args
|
361
378
|
end
|
362
379
|
|
380
|
+
# Connect to the host/port, optionally establish a TLS connection
|
381
|
+
# @return [Socket]
|
382
|
+
# @return [OpenSSL::SSL::SSLSocket]
|
383
|
+
def open_socket(host, port, tls, options)
|
384
|
+
connect_timeout = options.fetch(:connect_timeout, 30).to_i
|
385
|
+
socket = Socket.tcp host, port, connect_timeout: connect_timeout
|
386
|
+
enable_tcp_keepalive(socket)
|
387
|
+
if tls
|
388
|
+
cert_store = OpenSSL::X509::Store.new
|
389
|
+
cert_store.set_default_paths
|
390
|
+
context = OpenSSL::SSL::SSLContext.new
|
391
|
+
context.cert_store = cert_store
|
392
|
+
verify_peer = [false, "false", "none"].include? options[:verify_peer]
|
393
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless verify_peer
|
394
|
+
socket = OpenSSL::SSL::SSLSocket.new(socket, context)
|
395
|
+
socket.sync_close = true # closing the TLS socket also closes the TCP socket
|
396
|
+
socket.hostname = host # SNI host
|
397
|
+
socket.connect
|
398
|
+
socket.post_connection_check(host) || raise(Error, "TLS certificate hostname doesn't match requested")
|
399
|
+
end
|
400
|
+
socket
|
401
|
+
end
|
402
|
+
|
363
403
|
# Negotiate a connection
|
364
404
|
# @return [Array<Integer, Integer, Integer>] channel_max, frame_max, heartbeat
|
365
|
-
def
|
405
|
+
def establish(socket, user, password, vhost, options)
|
366
406
|
channel_max, frame_max, heartbeat = nil
|
367
407
|
socket.write "AMQP\x00\x00\x09\x01"
|
368
408
|
buf = String.new(capacity: 4096)
|
369
409
|
loop do
|
370
410
|
begin
|
371
411
|
socket.readpartial(4096, buf)
|
372
|
-
rescue
|
412
|
+
rescue *READ_EXCEPTIONS => e
|
373
413
|
raise Error, "Could not establish AMQP connection: #{e.message}"
|
374
414
|
end
|
375
415
|
|
376
416
|
type, channel_id, frame_size = buf.unpack("C S> L>")
|
377
|
-
frame_end = buf
|
417
|
+
frame_end = buf.getbyte(frame_size + 7)
|
378
418
|
raise UnexpectedFrameEndError, frame_end if frame_end != 206
|
379
419
|
|
380
420
|
case type
|
@@ -411,16 +451,18 @@ module AMQP
|
|
411
451
|
else raise Error, "Unexpected frame type: #{type}"
|
412
452
|
end
|
413
453
|
end
|
414
|
-
rescue
|
454
|
+
rescue Exception => e
|
415
455
|
begin
|
416
456
|
socket.close
|
417
|
-
rescue
|
457
|
+
rescue *READ_EXCEPTIONS
|
418
458
|
nil
|
419
459
|
end
|
420
460
|
raise e
|
421
461
|
end
|
422
462
|
|
423
|
-
|
463
|
+
# Enable TCP keepalive, which is prefered to heartbeats
|
464
|
+
# @return [void]
|
465
|
+
def enable_tcp_keepalive(socket)
|
424
466
|
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
425
467
|
socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 60)
|
426
468
|
socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
|
@@ -429,14 +471,15 @@ module AMQP
|
|
429
471
|
warn "AMQP-Client could not enable TCP keepalive on socket. #{e.inspect}"
|
430
472
|
end
|
431
473
|
|
432
|
-
|
474
|
+
# Fetch the AMQP port number from ENV
|
475
|
+
# @return [Integer] A port number
|
476
|
+
# @return [nil] When the environment variable AMQP_PORT isn't set
|
477
|
+
def port_from_env
|
433
478
|
return unless (port = ENV["AMQP_PORT"])
|
434
479
|
|
435
480
|
port.to_i
|
436
481
|
end
|
437
482
|
|
438
|
-
private_class_method :establish, :enable_tcp_keepalive, :port_from_env
|
439
|
-
|
440
483
|
CLIENT_PROPERTIES = {
|
441
484
|
capabilities: {
|
442
485
|
authentication_failure_close: true,
|
data/lib/amqp/client/errors.rb
CHANGED
@@ -32,6 +32,19 @@ module AMQP
|
|
32
32
|
end
|
33
33
|
end
|
34
34
|
|
35
|
+
# Depending on close level a ConnectionClosed or ChannelClosed error is returned
|
36
|
+
class Closed < Error
|
37
|
+
def self.new(id, level, code, reason, classid = 0, methodid = 0)
|
38
|
+
case level
|
39
|
+
when :connection
|
40
|
+
ConnectionClosed.new(code, reason, classid, methodid)
|
41
|
+
when :channel
|
42
|
+
ChannelClosed.new(id, code, reason, classid, methodid)
|
43
|
+
else raise ArgumentError, "invalid level '#{level}'"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
35
48
|
# Raised if channel is already closed
|
36
49
|
class ChannelClosed < Error
|
37
50
|
def initialize(id, code, reason, classid = 0, methodid = 0)
|
@@ -40,7 +53,11 @@ module AMQP
|
|
40
53
|
end
|
41
54
|
|
42
55
|
# Raised if connection is unexpectedly closed
|
43
|
-
class ConnectionClosed < Error
|
56
|
+
class ConnectionClosed < Error
|
57
|
+
def initialize(code, reason, classid = 0, methodid = 0)
|
58
|
+
super "Connection closed (#{code}) #{reason} (#{classid}/#{methodid})"
|
59
|
+
end
|
60
|
+
end
|
44
61
|
end
|
45
62
|
end
|
46
63
|
end
|