cent 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/MIGRATING.md +305 -0
- data/README.md +702 -155
- data/lib/cent/auto_batch.rb +305 -0
- data/lib/cent/batch.rb +209 -0
- data/lib/cent/broadcast.rb +117 -0
- data/lib/cent/client.rb +602 -216
- data/lib/cent/command.rb +64 -0
- data/lib/cent/endpoints.rb +191 -0
- data/lib/cent/errors.rb +392 -0
- data/lib/cent/generated/methods.rb +4226 -0
- data/lib/cent/generated/proxy_types.rb +1834 -0
- data/lib/cent/generated/types.rb +2844 -0
- data/lib/cent/handler.rb +179 -0
- data/lib/cent/http.rb +251 -0
- data/lib/cent/message.rb +52 -0
- data/lib/cent/pages.rb +165 -0
- data/lib/cent/payload.rb +93 -0
- data/lib/cent/proxy.rb +254 -0
- data/lib/cent/reply.rb +109 -0
- data/lib/cent/retry.rb +53 -0
- data/lib/cent/shared_poll.rb +61 -0
- data/lib/cent/token.rb +560 -0
- data/lib/cent/version.rb +2 -1
- data/lib/cent/wire.rb +222 -0
- data/lib/cent.rb +33 -5
- metadata +34 -44
- data/Gemfile +0 -15
- data/Rakefile +0 -12
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/cent.gemspec +0 -36
- data/lib/cent/error.rb +0 -46
- data/lib/cent/notary.rb +0 -105
data/lib/cent/command.rb
ADDED
|
@@ -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
|
data/lib/cent/errors.rb
ADDED
|
@@ -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
|