amqp-client 1.0.1 → 1.1.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|