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.
data/lib/cent/client.rb CHANGED
@@ -1,260 +1,646 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
- require 'cent/error'
3
+ require "securerandom"
5
4
 
6
5
  module Cent
7
- # Cent::Client
6
+ # One HTTP request to Centrifugo's API, for on_request.
8
7
  #
9
- # Ruby client for Centrifugo server HTTP API (Centrifugo v4+).
10
- #
11
- # Every API method returns the raw parsed response body from Centrifugo —
12
- # typically `{ "result" => { ... } }` on success. If Centrifugo rejects the
13
- # request with a top-level `error`, {Cent::ResponseError} is raised with
14
- # Centrifugo's numeric `code` and `message`. Transport-level problems
15
- # (network failure, timeout, non-2xx HTTP status, unparseable body) raise
16
- # other {Cent::Error} subclasses.
17
- #
18
- # {#batch} and {#broadcast} are special: their responses contain an array
19
- # of independent sub-replies, each of which may carry its own `error`
20
- # field. Those sub-reply errors are NOT raised — callers inspect them
21
- # manually. See {#batch} for details.
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
- # @example Basic usage
24
- # client = Cent::Client.new(api_key: 'secret')
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
- # @example Custom Faraday configuration
29
- # Cent::Client.new(api_key: 'k', endpoint: 'https://c.example.com/api') do |conn|
30
- # conn.options.open_timeout = 3
31
- # conn.options.timeout = 7
32
- # conn.adapter :typhoeus
33
- # end
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
- DEFAULT_ENDPOINT = 'http://localhost:8000/api'
36
- DEFAULT_TIMEOUT = 10
37
-
38
- attr_reader :connection
39
-
40
- # @param api_key [String] Centrifugo HTTP API key (sent as `X-API-Key`).
41
- # @param endpoint [String] Centrifugo HTTP API base URL.
42
- # @param timeout [Numeric] Request timeout in seconds.
43
- # @yield [Faraday::Connection] optional block to further configure the connection.
44
- def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, timeout: DEFAULT_TIMEOUT, &block)
45
- headers = {
46
- 'Content-Type' => 'application/json',
47
- 'X-API-Key' => api_key
48
- }
49
-
50
- base = endpoint.end_with?('/') ? endpoint : "#{endpoint}/"
51
-
52
- @connection = Faraday.new(base, headers: headers) do |conn|
53
- conn.options.timeout = timeout
54
- conn.options.open_timeout = timeout
55
- conn.request :json
56
- conn.response :json
57
- conn.response :raise_error
58
- block&.call(conn)
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
- # Publish data into a channel.
63
- # @see https://centrifugal.dev/docs/server/server_api#publish
64
- def publish(channel:, data:, skip_history: nil, tags: nil, b64data: nil,
65
- idempotency_key: nil, delta: nil, version: nil, version_epoch: nil)
66
- send_command('publish', {
67
- 'channel' => channel,
68
- 'data' => data,
69
- 'skip_history' => skip_history,
70
- 'tags' => tags,
71
- 'b64data' => b64data,
72
- 'idempotency_key' => idempotency_key,
73
- 'delta' => delta,
74
- 'version' => version,
75
- 'version_epoch' => version_epoch
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
- # Publish the same data into many channels.
80
- # @see https://centrifugal.dev/docs/server/server_api#broadcast
81
- def broadcast(channels:, data:, skip_history: nil, tags: nil, b64data: nil,
82
- idempotency_key: nil, delta: nil, version: nil, version_epoch: nil)
83
- send_command('broadcast', {
84
- 'channels' => channels,
85
- 'data' => data,
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
- # Subscribe a user's active session to a channel (server-side subscription).
97
- # @see https://centrifugal.dev/docs/server/server_api#subscribe
98
- def subscribe(user:, channel:, info: nil, b64info: nil, client: nil, session: nil,
99
- data: nil, b64data: nil, recover_since: nil, override: nil)
100
- send_command('subscribe', {
101
- 'user' => user,
102
- 'channel' => channel,
103
- 'info' => info,
104
- 'b64info' => b64info,
105
- 'client' => client,
106
- 'session' => session,
107
- 'data' => data,
108
- 'b64data' => b64data,
109
- 'recover_since' => recover_since,
110
- 'override' => override
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
- # Unsubscribe a user from a channel.
115
- # @see https://centrifugal.dev/docs/server/server_api#unsubscribe
116
- def unsubscribe(user:, channel:, client: nil, session: nil)
117
- send_command('unsubscribe', {
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
- # Disconnect a user by ID.
126
- # @see https://centrifugal.dev/docs/server/server_api#disconnect
127
- def disconnect(user:, client: nil, session: nil, whitelist: nil, disconnect: nil)
128
- send_command('disconnect', {
129
- 'user' => user,
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
- # Refresh a user connection (for unidirectional transports).
138
- # @see https://centrifugal.dev/docs/server/server_api#refresh
139
- def refresh(user:, client: nil, session: nil, expired: nil, expire_at: nil)
140
- send_command('refresh', {
141
- 'user' => user,
142
- 'client' => client,
143
- 'session' => session,
144
- 'expired' => expired,
145
- 'expire_at' => expire_at
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
- # Get channel presence (all currently subscribed clients).
150
- # @see https://centrifugal.dev/docs/server/server_api#presence
151
- def presence(channel:)
152
- send_command('presence', { 'channel' => channel })
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
- # Get short presence stats for a channel.
156
- # @see https://centrifugal.dev/docs/server/server_api#presence_stats
157
- def presence_stats(channel:)
158
- send_command('presence_stats', { 'channel' => channel })
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
- # Get channel history (recent publications).
162
- # @see https://centrifugal.dev/docs/server/server_api#history
163
- def history(channel:, limit: nil, since: nil, reverse: nil)
164
- send_command('history', {
165
- 'channel' => channel,
166
- 'limit' => limit,
167
- 'since' => since,
168
- 'reverse' => reverse
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
- # Remove all publications from a channel's history.
173
- # @see https://centrifugal.dev/docs/server/server_api#history_remove
174
- def history_remove(channel:)
175
- send_command('history_remove', { 'channel' => channel })
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
- # List active channels (channels with at least one subscriber).
179
- # @see https://centrifugal.dev/docs/server/server_api#channels
180
- def channels(pattern: nil)
181
- send_command('channels', { 'pattern' => pattern })
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
- # Get information about running Centrifugo nodes.
185
- # @see https://centrifugal.dev/docs/server/server_api#info
186
- def info
187
- send_command('info', {})
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
- # Send many commands in a single request.
191
- #
192
- # The response is shaped `{ "replies" => [<reply>, ...] }` — note there
193
- # is no top-level `result` wrapper, unlike every other method. Each reply
194
- # in the array corresponds to one command (in the order they were sent
195
- # when `parallel` is not set) and has the shape `{ "<method>" => <result> }`
196
- # on success or `{ "error" => { "code" => ..., "message" => ... } }` on a
197
- # per-command failure.
198
- #
199
- # These per-command errors are **not** raised as {Cent::ResponseError} —
200
- # that would make partial-failure responses impossible to inspect. The
201
- # caller is expected to walk `response["replies"]` and check each entry.
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
- private
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 send_command(method, params)
232
- body = connection.post(method, params.compact).body
233
- check_response_error!(body)
234
- body
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
- # Top-level `error` means Centrifugo rejected the whole request. Batch
249
- # and broadcast sub-reply errors live inside arrays and are NOT raised —
250
- # callers inspect them manually (see #batch docs).
251
- def check_response_error!(body)
252
- return unless body.is_a?(Hash) && body['error'].is_a?(Hash)
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
- raise Cent::ResponseError.new(
255
- code: body['error']['code'],
256
- message: body['error']['message']
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