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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +5 -0
- data/Rakefile +17 -0
- data/lib/tether/client.rb +298 -0
- data/lib/tether/deadline.rb +127 -0
- data/lib/tether/dispatcher.rb +109 -0
- data/lib/tether/errors.rb +204 -0
- data/lib/tether/headers.rb +256 -0
- data/lib/tether/inbox_mux.rb +113 -0
- data/lib/tether/msg.rb +90 -0
- data/lib/tether/nuid.rb +68 -0
- data/lib/tether/protocol/encoder.rb +125 -0
- data/lib/tether/protocol/parser.rb +336 -0
- data/lib/tether/protocol.rb +18 -0
- data/lib/tether/subscription.rb +123 -0
- data/lib/tether/transport.rb +114 -0
- data/lib/tether/version.rb +5 -0
- data/lib/tether.rb +28 -0
- data/sig/tether/client.rbs +45 -0
- data/sig/tether/deadline.rbs +21 -0
- data/sig/tether/dispatcher.rbs +19 -0
- data/sig/tether/errors.rbs +110 -0
- data/sig/tether/headers.rbs +50 -0
- data/sig/tether/inbox_mux.rbs +20 -0
- data/sig/tether/msg.rbs +16 -0
- data/sig/tether/nuid.rbs +21 -0
- data/sig/tether/protocol/encoder.rbs +13 -0
- data/sig/tether/protocol/parser.rbs +43 -0
- data/sig/tether/protocol.rbs +22 -0
- data/sig/tether/subscription.rbs +24 -0
- data/sig/tether/transport.rbs +22 -0
- data/sig/tether/version.rbs +3 -0
- metadata +77 -0
|
@@ -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
|