tether 0.1.0

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.
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tether
4
+ # Base class for every error raised by tether.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the server sends bytes that violate the client protocol.
8
+ class ProtocolError < Error; end
9
+
10
+ # Raised when a {Deadline} expires before an operation completes.
11
+ class TimeoutError < Error; end
12
+
13
+ # Raised when a request reaches a subject that has no subscribers.
14
+ class NoRespondersError < Error; end
15
+
16
+ # Raised when a payload exceeds the server's advertised max_payload.
17
+ class MaxPayloadError < Error; end
18
+
19
+ # Raised when a subscription's pending queue overflows.
20
+ class SlowConsumerError < Error; end
21
+
22
+ # Raised when a subject or queue group is not a valid NATS token sequence.
23
+ class InvalidSubjectError < Error; end
24
+
25
+ # Base class for errors about the state of the connection itself.
26
+ class ConnectionError < Error; end
27
+
28
+ # Raised when an operation is attempted on a closed connection.
29
+ class ConnectionClosedError < ConnectionError; end
30
+
31
+ # Raised when an operation is attempted on a connection that is draining.
32
+ class ConnectionDrainingError < ConnectionError; end
33
+
34
+ # Raised when an operation cannot be served while reconnecting.
35
+ class ConnectionReconnectingError < ConnectionError; end
36
+
37
+ # Raised when no server in the pool could be reached.
38
+ class NoServersError < ConnectionError; end
39
+
40
+ # An `-ERR` line received from the server.
41
+ #
42
+ # Every entry below is taken from the nats-server source (v2.14) rather than
43
+ # from documentation. The server writes `-ERR '<description>'` from exactly
44
+ # three places (`sendErr`, and two raw writes for a stale connection), and its
45
+ # casing is inconsistent — `Authorization Violation` is title case while
46
+ # `maximum control line exceeded` is not — so lookup is case insensitive.
47
+ # Several descriptions embed context (`Permissions Violation for Publish to
48
+ # "orders.new"`), so lookup is by substring, ordered specific first.
49
+ class ServerError < Error
50
+ MAPPING = {
51
+ "permissions violation" => :PermissionsViolationError,
52
+ "authorization violation" => :AuthorizationError,
53
+ "authentication expired" => :AuthenticationExpiredError,
54
+ "authentication revoked" => :AuthenticationRevokedError,
55
+ "authentication timeout" => :AuthenticationTimeoutError,
56
+ "stale connection" => :StaleConnectionError,
57
+ "maximum account active connections exceeded" => :MaxAccountConnectionsError,
58
+ "maximum connections exceeded" => :MaxConnectionsError,
59
+ "maximum subscriptions exceeded" => :MaxSubscriptionsError,
60
+ "maximum payload violation" => :MaxPayloadViolationError,
61
+ "maximum control line exceeded" => :MaxControlLineError,
62
+ "invalid publish subject" => :InvalidPublishSubjectError,
63
+ "invalid subject" => :ServerInvalidSubjectError,
64
+ "failed account registration" => :FailedAccountRegistrationError,
65
+ "no responders requires headers support" => :NoRespondersRequiresHeadersError,
66
+ "connection throttling is active" => :ConnectionThrottledError,
67
+ "secure connection - tls required" => :TLSRequiredError,
68
+ "invalid client protocol" => :InvalidClientProtocolError,
69
+ "unknown protocol operation" => :UnknownProtocolError,
70
+ "attempted to connect to route port" => :WrongPortError,
71
+ "attempted to connect to leaf node port" => :WrongPortError,
72
+ "attempted to connect to gateway port" => :WrongPortError
73
+ }.freeze
74
+
75
+ # Builds the most specific {ServerError} subclass for an `-ERR` description.
76
+ #
77
+ # @param description [String] the text between the quotes of an `-ERR` line
78
+ # @return [ServerError]
79
+ def self.for(description)
80
+ normalized = description.to_s.downcase
81
+ name = MAPPING[normalized] || MAPPING.find { |text, _| normalized.include?(text) }&.last
82
+ klass = name ? Tether.const_get(name) : self
83
+ klass.new(description)
84
+ end
85
+
86
+ # Whether the server is expected to close the connection after this error.
87
+ #
88
+ # Advisory only, and never a substitute for the connection state itself.
89
+ # The server sends `maximum subscriptions exceeded` from two paths that are
90
+ # indistinguishable on the wire: one leaves the connection up, the other
91
+ # closes it 20ms later. Reconnect must therefore be driven by the socket
92
+ # reaching EOF; this predicate only says whether to expect that.
93
+ #
94
+ # @return [Boolean]
95
+ def self.fatal?
96
+ true
97
+ end
98
+
99
+ # @return [Boolean] whether the server is expected to close the connection
100
+ # after this error; see {ServerError.fatal?} for why it is advisory
101
+ def fatal?
102
+ self.class.fatal?
103
+ end
104
+ end
105
+
106
+ # Raised when the account presented at connect time is not authorized.
107
+ class AuthorizationError < ServerError; end
108
+
109
+ # Raised when the credentials presented at connect time have expired.
110
+ # Covers both the user scoped and account scoped descriptions.
111
+ class AuthenticationExpiredError < ServerError; end
112
+
113
+ # Raised when the credentials presented at connect time have been revoked.
114
+ # Distinct from expiry: retrying with the same credentials cannot succeed.
115
+ class AuthenticationRevokedError < ServerError; end
116
+
117
+ # Raised when the server times out waiting for authentication.
118
+ class AuthenticationTimeoutError < ServerError; end
119
+
120
+ # Raised when the server drops a connection that stopped answering PING.
121
+ class StaleConnectionError < ServerError; end
122
+
123
+ # Raised when the server's global connection limit is reached.
124
+ class MaxConnectionsError < ServerError; end
125
+
126
+ # Raised when the account's active connection limit is reached.
127
+ class MaxAccountConnectionsError < ServerError; end
128
+
129
+ # Raised when the server rejects a payload larger than max_payload.
130
+ class MaxPayloadViolationError < ServerError; end
131
+
132
+ # Raised when a control line exceeds the server's limit.
133
+ class MaxControlLineError < ServerError; end
134
+
135
+ # Raised when CONNECT asks for no_responders without headers support.
136
+ class NoRespondersRequiresHeadersError < ServerError; end
137
+
138
+ # Raised when the server is shedding load and the client should back off.
139
+ class ConnectionThrottledError < ServerError; end
140
+
141
+ # Raised when the server requires TLS and the client did not upgrade.
142
+ class TLSRequiredError < ServerError; end
143
+
144
+ # Raised when the CONNECT payload is rejected by the server.
145
+ class InvalidClientProtocolError < ServerError; end
146
+
147
+ # Raised when the server cannot parse a command the client sent.
148
+ class UnknownProtocolError < ServerError; end
149
+
150
+ # Raised when the client connected to a route, leafnode, or gateway port
151
+ # instead of the client port. Reconnecting cannot help.
152
+ class WrongPortError < ServerError; end
153
+
154
+ # Raised when a subscription names a subject the server rejects.
155
+ # The server does not close the connection for this error: its parser
156
+ # deliberately swallows the error so a bad subject is not a parse failure.
157
+ class ServerInvalidSubjectError < ServerError
158
+ # @return [Boolean] false: the server's parser deliberately swallows this so a bad
159
+ # subject is not treated as a parse failure
160
+ def self.fatal?
161
+ false
162
+ end
163
+ end
164
+
165
+ # Raised when a publish names a malformed subject, in pedantic mode.
166
+ # The server does not close the connection for this error.
167
+ class InvalidPublishSubjectError < ServerError
168
+ # @return [Boolean] false: the server returns without closing
169
+ def self.fatal?
170
+ false
171
+ end
172
+ end
173
+
174
+ # Raised when the server fails to register the connection's account.
175
+ # The server does not close the connection for this error.
176
+ class FailedAccountRegistrationError < ServerError
177
+ # @return [Boolean] false: the server reports it and carries on
178
+ def self.fatal?
179
+ false
180
+ end
181
+ end
182
+
183
+ # Raised when the subscription limit is reached.
184
+ #
185
+ # Reported as non fatal because the ordinary over-limit SUB leaves the
186
+ # connection usable, but the account limit path sends the same text and then
187
+ # closes. See {ServerError.fatal?}.
188
+ class MaxSubscriptionsError < ServerError
189
+ # @return [Boolean] false for the ordinary over-limit SUB, but see {ServerError.fatal?}:
190
+ # the account limit path sends the same text and then closes
191
+ def self.fatal?
192
+ false
193
+ end
194
+ end
195
+
196
+ # Raised when publishing to or subscribing to a subject the user cannot access.
197
+ # The server does not close the connection for any of its seven variants.
198
+ class PermissionsViolationError < ServerError
199
+ # @return [Boolean] false for all seven variants the server can send
200
+ def self.fatal?
201
+ false
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tether
4
+ # NATS message headers, plus the optional status and description that the
5
+ # server encodes on the version line (`NATS/1.0 503`).
6
+ #
7
+ # Keys are case sensitive and used exactly as given: JetStream relies on
8
+ # literal names such as `Nats-Msg-Id` and `Nats-Expected-Last-Sequence`.
9
+ # A key may carry several values, as in Go's `nats.Header`.
10
+ class Headers
11
+ VERSION = "NATS/1.0"
12
+ CRLF = "\r\n"
13
+
14
+ # Status codes the server puts on the version line. Taken from the
15
+ # nats-server source rather than from HTTP: the numbers overlap with HTTP
16
+ # but the meanings are the server's own.
17
+ CONTROL = 100
18
+ NO_CONTENT = 204
19
+ BAD_REQUEST = 400
20
+ NOT_FOUND = 404
21
+ REQUEST_TIMEOUT = 408
22
+ CONFLICT = 409
23
+ REQUIRED_API_LEVEL = 412
24
+ TOO_MANY_RESULTS = 413
25
+ PIN_ID_MISMATCH = 423
26
+ TOO_MANY_REQUESTS = 429
27
+ INTERNAL_ERROR = 500
28
+ NO_RESPONDERS = 503
29
+
30
+ # A header name is an RFC 7230 token. A value may not contain CR or LF:
31
+ # the server does not validate the block it forwards, so an unescaped CRLF
32
+ # in a value forges a header line that its own lookup will honor.
33
+ TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
34
+ FORBIDDEN_IN_VALUE = /[\r\n]/
35
+ private_constant :CRLF, :TOKEN, :FORBIDDEN_IN_VALUE
36
+
37
+ # @return [Integer, nil] status code from the version line
38
+ attr_reader :status
39
+
40
+ # @return [String, nil] status description from the version line
41
+ attr_reader :description
42
+
43
+ # Parses a header block as it appears on the wire, including the trailing
44
+ # blank line.
45
+ #
46
+ # @param bytes [String] the header section of an HMSG or HPUB frame
47
+ # @return [Headers]
48
+ # @raise [ProtocolError] if the version line is missing or malformed
49
+ def self.parse(bytes)
50
+ lines = bytes.to_s.split(CRLF, -1)
51
+ version = lines.shift.to_s
52
+ raise ProtocolError, "invalid header version line: #{version.inspect}" unless version.start_with?(VERSION)
53
+
54
+ status, description = parse_version_line(version)
55
+ headers = new(status: status, description: description)
56
+ lines.each do |line|
57
+ next if line.empty?
58
+
59
+ key, _, value = line.partition(":")
60
+ raise ProtocolError, "invalid header line: #{line.inspect}" if value.empty? && !line.include?(":")
61
+
62
+ headers.add(key.strip, value.strip)
63
+ end
64
+ headers
65
+ end
66
+
67
+ # @return [Array(Integer, nil), Array(nil, nil)]
68
+ def self.parse_version_line(version)
69
+ remainder = version[VERSION.length..].to_s.strip
70
+ return [nil, nil] if remainder.empty?
71
+
72
+ code, _, text = remainder.partition(" ")
73
+ [Integer(code, exception: false), text.strip.empty? ? nil : text.strip]
74
+ end
75
+ private_class_method :parse_version_line
76
+
77
+ # Builds headers from a hash, so the common case needs no braces.
78
+ #
79
+ # @param entries [Hash{String => String, Array<String>}]
80
+ # @return [Headers]
81
+ def self.[](entries)
82
+ new(entries: entries)
83
+ end
84
+
85
+ # Builds a header set. Prefer {.[]} for the common case of a plain hash.
86
+ #
87
+ # @param entries [Hash{String => String, Array<String>}] initial headers
88
+ # @param status [Integer, nil]
89
+ # @param description [String, nil]
90
+ # @raise [ArgumentError] if a key is not a token or a value spans lines
91
+ def initialize(entries: {}, status: nil, description: nil)
92
+ @entries = {}
93
+ @status = status
94
+ @description = description && validate_value(description)
95
+ entries.each { |key, value| Array(value).each { |item| add(key.to_s, item.to_s) } }
96
+ end
97
+
98
+ # @param key [String]
99
+ # @return [String, nil] the first value for the key
100
+ def [](key)
101
+ @entries[key]&.first
102
+ end
103
+
104
+ # Replaces every value for a key.
105
+ #
106
+ # @param key [String]
107
+ # @param value [String]
108
+ # @return [String]
109
+ # @raise [ArgumentError] if the key is not a token or the value spans lines
110
+ def []=(key, value)
111
+ @entries[validate_key(key)] = [validate_value(value)]
112
+ value
113
+ end
114
+
115
+ # Appends a value, keeping any already present for the key.
116
+ #
117
+ # Repeated keys are legal on the wire, but every server side lookup stops at
118
+ # the first match, so a second value for a `Nats-` header is silently
119
+ # ignored by the server rather than merged.
120
+ #
121
+ # @param key [String]
122
+ # @param value [String]
123
+ # @return [self]
124
+ # @raise [ArgumentError] if the key is not a token or the value spans lines
125
+ def add(key, value)
126
+ (@entries[validate_key(key)] ||= []) << validate_value(value)
127
+ self
128
+ end
129
+
130
+ # Every value for a key. Note the server reads only the first: repeated
131
+ # keys are legal on the wire but its lookups stop at the first match.
132
+ #
133
+ # @param key [String]
134
+ # @return [Array<String>]
135
+ def values(key)
136
+ @entries.fetch(key, []).dup
137
+ end
138
+
139
+ # Removes a key and all of its values.
140
+ #
141
+ # @param key [String]
142
+ # @return [Array<String>, nil] the removed values
143
+ def delete(key)
144
+ @entries.delete(key)
145
+ end
146
+
147
+ # Matched exactly, including case.
148
+ #
149
+ # @param key [String]
150
+ # @return [Boolean]
151
+ def key?(key)
152
+ @entries.key?(key)
153
+ end
154
+
155
+ # Yields each key once per value, so a repeated key is yielded repeatedly,
156
+ # which is how it is written to the wire.
157
+ #
158
+ # @yieldparam key [String]
159
+ # @yieldparam value [String]
160
+ # @return [Enumerator, self]
161
+ def each(&block)
162
+ return to_enum(:each) unless block
163
+
164
+ @entries.each { |key, values| values.each { |value| block.call(key, value) } }
165
+ self
166
+ end
167
+
168
+ # @return [Array<String>] each key once, however many values it carries
169
+ def keys
170
+ @entries.keys
171
+ end
172
+
173
+ # A frame can carry a status with no headers at all, so this is not the
174
+ # same as having no header block.
175
+ #
176
+ # @return [Boolean] whether there are no headers, status, or description
177
+ def empty?
178
+ @entries.empty? && @status.nil? && @description.nil?
179
+ end
180
+
181
+ # Sent by the server only when the client asked for it in CONNECT.
182
+ #
183
+ # @return [Boolean] whether the server reported that nobody is listening
184
+ def no_responders?
185
+ @status == NO_RESPONDERS
186
+ end
187
+
188
+ # Whether this is a JetStream control frame of either kind.
189
+ #
190
+ # @return [Boolean]
191
+ def control?
192
+ @status == CONTROL
193
+ end
194
+
195
+ # An idle heartbeat carries no reply subject and must not be answered,
196
+ # unless it carries `Nats-Consumer-Stalled`, whose value is a subject to
197
+ # publish to. Distinguished from flow control by description, not by code,
198
+ # because both are status 100.
199
+ #
200
+ # @return [Boolean]
201
+ def idle_heartbeat?
202
+ control? && @description.to_s.start_with?("Idle")
203
+ end
204
+
205
+ # A flow control request carries a reply subject and the consumer stalls
206
+ # until the client publishes to it.
207
+ #
208
+ # @return [Boolean]
209
+ def flow_control?
210
+ control? && @description.to_s.start_with?("Flow")
211
+ end
212
+
213
+ # @return [Hash{String => Array<String>}] a copy; mutating it does not affect these headers
214
+ def to_h
215
+ @entries.transform_values(&:dup)
216
+ end
217
+
218
+ # Encodes the header block for the wire, including the trailing blank line.
219
+ #
220
+ # @return [String] binary encoded header section
221
+ def to_wire
222
+ out = "#{VERSION}#{status_suffix}#{CRLF}"
223
+ each { |key, value| out << key << ": " << value << CRLF }
224
+ out << CRLF
225
+ out.force_encoding(Encoding::BINARY)
226
+ end
227
+
228
+ # @return [String] shows the status alongside the headers, since a status
229
+ # only frame has no entries at all
230
+ def inspect
231
+ "#<Tether::Headers status=#{@status.inspect} #{to_h.inspect}>"
232
+ end
233
+
234
+ private
235
+
236
+ def validate_key(key)
237
+ key = key.to_s
238
+ raise ArgumentError, "invalid header name: #{key.inspect}" unless key.match?(TOKEN)
239
+
240
+ key
241
+ end
242
+
243
+ def validate_value(value)
244
+ value = value.to_s
245
+ raise ArgumentError, "header value may not span lines: #{value.inspect}" if value.match?(FORBIDDEN_IN_VALUE)
246
+
247
+ value
248
+ end
249
+
250
+ def status_suffix
251
+ return "" if @status.nil?
252
+
253
+ @description ? " #{@status} #{@description}" : " #{@status}"
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tether
4
+ # Request/reply over a single wildcard inbox subscription.
5
+ #
6
+ # A new SUB/UNSUB pair per request would make every request a round trip to
7
+ # the server before the payload even goes out. Instead one subscription on
8
+ # `_INBOX.<nuid>.*` covers the whole connection and replies are demultiplexed
9
+ # in memory by the last subject token.
10
+ class InboxMux
11
+ INBOX_PREFIX = "_INBOX"
12
+
13
+ # @return [String] the subject prefix this connection's replies arrive on
14
+ attr_reader :prefix
15
+
16
+ # Builds the mux without subscribing; {#start} runs on the first request.
17
+ #
18
+ # @param client [Client]
19
+ # @param prefix [String, nil] defaults to a fresh `_INBOX.<nuid>`
20
+ # @param max_pending [Integer] queue depth for the shared inbox subscription
21
+ def initialize(client, prefix: nil, max_pending: Subscription::DEFAULT_MAX_PENDING)
22
+ @client = client
23
+ @prefix = prefix || "#{INBOX_PREFIX}.#{NUID.next}"
24
+ @max_pending = max_pending
25
+ @pending = {}
26
+ @lock = Mutex.new
27
+ @counter = 0
28
+ end
29
+
30
+ # Publishes a request and waits for the first reply.
31
+ #
32
+ # @param subject [String]
33
+ # @param payload [String, nil]
34
+ # @param deadline [Deadline]
35
+ # @param headers [Headers, nil]
36
+ # @return [Msg]
37
+ # @raise [NoRespondersError] if the server reports nobody is subscribed
38
+ # @raise [TimeoutError] if the deadline passes first
39
+ def request(subject, payload = nil, deadline: Deadline.infinite, headers: nil)
40
+ start
41
+ token = next_token
42
+ waiter = Thread::Queue.new
43
+ @lock.synchronize { @pending[token] = waiter }
44
+
45
+ @client.publish(subject, payload, reply_to: "#{@prefix}.#{token}", headers: headers)
46
+ await(waiter, subject, deadline)
47
+ ensure
48
+ @lock.synchronize { @pending.delete(token) } if token
49
+ end
50
+
51
+ # Subscribes the shared inbox, once.
52
+ #
53
+ # @return [Subscription]
54
+ def start
55
+ @lock.synchronize do
56
+ return @subscription if @subscription
57
+
58
+ @subscription = @client.subscribe("#{@prefix}.*", max_pending: @max_pending) { |msg| resolve(msg) }
59
+ end
60
+ end
61
+
62
+ # The inbox is subscribed lazily, on the first request, so a connection
63
+ # that never makes one costs nothing.
64
+ #
65
+ # @return [Boolean] whether the shared inbox subscription exists yet
66
+ def started?
67
+ @lock.synchronize { !@subscription.nil? }
68
+ end
69
+
70
+ # Should return to zero once requests settle; a number that only grows is
71
+ # a leak.
72
+ #
73
+ # @return [Integer] requests currently waiting for a reply
74
+ def pending
75
+ @lock.synchronize { @pending.size }
76
+ end
77
+
78
+ # Releases every waiting request, so callers blocked in {#request} raise
79
+ # rather than waiting out their deadlines.
80
+ #
81
+ # @return [void]
82
+ def close
83
+ waiters = @lock.synchronize { @pending.values.tap { @pending.clear } }
84
+ waiters.each(&:close)
85
+ nil
86
+ end
87
+
88
+ private
89
+
90
+ def await(waiter, subject, deadline)
91
+ msg = waiter.pop(timeout: deadline.remaining)
92
+ raise ConnectionClosedError, "connection closed while waiting on #{subject}" if msg.nil? && waiter.closed?
93
+ raise TimeoutError, "no reply to #{subject} within the deadline" if msg.nil?
94
+ raise NoRespondersError, "no responders for #{subject}" if msg.no_responders?
95
+
96
+ msg
97
+ end
98
+
99
+ # Runs on the inbox subscription's worker. A reply whose token has already
100
+ # timed out finds no waiter and is dropped rather than raising.
101
+ def resolve(msg)
102
+ token = msg.subject[(@prefix.length + 1)..]
103
+ waiter = @lock.synchronize { @pending[token] }
104
+ waiter&.push(msg)
105
+ rescue ClosedQueueError
106
+ nil
107
+ end
108
+
109
+ def next_token
110
+ @lock.synchronize { (@counter += 1).to_s(36) }
111
+ end
112
+ end
113
+ end
data/lib/tether/msg.rb ADDED
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tether
4
+ # A message delivered to a subscription or returned from a request.
5
+ #
6
+ # Headers are parsed on first use, on whichever thread touches them, so the
7
+ # reader thread never pays for a header block it may not need.
8
+ class Msg
9
+ # @return [String]
10
+ attr_reader :subject
11
+
12
+ # @return [String] the payload, always binary
13
+ attr_reader :data
14
+
15
+ # @return [String, nil] the subject a reply should be published to
16
+ attr_reader :reply_to
17
+
18
+ # @return [Subscription, nil]
19
+ attr_reader :subscription
20
+
21
+ # Built by the dispatcher on the reader thread, so it does no work beyond
22
+ # holding references.
23
+ #
24
+ # @param subject [String]
25
+ # @param data [String]
26
+ # @param reply_to [String, nil]
27
+ # @param header_bytes [String, nil] the unparsed header block
28
+ # @param subscription [Subscription, nil]
29
+ def initialize(subject:, data:, reply_to: nil, header_bytes: nil, subscription: nil)
30
+ @subject = subject
31
+ @data = data
32
+ @reply_to = reply_to
33
+ @header_bytes = header_bytes
34
+ @subscription = subscription
35
+ end
36
+
37
+ # Parses the header block on first use, on whichever thread asks for it.
38
+ #
39
+ # @return [Headers, nil] nil when the message arrived without a header block
40
+ # @raise [ProtocolError] if the block is malformed
41
+ def headers
42
+ return @headers if defined?(@headers)
43
+
44
+ @headers = @header_bytes && Headers.parse(@header_bytes)
45
+ end
46
+
47
+ # JetStream and no-responders both report through this rather than `-ERR`.
48
+ #
49
+ # @return [Integer, nil] the status the server put on the header line
50
+ def status
51
+ headers&.status
52
+ end
53
+
54
+ # True on the empty 503 the server sends back when a request reaches a
55
+ # subject with no subscribers.
56
+ #
57
+ # @return [Boolean] whether the server reported that nobody is listening
58
+ def no_responders?
59
+ headers&.no_responders? || false
60
+ end
61
+
62
+ # Publishes back to this message's reply subject.
63
+ #
64
+ # @param payload [String, nil]
65
+ # @param headers [Headers, nil]
66
+ # @return [void]
67
+ # @raise [Error] if the message carries no reply subject
68
+ def respond(payload = nil, headers: nil)
69
+ raise Error, "message on #{@subject} has no reply subject" unless @reply_to
70
+
71
+ responder = @subscription&.responder
72
+ raise Error, "message on #{@subject} is not attached to a connection" unless responder
73
+
74
+ responder.publish(@reply_to, payload, headers: headers)
75
+ end
76
+
77
+ # Status only frames, such as a heartbeat or a 503, carry no payload.
78
+ #
79
+ # @return [Boolean] whether this carries no payload
80
+ def empty?
81
+ @data.nil? || @data.empty?
82
+ end
83
+
84
+ # @return [String] reports the payload's size rather than its bytes, which
85
+ # may be binary and large
86
+ def inspect
87
+ "#<Tether::Msg subject=#{@subject.inspect} reply_to=#{@reply_to.inspect} bytes=#{@data.bytesize}>"
88
+ end
89
+ end
90
+ end