cent 3.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,272 +1,646 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
- require 'cent/http'
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
- # Main object that handles configuration and requests to centrifugo API
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:
36
+ #
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" })
10
39
  #
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.
11
47
  class Client
12
- # @param endpoint [String]
13
- # (default: 'http://localhost:8000/api') Centrifugo HTTP API URL
14
- #
15
- # @param api_key [String]
16
- # Centrifugo API key(used to perform requests)
17
- #
18
- # @yield [Faraday::Connection] yields connection object so that it can be configured
19
- #
20
- # @example Construct new client instance
21
- # Cent::Client.new(
22
- # endpoint: 'http://localhost:8000/api',
23
- # api_key: 'api key'
24
- # )
25
- #
26
- def initialize(api_key:, endpoint: 'http://localhost:8000/api')
27
- headers = {
28
- 'Content-Type' => 'application/json',
29
- 'Authorization' => "apikey #{api_key}"
30
- }
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")
31
107
 
32
- @connection = Faraday.new(endpoint, headers: headers) do |conn|
33
- conn.request :json # encode req bodies as JSON
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
34
117
 
35
- conn.response :json # decode response bodies as JSON
36
- conn.response :raise_error
118
+ @token_func = callable(bearer_token_func, "bearer_token_func")
119
+ @headers = check_headers(headers, api_key)
37
120
 
38
- yield conn if block_given?
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"
39
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
40
136
  end
41
137
 
42
- # Publish data into channel
43
- #
44
- # @param channel [String]
45
- # Name of the channel to publish
46
- #
47
- # @param data [Hash]
48
- # Data for publication in the channel
49
- #
50
- # @example Publish `content: 'hello'` into `chat` channel
51
- # client.publish(channel: 'chat', data: 'hello') #=> {}
52
- #
53
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#publish)
54
- #
55
- # @raise [Cent::Error, Cent::ResponseError]
56
- #
57
- # @return [Hash] Return empty hash in case of successful publish
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.
58
142
  #
59
- def publish(channel:, data:)
60
- execute('publish', channel: channel, data: data)
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
61
175
  end
62
176
 
63
- # Publish data into multiple channels
64
- # (Similar to `#publish` but allows to send the same data into many channels)
65
- #
66
- # @param channels [Array<String>] Collection of channels names to publish
67
- # @param data [Hash] Data for publication in the channels
68
- #
69
- # @example Broadcast `content: 'hello'` into `channel_1`, 'channel_2' channels
70
- # client.broadcast(channels: ['channel_1', 'channel_2'], data: 'hello') #=> {}
71
- #
72
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#broadcast)
73
- #
74
- # @raise [Cent::Error, Cent::ResponseError]
177
+ # A request's JSON.
75
178
  #
76
- # @return [Hash] Return empty hash in case of successful broadcast
77
- #
78
- def broadcast(channels:, data:)
79
- execute('broadcast', channels: channels, data: data)
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}"
80
184
  end
81
185
 
82
- # Unsubscribe user from channel
83
- #
84
- # @param channel [String]
85
- # Channel name to unsubscribe from
86
- #
87
- # @param user [String, Integer]
88
- # User ID you want to unsubscribe
89
- #
90
- # @example Unsubscribe user with `id = 1` from `chat` channel
91
- # client.unsubscribe(channel: 'chat', user: '1') #=> {}
92
- #
93
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#unsubscribe)
186
+ # The endpoint without a trailing slash, or InvalidConfigError naming what
187
+ # is wrong.
94
188
  #
95
- # @raise [Cent::Error, Cent::ResponseError]
96
- #
97
- # @return [Hash] Return empty hash in case of successful unsubscribe
98
- #
99
- def unsubscribe(channel:, user:)
100
- execute('unsubscribe', channel: channel, user: user)
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}"
101
216
  end
102
217
 
103
- # Disconnect user by it's ID
104
- #
105
- # @param user [String, Integer]
106
- # User ID you want to disconnect
107
- #
108
- # @example Disconnect user with `id = 1`
109
- # client.disconnect(user: '1') #=> {}
110
- #
111
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#disconnect)
112
- #
113
- # @raise [Cent::Error, Cent::ResponseError]
114
- #
115
- # @return [Hash] Return empty hash in case of successful disconnect
116
- #
117
- def disconnect(user:)
118
- execute('disconnect', user: user)
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) }
119
222
  end
120
223
 
121
- # Get channel presence information
122
- # (all clients currently subscribed on this channel)
123
- #
124
- # @param channel [String] Name of the channel
125
- #
126
- # @example Get presence information for channel `chat`
127
- # client.presence(channel: 'chat') #=> {
128
- # "result" => {
129
- # "presence" => {
130
- # "c54313b2-0442-499a-a70c-051f8588020f" => {
131
- # "client" => "c54313b2-0442-499a-a70c-051f8588020f",
132
- # "user" => "42"
133
- # },
134
- # "adad13b1-0442-499a-a70c-051f858802da" => {
135
- # "client" => "adad13b1-0442-499a-a70c-051f858802da",
136
- # "user" => "42"
137
- # }
138
- # }
139
- # }
140
- # }
224
+ # Printable ASCII and tab: what HTTP header values carry without an encoding.
141
225
  #
