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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/MIGRATING.md +305 -0
- data/README.md +695 -169
- 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 +604 -230
- 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 +236 -32
- 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 +38 -56
- data/.github/workflows/main.yml +0 -20
- data/.github/workflows/release.yml +0 -37
- data/.gitignore +0 -20
- data/.rspec +0 -3
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -17
- data/Rakefile +0 -12
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/cent.gemspec +0 -35
- data/lib/cent/error.rb +0 -9
- data/lib/cent/notary.rb +0 -96
data/lib/cent/client.rb
CHANGED
|
@@ -1,272 +1,646 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require 'cent/http'
|
|
3
|
+
require "securerandom"
|
|
5
4
|
|
|
6
5
|
module Cent
|
|
7
|
-
#
|
|
6
|
+
# One HTTP request to Centrifugo's API, for on_request.
|
|
8
7
|
#
|
|
9
|
-
#
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# @
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
118
|
+
@token_func = callable(bearer_token_func, "bearer_token_func")
|
|
119
|
+
@headers = check_headers(headers, api_key)
|
|
37
120
|
|
|
38
|
-
|
|
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
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
#
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
#
|
|
154
|
-
#
|
|
155
|
-
# @
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
#
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
623
|
+
def leave
|
|
624
|
+
@lock.synchronize do
|
|
625
|
+
@active -= 1
|
|
626
|
+
@idle.broadcast if @active.zero?
|
|
627
|
+
end
|
|
628
|
+
end
|
|
268
629
|
|
|
269
|
-
|
|
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
|