cent 4.0.0 → 5.0.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 +4 -4
- data/CHANGELOG.md +93 -0
- data/MIGRATING.md +305 -0
- data/README.md +702 -155
- data/lib/cent/auto_batch.rb +305 -0
- data/lib/cent/batch.rb +209 -0
- data/lib/cent/broadcast.rb +117 -0
- data/lib/cent/client.rb +602 -216
- data/lib/cent/command.rb +64 -0
- data/lib/cent/endpoints.rb +191 -0
- data/lib/cent/errors.rb +392 -0
- data/lib/cent/generated/methods.rb +4226 -0
- data/lib/cent/generated/proxy_types.rb +1834 -0
- data/lib/cent/generated/types.rb +2844 -0
- data/lib/cent/handler.rb +179 -0
- data/lib/cent/http.rb +251 -0
- data/lib/cent/message.rb +52 -0
- data/lib/cent/pages.rb +165 -0
- data/lib/cent/payload.rb +93 -0
- data/lib/cent/proxy.rb +254 -0
- data/lib/cent/reply.rb +109 -0
- data/lib/cent/retry.rb +53 -0
- data/lib/cent/shared_poll.rb +61 -0
- data/lib/cent/token.rb +560 -0
- data/lib/cent/version.rb +2 -1
- data/lib/cent/wire.rb +222 -0
- data/lib/cent.rb +33 -5
- metadata +34 -44
- data/Gemfile +0 -15
- data/Rakefile +0 -12
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/cent.gemspec +0 -36
- data/lib/cent/error.rb +0 -46
- data/lib/cent/notary.rb +0 -105
data/lib/cent/client.rb
CHANGED
|
@@ -1,260 +1,646 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require 'cent/error'
|
|
3
|
+
require "securerandom"
|
|
5
4
|
|
|
6
5
|
module Cent
|
|
7
|
-
#
|
|
6
|
+
# One HTTP request to Centrifugo's API, for on_request.
|
|
8
7
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
8
|
+
# @!attribute [r] method
|
|
9
|
+
# @return [String] the API method: "publish", say, or "batch" for a batch
|
|
10
|
+
# @!attribute [r] commands
|
|
11
|
+
# @return [Integer] how many commands the request carried: 1, or a batch's
|
|
12
|
+
# size
|
|
13
|
+
# @!attribute [r] failed
|
|
14
|
+
# @return [Integer] how many of them Centrifugo answered with an error. A
|
|
15
|
+
# broadcast which failed in some channels is not counted: its command was
|
|
16
|
+
# answered.
|
|
17
|
+
# @!attribute [r] auto_batch
|
|
18
|
+
# @return [Boolean] true for a batch made of concurrent calls by automatic
|
|
19
|
+
# batching
|
|
20
|
+
# @!attribute [r] duration
|
|
21
|
+
# @return [Float] seconds from before getting the endpoint and credentials
|
|
22
|
+
# to the reply read
|
|
23
|
+
# @!attribute [r] status_code
|
|
24
|
+
# @return [Integer] the HTTP status of the reply, 0 without one
|
|
25
|
+
# @!attribute [r] error
|
|
26
|
+
# @return [Error, nil] the request's own error: the network, a timeout, an
|
|
27
|
+
# HTTP status, a reply which is not Centrifugo's, a request refused before
|
|
28
|
+
# sending - or, in the transport error mode, the error of its one command.
|
|
29
|
+
# nil when Centrifugo's reply was read.
|
|
30
|
+
class RequestInfo < Data.define(:method, :commands, :failed, :auto_batch, :duration, :status_code, :error)
|
|
31
|
+
include Message
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Calls Centrifugo's HTTP server API. Safe for concurrent use from threads and
|
|
35
|
+
# fibers, and meant to be created once and shared:
|
|
22
36
|
#
|
|
23
|
-
#
|
|
24
|
-
# client
|
|
25
|
-
# client.publish(channel: 'chat', data: { text: 'hi' })
|
|
26
|
-
# # => {"result" => {}}
|
|
37
|
+
# client = Cent::Client.new(api_endpoint: "http://localhost:8000/api", api_key: ENV.fetch("CENTRIFUGO_API_KEY"))
|
|
38
|
+
# client.publish(channel: "news", data: { text: "hello" })
|
|
27
39
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
40
|
+
# There is a method per API method, taking the fields of its request as
|
|
41
|
+
# keyword arguments. Each returns its result, raising unless Centrifugo did
|
|
42
|
+
# everything asked, and takes +timeout:+, in seconds, which replaces
|
|
43
|
+
# request_timeout for the call, longer or shorter. Ruby has no deadline a
|
|
44
|
+
# call runs under for cent to honour: a Timeout.timeout around a call only
|
|
45
|
+
# cuts it short. Under a fiber scheduler - Async, Falcon -
|
|
46
|
+
# calls wait without blocking other fibers.
|
|
34
47
|
class Client
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
# @
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
include ClientMethods
|
|
49
|
+
include Pages
|
|
50
|
+
|
|
51
|
+
# The User-Agent of every request, unless headers set one.
|
|
52
|
+
USER_AGENT = "rubycent/#{VERSION.split(".").first}".freeze
|
|
53
|
+
# The seconds bounding a call without a timeout of its own, by default.
|
|
54
|
+
DEFAULT_REQUEST_TIMEOUT = 10.0
|
|
55
|
+
|
|
56
|
+
# @param api_endpoint [String] the base URL of the API,
|
|
57
|
+
# "http://localhost:8000/api". Each method is sent to it plus its name,
|
|
58
|
+
# "/publish".
|
|
59
|
+
# @param api_endpoint_func [#call, nil] instead of api_endpoint, returns it
|
|
60
|
+
# for each request, for addresses discovered rather than fixed. One
|
|
61
|
+
# failing api_endpoint's rules fails the call with InvalidConfigError,
|
|
62
|
+
# before sending; an exception of its own, with FunctionError.
|
|
63
|
+
# @param api_key [String, nil] sent as X-API-Key. Leave it and
|
|
64
|
+
# bearer_token_func unset - nil, or "" - when requests are authenticated
|
|
65
|
+
# another way - mutual TLS, a proxy - or not at all.
|
|
66
|
+
# @param bearer_token_func [#call, nil] returns the token sent as
|
|
67
|
+
# "Authorization: Bearer" with each request, for Centrifugo PRO's JWKS
|
|
68
|
+
# authentication of the API - an {Token::ApiTokenSource}'s +method(:token)+,
|
|
69
|
+
# say. Not together with api_key. An empty token, or one which is not
|
|
70
|
+
# printable ASCII, fails the call with InvalidConfigError, before
|
|
71
|
+
# sending; an exception of its own, with FunctionError.
|
|
72
|
+
# @param http_options [Hash] Net::HTTP settings for the connections:
|
|
73
|
+
# +ca_file+, +cert+, +key+, +verify_mode+, +local_host+ and the like.
|
|
74
|
+
# @param http_client [#call, nil] sends requests instead of Net::HTTP - a
|
|
75
|
+
# fake in tests, another HTTP library: called with the URL, the headers,
|
|
76
|
+
# the body and the seconds left, it returns +[status, headers, body]+, and
|
|
77
|
+
# raises Cent::TimeoutError when the time is up. Without one, the client
|
|
78
|
+
# keeps up to 256 connections to a host alive, never follows redirects,
|
|
79
|
+
# and closes idle connections after 50 seconds.
|
|
80
|
+
# @param request_timeout [Numeric, nil] seconds bounding every call, 10
|
|
81
|
+
# when nil: a call's +timeout:+ replaces it, longer or shorter. It cannot
|
|
82
|
+
# be turned off: zero is refused, as a negative is; for long calls, set a
|
|
83
|
+
# large value.
|
|
84
|
+
# @param headers [Hash{String => String}] extra headers for every request,
|
|
85
|
+
# for a proxy in front of Centrifugo. Not X-API-Key, Authorization or
|
|
86
|
+
# Content-Type. A User-Agent replaces cent's.
|
|
87
|
+
# @param on_request [#call, nil] called after every HTTP request with a
|
|
88
|
+
# {RequestInfo}, for metrics; the call waits for it. It must not raise:
|
|
89
|
+
# what it raises goes to the caller in place of the call's outcome
|
|
90
|
+
# @param auto_batch [AutoBatch, nil] sends calls made concurrently in
|
|
91
|
+
# batches: see {AutoBatch}
|
|
92
|
+
# @param batch_context [#call, nil] with auto_batch, called with the fiber
|
|
93
|
+
# storages of a batch's callers and a block sending the batch - for a
|
|
94
|
+
# tracing span linking every caller's. Without it, a batch runs with an
|
|
95
|
+
# empty fiber storage, carrying no caller's values.
|
|
96
|
+
# @raise [InvalidConfigError] naming the option and the fix
|
|
97
|
+
def initialize(api_endpoint: nil, api_endpoint_func: nil, api_key: "", bearer_token_func: nil,
|
|
98
|
+
http_options: {}, http_client: nil, request_timeout: DEFAULT_REQUEST_TIMEOUT,
|
|
99
|
+
headers: {}, on_request: nil, auto_batch: nil, batch_context: nil)
|
|
100
|
+
if api_endpoint.nil? && api_endpoint_func.nil?
|
|
101
|
+
raise InvalidConfigError, "api_endpoint is required, for example http://localhost:8000/api"
|
|
102
|
+
end
|
|
103
|
+
raise InvalidConfigError, "set api_endpoint or api_endpoint_func, not both" if api_endpoint && api_endpoint_func
|
|
104
|
+
|
|
105
|
+
@endpoint = api_endpoint && Client.check_endpoint(api_endpoint, "api_endpoint")
|
|
106
|
+
@endpoint_func = callable(api_endpoint_func, "api_endpoint_func")
|
|
107
|
+
|
|
108
|
+
api_key = "" if api_key.nil? # no key, as when credentials are absent
|
|
109
|
+
raise InvalidConfigError, "api_key must be a String, not #{api_key.class}" unless api_key.is_a?(String)
|
|
110
|
+
unless api_key.empty? || bearer_token_func.nil?
|
|
111
|
+
raise InvalidConfigError,
|
|
112
|
+
"set api_key or bearer_token_func, not both: a request with a bearer token is judged by the token alone"
|
|
113
|
+
end
|
|
114
|
+
unless Client.header_value?(api_key)
|
|
115
|
+
raise InvalidConfigError, "api_key must be printable ASCII: no line breaks, control or non-ASCII characters"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@token_func = callable(bearer_token_func, "bearer_token_func")
|
|
119
|
+
@headers = check_headers(headers, api_key)
|
|
120
|
+
|
|
121
|
+
# nil is unset: the default. Zero is refused, as a negative is - a call
|
|
122
|
+
# is never unbounded.
|
|
123
|
+
request_timeout = DEFAULT_REQUEST_TIMEOUT if request_timeout.nil?
|
|
124
|
+
unless request_timeout.is_a?(Numeric) && request_timeout.positive? && request_timeout.finite?
|
|
125
|
+
raise InvalidConfigError, "request_timeout must be a positive number of seconds; nil for the default"
|
|
59
126
|
end
|
|
127
|
+
|
|
128
|
+
@request_timeout = request_timeout.to_f
|
|
129
|
+
@on_request = callable(on_request, "on_request")
|
|
130
|
+
setup_transport(http_client, http_options)
|
|
131
|
+
@calls = Calls.new
|
|
132
|
+
@auto_batch = [auto_batch, batch_context]
|
|
133
|
+
@batcher = batcher(auto_batch, batch_context)
|
|
134
|
+
@pid = ::Process.pid
|
|
135
|
+
@fork_lock = Mutex.new
|
|
60
136
|
end
|
|
61
137
|
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
138
|
+
# @return [Float] the seconds bounding a call without a timeout of its own
|
|
139
|
+
attr_reader :request_timeout
|
|
140
|
+
|
|
141
|
+
# Without the API key, headers or anything else secret.
|
|
142
|
+
#
|
|
143
|
+
# @return [String]
|
|
144
|
+
def inspect = "#<#{self.class} #{@endpoint || "api_endpoint_func"}>"
|
|
145
|
+
|
|
146
|
+
# An empty {Batch} sent by this client. Without +parallel+ the commands run
|
|
147
|
+
# one by one, in the order added - but Centrifugo PRO sends a sequential
|
|
148
|
+
# batch's publications to its broker together: each channel's take effect
|
|
149
|
+
# in order, publications into different channels may not, unless the
|
|
150
|
+
# namespace sets publication_grouping_disabled. With +parallel+, Centrifugo
|
|
151
|
+
# runs them concurrently, with no order.
|
|
152
|
+
#
|
|
153
|
+
# @param parallel [Boolean]
|
|
154
|
+
# @return [Batch]
|
|
155
|
+
def new_batch(parallel: false) = Batch.new(self, parallel)
|
|
156
|
+
|
|
157
|
+
# Stops the client: calls made from now on raise ClosedError, and close
|
|
158
|
+
# waits for the calls in progress to end. A broadcast being retried stops at
|
|
159
|
+
# its next attempt. It raises Cent::TimeoutError when +timeout+, in seconds,
|
|
160
|
+
# passes first; the calls left go on then, and close may be called again.
|
|
161
|
+
# It then closes its idle connections.
|
|
162
|
+
#
|
|
163
|
+
# Call it when the application shuts down - +at_exit+, Puma's
|
|
164
|
+
# +on_worker_shutdown+, Sidekiq's +on(:shutdown)+ - so that what was
|
|
165
|
+
# accepted is sent before the process exits.
|
|
166
|
+
#
|
|
167
|
+
# @param timeout [Numeric, nil]
|
|
168
|
+
# @return [void]
|
|
169
|
+
# @raise [TimeoutError]
|
|
170
|
+
def close(timeout: nil)
|
|
171
|
+
raise TimeoutError, "close: calls still in progress" unless @calls.close(timeout)
|
|
172
|
+
|
|
173
|
+
@batcher&.close
|
|
174
|
+
@pool&.close
|
|
77
175
|
end
|
|
78
176
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
'skip_history' => skip_history,
|
|
87
|
-
'tags' => tags,
|
|
88
|
-
'b64data' => b64data,
|
|
89
|
-
'idempotency_key' => idempotency_key,
|
|
90
|
-
'delta' => delta,
|
|
91
|
-
'version' => version,
|
|
92
|
-
'version_epoch' => version_epoch
|
|
93
|
-
})
|
|
177
|
+
# A request's JSON.
|
|
178
|
+
#
|
|
179
|
+
# @api private
|
|
180
|
+
def self.encode(w)
|
|
181
|
+
JSON.generate(w, strict: true, max_nesting: false)
|
|
182
|
+
rescue JSON::GeneratorError, EncodingError => e
|
|
183
|
+
raise InvalidRequestError, "cannot be encoded: #{e.message}"
|
|
94
184
|
end
|
|
95
185
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
186
|
+
# The endpoint without a trailing slash, or InvalidConfigError naming what
|
|
187
|
+
# is wrong.
|
|
188
|
+
#
|
|
189
|
+
# @api private
|
|
190
|
+
def self.check_endpoint(addr, option)
|
|
191
|
+
raise InvalidConfigError, "#{option} must be a String, not #{addr.class}" unless addr.is_a?(String)
|
|
192
|
+
|
|
193
|
+
u = begin
|
|
194
|
+
URI.parse(addr)
|
|
195
|
+
rescue URI::Error => e
|
|
196
|
+
raise InvalidConfigError, "#{option}: #{addr.inspect} is not a valid URL: #{e.message}"
|
|
197
|
+
end
|
|
198
|
+
unless u.is_a?(URI::HTTP) && %w[http https].include?(u.scheme&.downcase)
|
|
199
|
+
raise InvalidConfigError, "#{option}: #{addr.inspect} must be an absolute http:// or https:// URL"
|
|
200
|
+
end
|
|
201
|
+
raise InvalidConfigError, "#{option}: #{addr.inspect} has no host" if u.host.nil? || u.host.empty?
|
|
202
|
+
if u.query || u.fragment || u.userinfo
|
|
203
|
+
raise InvalidConfigError,
|
|
204
|
+
"#{option}: #{addr.inspect} must not have a query, fragment or credentials; use api_key"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
path = u.path.sub(%r{/+\z}, "")
|
|
208
|
+
last = path.split("/").last
|
|
209
|
+
if API_METHODS.key?(last)
|
|
210
|
+
raise InvalidConfigError,
|
|
211
|
+
"#{option}: #{addr.inspect} ends with the method #{last.inspect}; " \
|
|
212
|
+
"it is the API base URL, such as http://localhost:8000/api"
|
|
213
|
+
end
|
|
214
|
+
port = u.port == u.default_port ? "" : ":#{u.port}"
|
|
215
|
+
"#{u.scheme.downcase}://#{u.host}#{port}#{path}"
|
|
112
216
|
end
|
|
113
217
|
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
'user' => user,
|
|
119
|
-
'channel' => channel,
|
|
120
|
-
'client' => client,
|
|
121
|
-
'session' => session
|
|
122
|
-
})
|
|
218
|
+
# @api private
|
|
219
|
+
def self.header_name?(name)
|
|
220
|
+
name.is_a?(String) && !name.empty? &&
|
|
221
|
+
name.each_char.all? { |c| c.ord > 0x20 && c.ord < 0x7F && !'"(),/:;<=>?@[\]{}'.include?(c) }
|
|
123
222
|
end
|
|
124
223
|
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
'client' => client,
|
|
131
|
-
'session' => session,
|
|
132
|
-
'whitelist' => whitelist,
|
|
133
|
-
'disconnect' => disconnect
|
|
134
|
-
})
|
|
224
|
+
# Printable ASCII and tab: what HTTP header values carry without an encoding.
|
|
225
|
+
#
|
|
226
|
+
# @api private
|
|
227
|
+
def self.header_value?(value)
|
|
228
|
+
value.is_a?(String) && value.each_char.all? { |c| (c.ord >= 0x20 && c.ord < 0x7F) || c == "\t" }
|
|
135
229
|
end
|
|
136
230
|
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
231
|
+
# Sends a batch's body; an empty batch, whose body is nil, sends nothing.
|
|
232
|
+
#
|
|
233
|
+
# @api private
|
|
234
|
+
def send_batch(body, n, timeout)
|
|
235
|
+
check_fork
|
|
236
|
+
@calls.enter
|
|
237
|
+
begin
|
|
238
|
+
return [] if body.nil?
|
|
239
|
+
|
|
240
|
+
call("batch", -> { body }, n, deadline(timeout)) do |raw|
|
|
241
|
+
replies = Reply.batch(raw, n)
|
|
242
|
+
[replies, replies.count { !it["error"].nil? }]
|
|
243
|
+
end
|
|
244
|
+
ensure
|
|
245
|
+
@calls.leave
|
|
246
|
+
end
|
|
147
247
|
end
|
|
148
248
|
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
249
|
+
# An automatic batch: counted as a call by its batcher, closed or not.
|
|
250
|
+
#
|
|
251
|
+
# @api private
|
|
252
|
+
def send_auto_batch(body, n, timeout)
|
|
253
|
+
call("batch", -> { body }, n, HTTP.now + timeout, auto: true) do |raw|
|
|
254
|
+
replies = Reply.batch(raw, n)
|
|
255
|
+
[replies, replies.count { !it["error"].nil? }]
|
|
256
|
+
end
|
|
153
257
|
end
|
|
154
258
|
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
259
|
+
# @api private
|
|
260
|
+
attr_reader :calls
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
def setup_transport(http_client, http_options)
|
|
265
|
+
raise InvalidConfigError, "http_options must be a Hash" unless http_options.is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
if http_client
|
|
268
|
+
unless http_options.empty?
|
|
269
|
+
raise InvalidConfigError, "http_options are for the default transport: configure http_client instead"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
@http = callable(http_client, "http_client")
|
|
273
|
+
@pool = nil
|
|
274
|
+
else
|
|
275
|
+
check_http_options(http_options)
|
|
276
|
+
@pool = HTTP::Pool.new(http_options)
|
|
277
|
+
end
|
|
159
278
|
end
|
|
160
279
|
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
280
|
+
# In a process forked after the client was used - under Puma's
|
|
281
|
+
# preload_app! or a Sidekiq swarm, say - starts afresh: its own connections,
|
|
282
|
+
# as the parent's sockets are the parent's, and its own batch threads, as
|
|
283
|
+
# threads do not survive a fork. The parent's connections are left to it,
|
|
284
|
+
# not closed.
|
|
285
|
+
def check_fork
|
|
286
|
+
return if @pid == ::Process.pid
|
|
287
|
+
|
|
288
|
+
@fork_lock.synchronize do
|
|
289
|
+
next if @pid == ::Process.pid
|
|
290
|
+
|
|
291
|
+
@calls = Calls.new
|
|
292
|
+
@pool = HTTP::Pool.new(@pool.options) if @pool
|
|
293
|
+
@batcher = batcher(*@auto_batch) if @batcher
|
|
294
|
+
@pid = ::Process.pid
|
|
295
|
+
end
|
|
170
296
|
end
|
|
171
297
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
298
|
+
def batcher(auto_batch, batch_context)
|
|
299
|
+
unless auto_batch.nil? || auto_batch.is_a?(AutoBatch)
|
|
300
|
+
raise InvalidConfigError, "auto_batch must be a Cent::AutoBatch"
|
|
301
|
+
end
|
|
302
|
+
raise InvalidConfigError, "batch_context is for auto_batch: set both, or neither" if batch_context && !auto_batch
|
|
303
|
+
|
|
304
|
+
auto_batch && AutoBatch::Batcher.new(self, auto_batch, callable(batch_context, "batch_context"))
|
|
176
305
|
end
|
|
177
306
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
307
|
+
def callable(f, name)
|
|
308
|
+
raise InvalidConfigError, "#{name} must respond to call" unless f.nil? || f.respond_to?(:call)
|
|
309
|
+
|
|
310
|
+
f
|
|
182
311
|
end
|
|
183
312
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
313
|
+
def check_headers(headers, api_key)
|
|
314
|
+
raise InvalidConfigError, "headers must be a Hash" unless headers.is_a?(Hash)
|
|
315
|
+
|
|
316
|
+
h = {}
|
|
317
|
+
headers.each do |k, v|
|
|
318
|
+
k = k.to_s
|
|
319
|
+
case k.downcase
|
|
320
|
+
when "x-api-key" then raise InvalidConfigError, "headers may not set X-API-Key; use api_key"
|
|
321
|
+
when "authorization" then raise InvalidConfigError, "headers may not set Authorization; use bearer_token_func"
|
|
322
|
+
when "content-type" then raise InvalidConfigError, "headers may not set Content-Type"
|
|
323
|
+
end
|
|
324
|
+
raise InvalidConfigError, "headers: #{k.inspect} is not a valid HTTP header name" unless Client.header_name?(k)
|
|
325
|
+
unless Client.header_value?(v)
|
|
326
|
+
raise InvalidConfigError,
|
|
327
|
+
"headers: #{k} must be a String of printable ASCII: no line breaks, control or non-ASCII characters"
|
|
328
|
+
end
|
|
329
|
+
raise InvalidConfigError, "headers: Host must not be empty" if k.casecmp?("host") && v.empty?
|
|
330
|
+
|
|
331
|
+
h[k] = v
|
|
332
|
+
end
|
|
333
|
+
h["Content-Type"] = "application/json"
|
|
334
|
+
h["User-Agent"] = USER_AGENT unless h.keys.any? { it.casecmp?("user-agent") }
|
|
335
|
+
h["X-API-Key"] = api_key unless api_key.empty?
|
|
336
|
+
h.freeze
|
|
188
337
|
end
|
|
189
338
|
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
# If Centrifugo rejects the batch request as a whole (e.g. malformed
|
|
203
|
-
# top-level body), the top-level `error` field is present and
|
|
204
|
-
# {Cent::ResponseError} is raised normally.
|
|
205
|
-
#
|
|
206
|
-
# @example
|
|
207
|
-
# response = client.batch(commands: [
|
|
208
|
-
# { 'publish' => { 'channel' => 'a', 'data' => {} } },
|
|
209
|
-
# { 'publish' => { 'channel' => 'unknown:b', 'data' => {} } }
|
|
210
|
-
# ])
|
|
211
|
-
# response['replies'].each do |reply|
|
|
212
|
-
# if reply['error']
|
|
213
|
-
# warn "command failed: #{reply['error']['code']} #{reply['error']['message']}"
|
|
214
|
-
# end
|
|
215
|
-
# end
|
|
216
|
-
#
|
|
217
|
-
# @param commands [Array<Hash>] Each element is a command object of the
|
|
218
|
-
# form `{ "publish" => { ... } }`, `{ "broadcast" => { ... } }`, etc.
|
|
219
|
-
# @param parallel [Boolean, nil] When true, Centrifugo processes commands
|
|
220
|
-
# in parallel (lower latency, order not guaranteed).
|
|
221
|
-
# @see https://centrifugal.dev/docs/server/server_api#batch
|
|
222
|
-
def batch(commands:, parallel: nil)
|
|
223
|
-
send_command('batch', {
|
|
224
|
-
'commands' => commands,
|
|
225
|
-
'parallel' => parallel
|
|
226
|
-
})
|
|
339
|
+
# Timeouts are cent's: one deadline per call.
|
|
340
|
+
OWN_HTTP_OPTIONS = %i[open_timeout read_timeout write_timeout ssl_timeout keep_alive_timeout max_retries].freeze
|
|
341
|
+
private_constant :OWN_HTTP_OPTIONS
|
|
342
|
+
|
|
343
|
+
def check_http_options(options)
|
|
344
|
+
probe = Net::HTTP.new("localhost")
|
|
345
|
+
options.each_key do |k|
|
|
346
|
+
if OWN_HTTP_OPTIONS.include?(k.to_sym)
|
|
347
|
+
raise InvalidConfigError, "http_options: #{k} is cent's to set: a call is bounded by its timeout"
|
|
348
|
+
end
|
|
349
|
+
raise InvalidConfigError, "http_options: #{k} is not a Net::HTTP setting" unless probe.respond_to?(:"#{k}=")
|
|
350
|
+
end
|
|
227
351
|
end
|
|
228
352
|
|
|
229
|
-
|
|
353
|
+
def deadline(timeout, name = "timeout")
|
|
354
|
+
return HTTP.now + @request_timeout if timeout.nil?
|
|
355
|
+
# Zero is refused as a negative is: nil is the way to say "the default".
|
|
356
|
+
unless timeout.is_a?(Numeric) && timeout.positive?
|
|
357
|
+
raise InvalidRequestError, "#{name} must be a positive number of seconds; nil for request_timeout"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
HTTP.now + timeout
|
|
361
|
+
end
|
|
230
362
|
|
|
231
|
-
def
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
rescue Faraday::TimeoutError => e
|
|
236
|
-
raise Cent::TimeoutError, e.message
|
|
237
|
-
rescue Faraday::ConnectionFailed => e
|
|
238
|
-
raise Cent::NetworkError, e.message
|
|
239
|
-
rescue Faraday::UnauthorizedError => e
|
|
240
|
-
raise Cent::UnauthorizedError.new(status: 401, message: e.message)
|
|
241
|
-
rescue Faraday::ClientError, Faraday::ServerError => e
|
|
242
|
-
status = e.response_status || e.response&.dig(:status)
|
|
243
|
-
raise Cent::TransportError.new(status: status, message: e.message)
|
|
244
|
-
rescue Faraday::ParsingError => e
|
|
245
|
-
raise Cent::DecodeError, e.message
|
|
363
|
+
def encode_request(method)
|
|
364
|
+
Reply.validate(method, yield)
|
|
365
|
+
rescue InvalidRequestError => e
|
|
366
|
+
raise InvalidRequestError, "invalid #{method} request: #{e.message}"
|
|
246
367
|
end
|
|
247
368
|
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
369
|
+
# The result of an API call, as the Hash of its reply.
|
|
370
|
+
def call_result(method, timeout, &)
|
|
371
|
+
wire = encode_request(method, &)
|
|
372
|
+
deadline = deadline(timeout)
|
|
373
|
+
check_fork
|
|
374
|
+
@calls.enter
|
|
375
|
+
begin
|
|
376
|
+
alone = lambda do
|
|
377
|
+
call(method, -> { Client.encode(wire) }, 1, deadline) do |raw|
|
|
378
|
+
out = Reply.envelope(raw, method)
|
|
379
|
+
[out, out[1] ? 1 : 0]
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
if @batcher
|
|
383
|
+
went_alone, out = @batcher.call(method, wire, deadline, alone)
|
|
384
|
+
if went_alone
|
|
385
|
+
result, err = out
|
|
386
|
+
else
|
|
387
|
+
index, reply = out
|
|
388
|
+
err = Reply.command_error(reply, index, method)
|
|
389
|
+
result = Wire.obj(reply[method] || {}, "replies[#{index}].#{method}")
|
|
390
|
+
end
|
|
391
|
+
else
|
|
392
|
+
result, err = alone.call
|
|
393
|
+
end
|
|
394
|
+
ensure
|
|
395
|
+
@calls.leave
|
|
396
|
+
end
|
|
397
|
+
raise err if err
|
|
398
|
+
|
|
399
|
+
result
|
|
400
|
+
end
|
|
253
401
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
402
|
+
def invoke(method, result_class, timeout, &)
|
|
403
|
+
result_class.from_wire(call_result(method, timeout, &), "#{method} result")
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def call(method, make_body, commands, deadline, auto: false)
|
|
407
|
+
start = HTTP.now
|
|
408
|
+
status = [0]
|
|
409
|
+
begin
|
|
410
|
+
out, failed = yield roundtrip(method, make_body, deadline, status)
|
|
411
|
+
rescue StandardError => e
|
|
412
|
+
report(method, commands, 0, auto, start, status[0], e)
|
|
413
|
+
raise
|
|
414
|
+
end
|
|
415
|
+
report(method, commands, failed, auto, start, status[0], nil)
|
|
416
|
+
out
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def roundtrip(method, make_body, deadline, status)
|
|
420
|
+
base = @endpoint || Client.check_endpoint(function("api_endpoint_func") do
|
|
421
|
+
@endpoint_func.call
|
|
422
|
+
end, "api_endpoint_func")
|
|
423
|
+
headers = @headers
|
|
424
|
+
if @token_func
|
|
425
|
+
token = function("bearer_token_func") { @token_func.call }
|
|
426
|
+
unless token.is_a?(String) && !token.empty? && Client.header_value?(token)
|
|
427
|
+
raise InvalidConfigError,
|
|
428
|
+
"bearer_token_func returned an empty token, not a String, or one which is not printable ASCII"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
headers = headers.merge("Authorization" => "Bearer #{token}")
|
|
432
|
+
end
|
|
433
|
+
body = begin
|
|
434
|
+
make_body.call
|
|
435
|
+
rescue InvalidRequestError => e
|
|
436
|
+
raise InvalidRequestError, "invalid #{method} request: #{e.message}"
|
|
437
|
+
end
|
|
438
|
+
left = deadline - HTTP.now
|
|
439
|
+
raise TimeoutError, "#{method}: timed out" if left <= 0
|
|
440
|
+
|
|
441
|
+
code, resp_headers, data = send_http("#{base}/#{method}", headers, body, deadline, left)
|
|
442
|
+
status[0] = code
|
|
443
|
+
raise Reply.http_error(method, code, data, resp_headers) if code != 200
|
|
444
|
+
|
|
445
|
+
data
|
|
446
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, IO::TimeoutError => e
|
|
447
|
+
raise TimeoutError, "#{method}: timed out: #{e.message}"
|
|
448
|
+
rescue Net::HTTPBadResponse, Zlib::Error => e
|
|
449
|
+
raise DecodeError, "#{method}: the reply cannot be decoded: #{e.message}"
|
|
450
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::ProtocolError => e
|
|
451
|
+
raise NetworkError, "#{method}: #{e.class}: #{e.message}"
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# What a configured function returns; its own exception raised as a
|
|
455
|
+
# FunctionError naming it, the exception its cause.
|
|
456
|
+
def function(name)
|
|
457
|
+
yield
|
|
458
|
+
rescue StandardError => e
|
|
459
|
+
raise FunctionError.new(name, "#{name} raised #{e.class}: #{e.message}")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def send_http(url, headers, body, deadline, left)
|
|
463
|
+
return @pool.post(url, headers, body, deadline) if @pool
|
|
464
|
+
|
|
465
|
+
code, resp_headers, data = @http.call(url, headers, body, left)
|
|
466
|
+
[code, resp_headers.to_h.transform_keys { it.to_s.downcase }, data.to_s]
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def report(method, commands, failed, auto, start, status, error)
|
|
470
|
+
return unless @on_request
|
|
471
|
+
|
|
472
|
+
@on_request.call(RequestInfo.new(method:, commands:, failed:, auto_batch: auto, duration: HTTP.now - start,
|
|
473
|
+
status_code: status, error:))
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Broadcast.
|
|
477
|
+
|
|
478
|
+
def send_broadcast(timeout, &build)
|
|
479
|
+
wire = nil
|
|
480
|
+
raw = call_result("broadcast", timeout) { wire = build.call }
|
|
481
|
+
BroadcastResult.from_wire(raw, wire["channels"], wire["idempotency_key"] || "").check!
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def retry_broadcast(max_attempts, attempt_timeout, timeout, on_attempt_error, &)
|
|
485
|
+
check_retry_options(max_attempts, attempt_timeout)
|
|
486
|
+
wire = encode_request("broadcast", &)
|
|
487
|
+
# Without a timeout the call has no bound of its own: each attempt has
|
|
488
|
+
# attempt_timeout, or request_timeout as a call alone would, so that an
|
|
489
|
+
# attempt which hangs leaves time to retry.
|
|
490
|
+
deadline = timeout.nil? ? Float::INFINITY : deadline(timeout)
|
|
491
|
+
per_attempt = attempt_timeout || @request_timeout
|
|
492
|
+
state = RetryState.new(wire)
|
|
493
|
+
last = nil
|
|
494
|
+
(1..max_attempts).each do |attempt|
|
|
495
|
+
left = deadline - HTTP.now
|
|
496
|
+
if left <= 0
|
|
497
|
+
last ||= TimeoutError.new("broadcast: timed out")
|
|
498
|
+
break
|
|
499
|
+
end
|
|
500
|
+
res, err = broadcast_attempt(state.attempt_wire, [left, per_attempt].min)
|
|
501
|
+
state.record(res, err)
|
|
502
|
+
last = err
|
|
503
|
+
# An ended deadline leaves no time for another attempt.
|
|
504
|
+
break if err.nil? || !Cent.retryable?(err) || attempt == max_attempts || HTTP.now >= deadline
|
|
505
|
+
|
|
506
|
+
retry_channels = state.next_channels(res)
|
|
507
|
+
wait = on_attempt_error && attempt_error(on_attempt_error, err) do
|
|
508
|
+
BroadcastAttempt.new(attempt:, error: err, retry: retry_channels, result: state.result,
|
|
509
|
+
idempotency_key: state.key)
|
|
510
|
+
end
|
|
511
|
+
sleep((wait.nil? ? Cent.default_delay(attempt) : wait.to_f).clamp(0, [deadline - HTTP.now, 0].max))
|
|
512
|
+
raise ClosedError, "client closed" if @calls.closed?
|
|
513
|
+
end
|
|
514
|
+
state.outcome(last)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def check_retry_options(max_attempts, attempt_timeout)
|
|
518
|
+
unless max_attempts.is_a?(Integer) && max_attempts >= 1
|
|
519
|
+
raise InvalidRequestError, "invalid broadcast request: max_attempts must be 1 or more"
|
|
520
|
+
end
|
|
521
|
+
return if attempt_timeout.nil? || (attempt_timeout.is_a?(Numeric) && attempt_timeout.positive?)
|
|
522
|
+
|
|
523
|
+
raise InvalidRequestError, "invalid broadcast request: attempt_timeout must be positive"
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# [result, error] of an attempt of broadcast_with_retry.
|
|
527
|
+
def broadcast_attempt(wire, timeout)
|
|
528
|
+
[send_broadcast(timeout) { wire }, nil]
|
|
529
|
+
rescue InvalidRequestError
|
|
530
|
+
raise
|
|
531
|
+
rescue BroadcastFailedChannelsError => e
|
|
532
|
+
[e.result, e]
|
|
533
|
+
rescue Error => e
|
|
534
|
+
[BroadcastResult.failed_as_whole(wire["channels"], e, 1), e]
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# What broadcast_with_retry's block returns. To stop, it raises: its
|
|
538
|
+
# exception propagates, its cause the attempt's error.
|
|
539
|
+
def attempt_error(on_attempt_error, err)
|
|
540
|
+
on_attempt_error.call(yield)
|
|
541
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised, with its cause
|
|
542
|
+
raise e, cause: err if e.cause.nil? && !e.equal?(err)
|
|
543
|
+
|
|
544
|
+
raise
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# The outcome of broadcast_with_retry's attempts so far.
|
|
548
|
+
#
|
|
549
|
+
# @api private
|
|
550
|
+
class RetryState
|
|
551
|
+
attr_reader :key
|
|
552
|
+
|
|
553
|
+
def initialize(wire)
|
|
554
|
+
@wire = wire
|
|
555
|
+
@channels = wire["channels"]
|
|
556
|
+
@key = wire["idempotency_key"] || "rubycent-#{SecureRandom.urlsafe_base64(18)}"
|
|
557
|
+
@final = @channels.map { ChannelResult.new(channel: it) }
|
|
558
|
+
@attempts = 0
|
|
559
|
+
# Positions in channels of what the next attempt sends: positions, not
|
|
560
|
+
# names, so that a channel listed twice keeps both entries.
|
|
561
|
+
@pending = (0...@channels.size).to_a
|
|
562
|
+
@replied = false
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def attempt_wire = @wire.merge("channels" => @pending.map { @channels[it] }, "idempotency_key" => @key)
|
|
566
|
+
|
|
567
|
+
def record(res, err)
|
|
568
|
+
@attempts += 1
|
|
569
|
+
@pending.each_with_index do |pos, i|
|
|
570
|
+
@final[pos] = res.channels[i].with(attempts: @final[pos].attempts + 1)
|
|
571
|
+
end
|
|
572
|
+
@replied = true if err.nil? || err.is_a?(BroadcastFailedChannelsError)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Keeps in pending the channels which may still succeed, and returns them.
|
|
576
|
+
def next_channels(res)
|
|
577
|
+
@pending = @pending.each_with_index.filter_map { |pos, i| pos if Cent.retryable?(res.channels[i].error) }
|
|
578
|
+
@pending.map { @final[it] }
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def result
|
|
582
|
+
BroadcastResult.new(channels: @final.dup, idempotency_key: @key, attempts: @attempts, replied: @replied)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# The final result, or the error to raise: BroadcastFailedChannelsError
|
|
586
|
+
# when some attempt got a reply, the last attempt's error otherwise.
|
|
587
|
+
def outcome(last)
|
|
588
|
+
unless @replied
|
|
589
|
+
last.idempotency_key = @key if last.is_a?(Error)
|
|
590
|
+
raise last
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
result.check!
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
private_constant :RetryState
|
|
597
|
+
|
|
598
|
+
# The calls in progress, so that close can wait for them.
|
|
599
|
+
#
|
|
600
|
+
# @api private
|
|
601
|
+
class Calls
|
|
602
|
+
def initialize
|
|
603
|
+
@lock = Mutex.new
|
|
604
|
+
@idle = ConditionVariable.new
|
|
605
|
+
@active = 0
|
|
606
|
+
@closed = false
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def closed? = @closed
|
|
610
|
+
|
|
611
|
+
def enter
|
|
612
|
+
@lock.synchronize do
|
|
613
|
+
raise ClosedError, "client closed" if @closed
|
|
614
|
+
|
|
615
|
+
@active += 1
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Counts in work going on for calls already counted - an automatic batch
|
|
620
|
+
# which outlives its callers - closed or not.
|
|
621
|
+
def add = @lock.synchronize { @active += 1 }
|
|
622
|
+
|
|
623
|
+
def leave
|
|
624
|
+
@lock.synchronize do
|
|
625
|
+
@active -= 1
|
|
626
|
+
@idle.broadcast if @active.zero?
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def close(timeout)
|
|
631
|
+
deadline = timeout && (HTTP.now + timeout)
|
|
632
|
+
@lock.synchronize do
|
|
633
|
+
@closed = true
|
|
634
|
+
until @active.zero?
|
|
635
|
+
left = deadline && (deadline - HTTP.now)
|
|
636
|
+
return false if left && left <= 0
|
|
637
|
+
|
|
638
|
+
@idle.wait(@lock, left)
|
|
639
|
+
end
|
|
640
|
+
true
|
|
641
|
+
end
|
|
642
|
+
end
|
|
258
643
|
end
|
|
644
|
+
private_constant :Calls
|
|
259
645
|
end
|
|
260
646
|
end
|