142
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#presence)
143
- #
144
- # @raise [Cent::Error, Cent::ResponseError]
145
- #
146
- # @return [Hash]
147
- # Return hash with information about all clients currently subscribed on this channel
148
- #
149
- def presence(channel:)
150
- execute('presence', channel: channel)
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" }
151
229
  end
152
230
 
153
- # Get short channel presence information
154
- #
155
- # @param channel [String] Name of the channel
156
- #
157
- # @example Get short presence information for channel `chat`
158
- # client.presence_stats(channel: 'chat') #=> {
159
- # "result" => {
160
- # "num_clients" => 0,
161
- # "num_users" => 0
162
- # }
163
- # }
164
- #
165
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#presence_stats)
166
- #
167
- # @raise [Cent::Error, Cent::ResponseError]
168
- #
169
- # @return [Hash]
170
- # Return hash with short presence information about channel
171
- #
172
- def presence_stats(channel:)
173
- execute('presence_stats', channel: channel)
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
174
247
  end
175
248
 
176
- # Get channel history information
177
- # (list of last messages published into channel)
178
- #
179
- # @param channel [String] Name of the channel
180
- #
181
- # @example Get history for channel `chat`
182
- # client.history(channel: 'chat') #=> {
183
- # "result" => {
184
- # "publications" => [
185
- # {
186
- # "data" => {
187
- # "text" => "hello"
188
- # },
189
- # "uid" => "BWcn14OTBrqUhTXyjNg0fg"
190
- # },
191
- # {
192
- # "data" => {
193
- # "text" => "hi!"
194
- # },
195
- # "uid" => "Ascn14OTBrq14OXyjNg0hg"
196
- # }
197
- # ]
198
- # }
199
- # }
200
- #
201
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#history)
202
- #
203
- # @raise [Cent::Error, Cent::ResponseError]
249
+ # An automatic batch: counted as a call by its batcher, closed or not.
204
250
  #
205
- # @return [Hash]
206
- # Return hash with a list of last messages published into channel
207
- #
208
- def history(channel:)
209
- execute('history', channel: channel)
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
210
257
  end
211
258
 
212
- # Get list of active(with one or more subscribers) channels.
213
- #
214
- # @example Get active channels list
215
- # client.channels #=> {
216
- # "result" => {
217
- # "channels" => [
218
- # "chat"
219
- # ]
220
- # }
221
- # }
222
- #
223
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#channels)
224
- #
225
- # @raise [Cent::Error, Cent::ResponseError]
226
- #
227
- # @return [Hash]
228
- # Return hash with a list of active channels
229
- #
230
- def channels
231
- execute('channels', {})
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
232
278
  end
233
279
 
234
- # Get information about running Centrifugo nodes
235
- #
236
- # @example Get running centrifugo nodes list
237
- # client.info #=> {
238
- # "result" => {
239
- # "nodes" => [
240
- # {
241
- # "name" => "Alexanders-MacBook-Pro.local_8000",
242
- # "num_channels" => 0,
243
- # "num_clients" => 0,
244
- # "num_users" => 0,
245
- # "uid" => "f844a2ed-5edf-4815-b83c-271974003db9",
246
- # "uptime" => 0,
247
- # "version" => ""
248
- # }
249
- # ]
250
- # }
251
- # }
252
- #
253
- # @see (https://centrifugal.github.io/centrifugo/server/http_api/#info)
254
- #
255
- # @raise [Cent::Error, Cent::ResponseError]
256
- #
257
- # @return [Hash]
258
- # Return hash with a list of last messages published into channel
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
296
+ end
297
+
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"))
305
+ end
306
+
307
+ def callable(f, name)
308
+ raise InvalidConfigError, "#{name} must respond to call" unless f.nil? || f.respond_to?(:call)
309
+
310
+ f
311
+ end
312
+
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
337
+ end
338
+
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
351
+ end
352
+
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
362
+
363
+ def encode_request(method)
364
+ Reply.validate(method, yield)
365
+ rescue InvalidRequestError => e
366
+ raise InvalidRequestError, "invalid #{method} request: #{e.message}"
367
+ end
368
+
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
401
+
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.
259
548
  #
260
- def info
261
- execute('info', {})
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
262
595
  end
596
+ private_constant :RetryState
263
597
 
264
- private
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 }
265
622
 
266
- def execute(method, data)
267
- body = { method: method, params: data }
623
+ def leave
624
+ @lock.synchronize do
625
+ @active -= 1
626
+ @idle.broadcast if @active.zero?
627
+ end
628
+ end
268
629
 
269
- Cent::HTTP.new(connection: @connection).post(body: body)
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
270
643
  end
644
+ private_constant :Calls
271
645
  end
272
646
  end