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.
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # An API call left for Centrifugo's async consumers to make: written to a
5
+ # PostgreSQL outbox table, a Kafka topic, a Redis stream. See
6
+ # https://centrifugal.dev/docs/server/consumers.
7
+ #
8
+ # There is a class method per API method, taking the fields of its request as
9
+ # keyword arguments, as Client's methods do. The call is checked as a Client
10
+ # checks it before sending - an invalid one raises InvalidRequestError here,
11
+ # rather than in Centrifugo, whose consumers drop a command they cannot run -
12
+ # and encoded:
13
+ #
14
+ # cmd = Cent::Command.publish(channel: "news", data: { text: "hi" })
15
+ # db.exec_params("INSERT INTO centrifugo_outbox (method, payload, partition) VALUES ($1, $2, 0)",
16
+ # [cmd.method, cmd.payload])
17
+ #
18
+ # Consumers take it as its method and payload apart - the outbox table's
19
+ # method and payload columns, a message attribute - or as the JSON message
20
+ # +{"method": ..., "payload": ...}+ of {#to_json} (Kafka, NATS JetStream).
21
+ # {#as_json} gives that message with payload as a Hash, so a command inside
22
+ # another value - rendered by Rails, say - carries an object, not a string.
23
+ #
24
+ # The outbox table's payload column is jsonb: raw SQL passes {#payload}, JSON
25
+ # text, as it is; ActiveRecord's json types take the object,
26
+ # +JSON.parse(cmd.payload)+ - given the text, they would store a JSON string.
27
+ #
28
+ # Where the method goes apart from the payload - the outbox table's method
29
+ # column, a message header or attribute - Centrifugo reads the payload as the
30
+ # request itself: a JSON string holding it is a bad request there, dropped
31
+ # with only Centrifugo's log saying so. Only a message which is a whole
32
+ # command may hold its payload as a string of JSON, for CDC pipelines.
33
+ #
34
+ # Within a partition a consumer keeps commands in order: a channel's commands
35
+ # keep their order only when every writer derives their partition from the
36
+ # channel the same way - the channel as a Kafka message key, say.
37
+ #
38
+ # @!attribute [r] method
39
+ # @return [String] the API method, such as "publish"
40
+ # @!attribute [r] payload
41
+ # @return [String] the request, as JSON text
42
+ class Command < Data.define(:method, :payload)
43
+ extend CommandMethods
44
+
45
+ # The command as one JSON message: +{"method": ..., "payload": ...}+, the
46
+ # payload's text kept exactly.
47
+ #
48
+ # @return [String]
49
+ def to_json(*) = "{\"method\":#{JSON.generate(method)},\"payload\":#{payload}}"
50
+
51
+ # The message of {#to_json} as Ruby values, payload a Hash.
52
+ #
53
+ # @return [Hash{String => Object}]
54
+ def as_json(*) = { "method" => method, "payload" => JSON.parse(payload) }
55
+
56
+ def self.build(method)
57
+ w = Reply.validate(method, yield)
58
+ new(method:, payload: Client.encode(w))
59
+ rescue InvalidRequestError => e
60
+ raise InvalidRequestError, "invalid #{method} request: #{e.message}"
61
+ end
62
+ private_class_method :build
63
+ end
64
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # The HTTP endpoints client SDKs call: for connection and subscription tokens
5
+ # - what their getToken callbacks fetch - and for shared poll signatures, plus
6
+ # the JSON Web Key Set Centrifugo fetches. Each is a {Handler}: a Rack app,
7
+ # with a framework-free core.
8
+ #
9
+ # conn = Cent::Endpoints::ConnectionToken.new(on_error: ->(_http, err) { logger.error(err) }) do |req|
10
+ # user = session_user(req.raw) # the Rack env
11
+ # raise Cent::Endpoints::ForbiddenError unless user
12
+ #
13
+ # Cent::Token.connection(key, user:, expires_at: Time.now + 15 * 60)
14
+ # end
15
+ # mount conn, at: "/centrifugo/connection_token" # config/routes.rb
16
+ #
17
+ # An endpoint answers a JSON object, never cached; ForbiddenError - also as the
18
+ # cause of another error - with 403, on which client SDKs stop asking, and
19
+ # disconnect or unsubscribe; any other error with 500, which they retry. A
20
+ # malformed request gets 400, a wrong method 405. +on_error+, when set, is
21
+ # told of every 500 and 400, with the request and the error - without it, an
22
+ # error in the block is logged nowhere.
23
+ module Endpoints
24
+ # Bodies are read up to 1 MiB.
25
+ MAX_BODY = 1 << 20
26
+
27
+ # Raised by the block of an endpoint when the user may not have a token or
28
+ # signature: answered with 403, on which client SDKs stop asking, and
29
+ # disconnect or unsubscribe. Client SDKs report it as their
30
+ # UnauthorizedError. It is not the API client's {Cent::UnauthorizedError},
31
+ # which means Centrifugo refused this server's credentials: the two are
32
+ # named apart so that rescuing one where the other is meant cannot pass
33
+ # silently.
34
+ class ForbiddenError < StandardError
35
+ def initialize(msg = "forbidden") = super
36
+ end
37
+
38
+ # @api private
39
+ class Endpoint < Handler
40
+ # @param on_error [#call, nil] told of every 500 and 400, with the
41
+ # request and the error; it must not raise. Pass one: without it, an
42
+ # error in the block is answered with 500 and logged nowhere.
43
+ def initialize(on_error: nil, &) = super(MAX_BODY, on_error, &)
44
+
45
+ private
46
+
47
+ def answer(req, body, error)
48
+ if error
49
+ return HTTPResponse.text(403, "Forbidden") if find(error, ForbiddenError)
50
+
51
+ return failure(req, 500, error)
52
+ end
53
+ HTTPResponse.json(JSON.generate(body))
54
+ end
55
+
56
+ def token(req, out, error)
57
+ if error.nil? && !(out.is_a?(String) && !out.empty?)
58
+ error = TypeError.new("the block returned #{out.class}, not a token")
59
+ end
60
+ answer(req, error ? nil : { "token" => out }, error)
61
+ end
62
+ end
63
+
64
+ # The endpoint a client's getToken callback calls. Its block, given the
65
+ # {HTTPRequest}, returns the connection token of the request's user - from
66
+ # {Token.connection}, say - or raises ForbiddenError. It takes GET and POST,
67
+ # the body ignored.
68
+ class ConnectionToken < Endpoint
69
+ private
70
+
71
+ def start(req, _body)
72
+ return HTTPResponse.allow("GET, POST") unless %w[GET POST].include?(req.method)
73
+
74
+ []
75
+ end
76
+
77
+ def finish(req, _args, out, error) = token(req, out, error)
78
+ end
79
+
80
+ # The endpoint a subscription's getToken callback calls. Its block, given
81
+ # the channel and the {HTTPRequest}, returns the token of the request's
82
+ # user for the channel - from {Token.subscription}, say - or raises
83
+ # ForbiddenError when the user may not subscribe. The channel comes from a
84
+ # POST's JSON body, +{"channel": ...}+, or from a GET's channel query
85
+ # parameter; it is required.
86
+ #
87
+ # Cent::Endpoints::SubscriptionToken.new do |channel, req|
88
+ # user = session_user(req.raw)
89
+ # raise Cent::Endpoints::ForbiddenError unless user && may_read?(user, channel)
90
+ #
91
+ # Cent::Token.subscription(key, user:, channel:, expires_at: Time.now + 15 * 60)
92
+ # end
93
+ class SubscriptionToken < Endpoint
94
+ private
95
+
96
+ def start(req, body)
97
+ case req.method
98
+ when "GET" then channel = req.query.fetch("channel", "")
99
+ when "POST"
100
+ d = parse_json(req, body)
101
+ return d if d.is_a?(HTTPResponse)
102
+
103
+ channel = d.fetch("channel", "")
104
+ unless channel.is_a?(String)
105
+ return failure(req, 400,
106
+ ArgumentError.new("malformed request: channel is not a string"))
107
+ end
108
+ else return HTTPResponse.allow("GET, POST")
109
+ end
110
+ return failure(req, 400, ArgumentError.new("subscription token request without a channel")) if channel.empty?
111
+
112
+ [channel]
113
+ end
114
+
115
+ def finish(req, _args, out, error) = token(req, out, error)
116
+ end
117
+
118
+ # The endpoint a shared poll subscription's getSignature callback calls: a
119
+ # POST of +{"channel": ..., "keys": [...]}+, answered +{"signature": ...,
120
+ # "keys": [...]}+.
121
+ #
122
+ # Its block, given the channel, the keys and the {HTTPRequest}, decides
123
+ # which of the keys the user may track in the channel, signs them - with
124
+ # {SharedPoll.sign}, say - and returns them, in the order signed, with the
125
+ # signature: +[allowed, signature]+. The keys it leaves out are revoked:
126
+ # the client stops tracking them. To end the subscription instead, it
127
+ # raises ForbiddenError. It may return only keys it was asked for: a
128
+ # signature covering another would be refused, and such a key fails the
129
+ # request with 500.
130
+ class SharedPollSignature < Endpoint
131
+ private
132
+
133
+ def start(req, body)
134
+ return HTTPResponse.allow("POST") unless req.method == "POST"
135
+
136
+ d = parse_json(req, body)
137
+ return d if d.is_a?(HTTPResponse)
138
+
139
+ channel = d.fetch("channel", "")
140
+ keys = d["keys"] || []
141
+ unless channel.is_a?(String) && keys.is_a?(Array) && keys.all?(String)
142
+ return failure(req, 400, ArgumentError.new("malformed request: channel and keys must be strings"))
143
+ end
144
+ return failure(req, 400, ArgumentError.new("signature request without a channel")) if channel.empty?
145
+
146
+ [channel, keys]
147
+ end
148
+
149
+ def finish(req, args, out, error)
150
+ return answer(req, nil, error) if error
151
+
152
+ allowed, signature = out if out.is_a?(Array) && out.size == 2
153
+ unless signature.is_a?(String) && allowed.is_a?(Array) && allowed.all?(String)
154
+ return answer(req, nil, TypeError.new("the block must return [keys, signature]"))
155
+ end
156
+
157
+ extra = allowed - args[1]
158
+ unless extra.empty?
159
+ return answer(req, nil,
160
+ ArgumentError.new("signed key #{extra.first.inspect} the client did not ask for"))
161
+ end
162
+
163
+ # The SDK reads the keys as an array, empty included.
164
+ answer(req, { "signature" => signature, "keys" => allowed }, nil)
165
+ end
166
+ end
167
+
168
+ # Serves the JSON Web Key Set of +keys+ - see {Token.jwks} - at the URL
169
+ # configured as Centrifugo's client.token.jwks_public_endpoint, or PRO's
170
+ # http_api.jwks.endpoint. It answers GET and HEAD, cacheable for five
171
+ # minutes. A set Token.jwks refuses fails here, when the endpoint is built.
172
+ class JWKS < Handler
173
+ # @param keys [Array<Token::Key>]
174
+ # @raise [Token::Error]
175
+ def initialize(*keys)
176
+ super(MAX_BODY, nil)
177
+ @body = Token.jwks(*keys)
178
+ end
179
+
180
+ private
181
+
182
+ def answers_itself? = true
183
+
184
+ def start(req, _body)
185
+ return HTTPResponse.allow("GET, HEAD") unless %w[GET HEAD].include?(req.method)
186
+
187
+ HTTPResponse.json(req.method == "GET" ? @body : "", "public, max-age=300")
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # How many failures an aggregate error names in its message; all of them stay
5
+ # available in the error itself.
6
+ MAX_ERROR_ITEMS = 3
7
+ private_constant :MAX_ERROR_ITEMS
8
+
9
+ # The base of every error cent raises.
10
+ class Error < StandardError
11
+ # @return [String, nil] set on the error broadcast_with_retry raises when no
12
+ # attempt got a reply - a timeout, say - to the key its attempts carried,
13
+ # made for the call when the request had none: Centrifugo may have
14
+ # published, and a retry with this key does not publish twice. Nil on
15
+ # other errors.
16
+ attr_accessor :idempotency_key
17
+ end
18
+
19
+ # An error of Centrifugo itself: it understood the request and refused it, or
20
+ # failed to carry it out.
21
+ #
22
+ # Centrifugo identifies an error by its {#code}; {#server_message} is for
23
+ # people and may change. Errors with a known code are raised as the subclass
24
+ # for it - +rescue Cent::UnknownChannelError+ - and any other code as ApiError
25
+ # itself: Centrifugo adds codes from time to time, so rescue the ones you
26
+ # handle and treat the rest by their class.
27
+ class ApiError < Error
28
+ # @return [Integer] the error code: stable, compare it
29
+ attr_reader :code
30
+ # @return [String] Centrifugo's message, for people: never compare it
31
+ attr_reader :server_message
32
+
33
+ # Whether Centrifugo marked the error temporary: the same request may
34
+ # succeed if retried later - a broker being unavailable, a rate limit - not
35
+ # because of the request. It does not mean nothing was done: a publication
36
+ # may have happened, so retry it with the same idempotency_key. Centrifugo
37
+ # versions before the field existed never set it; {Cent.retryable?} takes
38
+ # that into account.
39
+ #
40
+ # @return [Boolean]
41
+ def temporary? = @temporary
42
+
43
+ # @param code [Integer]
44
+ # @param server_message [String]
45
+ # @param temporary [Boolean]
46
+ def initialize(code, server_message = "", temporary: false)
47
+ @code = code
48
+ @server_message = server_message
49
+ @temporary = temporary
50
+ @pro_method = nil
51
+ super(nil)
52
+ end
53
+
54
+ # @return [String]
55
+ def to_s
56
+ s = "#{server_message.empty? ? "server error" : server_message} (code #{code})"
57
+ s += " (#{@pro_method} is a Centrifugo PRO method: is the server Centrifugo OSS?)" if @pro_method
58
+ s
59
+ end
60
+
61
+ # The error for a code: the subclass of a known code, ApiError otherwise.
62
+ #
63
+ # @return [ApiError]
64
+ def self.for_code(code, server_message = "", temporary: false)
65
+ (BY_CODE[code] || ApiError).new(code, server_message, temporary:)
66
+ end
67
+
68
+ # @api private
69
+ def pro_method!(method) = (@pro_method = method)
70
+ end
71
+
72
+ # Code 100: an internal error of Centrifugo, temporary.
73
+ class InternalError < ApiError
74
+ # The error code.
75
+ CODE = 100
76
+ end
77
+
78
+ # Code 102: the channel is not in a namespace Centrifugo is configured with.
79
+ class UnknownChannelError < ApiError
80
+ # The error code.
81
+ CODE = 102
82
+ end
83
+
84
+ # Code 104: what the request refers to was not found.
85
+ #
86
+ # A method only Centrifugo PRO has, sent in a batch to Centrifugo OSS, fails
87
+ # with it too, instead of the 404 it gets when sent alone.
88
+ class NotFoundError < ApiError
89
+ # The error code.
90
+ CODE = 104
91
+ end
92
+
93
+ # Code 107: Centrifugo refused the request as invalid.
94
+ class BadRequestError < ApiError
95
+ # The error code.
96
+ CODE = 107
97
+ end
98
+
99
+ # Code 108: the feature is not available - history or presence off in the
100
+ # channel's namespace, say.
101
+ class NotAvailableError < ApiError
102
+ # The error code.
103
+ CODE = 108
104
+ end
105
+
106
+ # Code 111: a rate limit was reached, temporary.
107
+ class TooManyRequestsError < ApiError
108
+ # The error code.
109
+ CODE = 111
110
+ end
111
+
112
+ # Code 112: the stream position asked for is not in the history any more.
113
+ class UnrecoverablePositionError < ApiError
114
+ # The error code.
115
+ CODE = 112
116
+ end
117
+
118
+ # Code 113: the request conflicts with the current state - a map publish
119
+ # whose version check failed, say.
120
+ class ConflictError < ApiError
121
+ # The error code.
122
+ CODE = 113
123
+ end
124
+
125
+ class ApiError
126
+ # @api private
127
+ BY_CODE = [
128
+ InternalError, UnknownChannelError, NotFoundError, BadRequestError,
129
+ NotAvailableError, TooManyRequestsError, UnrecoverablePositionError, ConflictError
130
+ ].to_h { [it::CODE, it] }.freeze
131
+ end
132
+
133
+ # A reply with an HTTP status other than 200: the request did not reach the
134
+ # API method. A 401 is an {UnauthorizedError}.
135
+ class HTTPError < Error
136
+ # @return [Integer]
137
+ attr_reader :status_code
138
+ # @return [String] the start of the reply's body, for diagnostics
139
+ attr_reader :body
140
+ # @return [Hash{String => String}] the reply's headers, lowercase names: a
141
+ # request ID a proxy set, a +retry-after+
142
+ attr_reader :headers
143
+
144
+ # @param status_code [Integer]
145
+ # @param body [String]
146
+ # @param headers [Hash{String => String}]
147
+ def initialize(status_code, body = "", headers = {})
148
+ @status_code = status_code
149
+ @body = body
150
+ @headers = headers
151
+ @pro_method = nil
152
+ super(nil)
153
+ end
154
+
155
+ # @return [String]
156
+ def to_s
157
+ s = "unexpected HTTP status #{status_code}"
158
+ if (300..399).cover?(status_code)
159
+ # Centrifugo's API never redirects a call: its router redirects only
160
+ # a path not in canonical form, a doubled slash say, and a proxy in
161
+ # front of it may redirect http to https.
162
+ s += " (a redirect, which cent does not follow: check api_endpoint's scheme and path - http or https, " \
163
+ "a doubled slash)"
164
+ elsif status_code == 404 && @pro_method
165
+ s += " (#{@pro_method} is a Centrifugo PRO method: if other methods work, the server is Centrifugo OSS)"
166
+ elsif status_code == 404
167
+ # The usual cause: api_endpoint lacks the API prefix.
168
+ s += " (is api_endpoint the API base URL, such as http://localhost:8000/api?)"
169
+ elsif status_code == 401
170
+ s += " (check the credentials: api_key or bearer_token_func)"
171
+ end
172
+ s += ": #{body.dup.force_encoding(Encoding::UTF_8).scrub}" unless body.empty?
173
+ s
174
+ end
175
+
176
+ # @api private
177
+ def pro_method!(method) = (@pro_method = method)
178
+ end
179
+
180
+ # A 401: Centrifugo did not accept the credentials - the API key or the bearer
181
+ # token.
182
+ class UnauthorizedError < HTTPError; end
183
+
184
+ # The reply is not a Centrifugo API reply. The usual cause is an api_endpoint
185
+ # which does not point at Centrifugo's API, so that something else - a proxy,
186
+ # a web page - answers instead.
187
+ class DecodeError < Error; end
188
+
189
+ # A request the client can tell is wrong without sending it: one which cannot
190
+ # be encoded - a payload which is not JSON, in any request - or a publication
191
+ # without a channel or data. It is never sent.
192
+ #
193
+ # Only rules which hold for every Centrifugo are checked; everything else
194
+ # Centrifugo checks itself, and reports as a {BadRequestError}.
195
+ class InvalidRequestError < Error; end
196
+
197
+ # A client refused as configured: not created, or - on a call, before
198
+ # sending - given an endpoint by api_endpoint_func which fails api_endpoint's
199
+ # rules, or a token by bearer_token_func which is empty or not printable
200
+ # ASCII. The message names the option and the fix.
201
+ class InvalidConfigError < Error; end
202
+
203
+ # api_endpoint_func or bearer_token_func raised: the message names the
204
+ # function, and the cause is its exception. {Cent.retryable?} judges it by
205
+ # the cause: a Cent error as itself, a network failure or timeout as
206
+ # retryable, anything else as not.
207
+ class FunctionError < Error
208
+ # @return [String] the option holding the function: "api_endpoint_func" or
209
+ # "bearer_token_func"
210
+ attr_reader :function
211
+
212
+ # @param function [String] the option holding the function
213
+ # @param message [String]
214
+ def initialize(function, message = "#{function} failed")
215
+ @function = function
216
+ super(message)
217
+ end
218
+ end
219
+
220
+ # A call made after the client was closed.
221
+ class ClosedError < Error; end
222
+
223
+ # The request failed on its way: a refused or reset connection, a failed TLS
224
+ # handshake. The cause is the error of the network.
225
+ class NetworkError < Error; end
226
+
227
+ # The request timed out: its timeout, or request_timeout. A request which
228
+ # timed out may still have been carried out.
229
+ class TimeoutError < Error; end
230
+
231
+ # A pending command's result read before its batch was sent.
232
+ class BatchNotSentError < Error; end
233
+
234
+ # A batch sent again: a batch is sent once.
235
+ class BatchSentError < Error; end
236
+
237
+ # A broadcast Centrifugo carried out which failed in one or more of its
238
+ # channels, up to all of them. {#result} holds every channel's outcome.
239
+ #
240
+ # It is not an ApiError of its parts: +rescue Cent::UnknownChannelError+ does
241
+ # not catch a broadcast in which one channel of many was unknown, nor one into
242
+ # a single channel - the broadcast as a whole is not unknown. Look at each
243
+ # channel's error in {#failed}.
244
+ class BroadcastFailedChannelsError < Error
245
+ # @return [Array<ChannelResult>] the channels which failed, with their
246
+ # errors, in the order of the request
247
+ attr_reader :failed
248
+ # @return [Integer] how many channels the broadcast had
249
+ attr_reader :total
250
+ # @return [BroadcastResult] every channel's outcome
251
+ attr_reader :result
252
+
253
+ # For test doubles, say:
254
+ #
255
+ # raise Cent::BroadcastFailedChannelsError.new(result.failed, result.channels.size, result)
256
+ #
257
+ # @param failed [Array<ChannelResult>]
258
+ # @param total [Integer]
259
+ # @param result [BroadcastResult]
260
+ def initialize(failed, total, result)
261
+ @failed = failed
262
+ @total = total
263
+ @result = result
264
+ super(nil)
265
+ end
266
+
267
+ # The channels whose failure is retryable, in the order of the request: the
268
+ # channels a retry should go to, with the same idempotency_key. Channels
269
+ # which failed for good, such as an unknown channel, are left out.
270
+ #
271
+ # @return [Array<String>]
272
+ def retryable_channels = failed.filter_map { it.channel if Cent.retryable?(it.error) }
273
+
274
+ # @return [String]
275
+ def to_s
276
+ items = failed.map { |ch| [ch.channel, ch.error] }
277
+ msg = Cent.failures_message("broadcast", "channels", "got the publication", total, items)
278
+ msg += " (retry only retryable_channels, with the same idempotency_key)" unless retryable_channels.empty?
279
+ msg
280
+ end
281
+ end
282
+
283
+ # The failure of one command of a batch.
284
+ #
285
+ # @!attribute [r] index
286
+ # @return [Integer] the command's position in the batch, from zero
287
+ # @!attribute [r] method
288
+ # @return [String] its API method, such as "publish"
289
+ # @!attribute [r] error
290
+ # @return [Error] the error its pending raises: an ApiError, or a
291
+ # BroadcastFailedChannelsError for a broadcast which failed in one or more
292
+ # channels
293
+ class FailedCommand < Data.define(:index, :method, :error)
294
+ include Message
295
+ end
296
+
297
+ # A batch in which one or more commands failed, up to all of them. Every
298
+ # command got its own reply: each pending holds its outcome. {#result} is the
299
+ # batch's.
300
+ #
301
+ # It is not an ApiError of its parts: look at each command's error in
302
+ # {#failed}, or at its pending.
303
+ class BatchFailedCommandsError < Error
304
+ # @return [Array<FailedCommand>]
305
+ attr_reader :failed
306
+ # @return [Integer] how many commands the batch had
307
+ attr_reader :total
308
+ # @return [BatchResult]
309
+ attr_reader :result
310
+
311
+ # For test doubles, say.
312
+ #
313
+ # @param failed [Array<FailedCommand>]
314
+ # @param total [Integer]
315
+ # @param result [BatchResult]
316
+ def initialize(failed, total, result)
317
+ @failed = failed
318
+ @total = total
319
+ @result = result
320
+ super(nil)
321
+ end
322
+
323
+ # @return [String]
324
+ def to_s
325
+ items = failed.map { |f| ["##{f.index} #{f.method}", f.error] }
326
+ Cent.failures_message("batch", "commands", "succeeded", total, items)
327
+ end
328
+ end
329
+
330
+ # "broadcast: 2 of 4 channels failed, 2 got the publication: a: ...; b: ..."
331
+ #
332
+ # @api private
333
+ def self.failures_message(op, parts, rest, total, items)
334
+ s = "#{op}: #{items.size} of #{total} #{parts} failed"
335
+ s += ", #{total - items.size} #{rest}" if items.size < total
336
+ named = items.first(MAX_ERROR_ITEMS).map { |label, err| "#{label}: #{err.message}" }
337
+ named << "and #{items.size - MAX_ERROR_ITEMS} more" if items.size > MAX_ERROR_ITEMS
338
+ s += ": #{named.join("; ")}" unless named.empty?
339
+ s
340
+ end
341
+
342
+ # Whether a new attempt, with time left, may succeed after +err+: the error
343
+ # came from a temporary condition, not from the request.
344
+ #
345
+ # Retryable are an ApiError Centrifugo marked temporary, and code 100 from
346
+ # Centrifugo versions which did not mark errors yet; a network failure; a
347
+ # timeout, also a caller's own; an HTTPError 429 or 5xx; a broadcast or batch
348
+ # partial error with at least one retryable part.
349
+ #
350
+ # A FunctionError is judged by its cause, the configured function's own
351
+ # exception.
352
+ #
353
+ # Not retryable are the request's own errors - an unknown channel, a bad
354
+ # request, InvalidRequestError - and InvalidConfigError, ClosedError, a
355
+ # DecodeError, rejected credentials and an invalid TLS certificate.
356
+ #
357
+ # A retryable error does not mean nothing was done: retry a publication only
358
+ # with the same idempotency_key. Retry a broadcast with broadcast_with_retry,
359
+ # or only its retryable_channels: repeating the call resends to the channels
360
+ # which already got it.
361
+ #
362
+ # @param err [Exception, nil]
363
+ # @return [Boolean]
364
+ def self.retryable?(err)
365
+ case err
366
+ when BroadcastFailedChannelsError, BatchFailedCommandsError then err.failed.any? { retryable?(it.error) }
367
+ when ApiError then err.temporary? || err.code == InternalError::CODE
368
+ when HTTPError then err.status_code == 429 || err.status_code >= 500
369
+ when TimeoutError then true
370
+ when NetworkError then !certificate_error?(err)
371
+ when FunctionError
372
+ cause = err.cause
373
+ cause.is_a?(Error) ? retryable?(cause) : NETWORK_CAUSES.any? { cause.is_a?(it) }
374
+ else false
375
+ end
376
+ end
377
+
378
+ # The exceptions of a configured function which a retry may get past.
379
+ NETWORK_CAUSES = [SocketError, SystemCallError, IOError, ::Timeout::Error, IO::TimeoutError].freeze
380
+ private_constant :NETWORK_CAUSES
381
+
382
+ def self.certificate_error?(err)
383
+ cause = err.cause
384
+ while cause
385
+ return true if cause.is_a?(OpenSSL::SSL::SSLError) && cause.message.include?("certificate verify failed")
386
+
387
+ cause = cause.cause
388
+ end
389
+ false
390
+ end
391
+ private_class_method :certificate_error?
392
+ end