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/payload.rb
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# Payloads - data, info, meta and the like - are JSON values: a Hash, Array,
|
|
5
|
+
# String, Integer, Float, true, false or nil, Symbols allowed as strings and
|
|
6
|
+
# Hash keys. Raw JSON - text you already have, or encoded with rules of your
|
|
7
|
+
# own - goes in a +JSON::Fragment+, sent as it is:
|
|
8
|
+
#
|
|
9
|
+
# client.publish(channel: "news", data: { text: "hi" })
|
|
10
|
+
# client.publish(channel: "news", data: JSON::Fragment.new(row["payload_json"]))
|
|
11
|
+
#
|
|
12
|
+
# A String is a JSON string, never raw JSON. Replies give payloads as
|
|
13
|
+
# +JSON.parse+ gives them - Hashes with String keys - which go back unchanged
|
|
14
|
+
# as the same JSON value.
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
module Payload
|
|
18
|
+
# Deeper than this, a payload is refused: Centrifugo's decoder stops at
|
|
19
|
+
# 10000 levels, and a value which contains itself never ends.
|
|
20
|
+
MAX_DEPTH = 1000
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Checks that a payload is a JSON value, raising InvalidRequestError naming
|
|
25
|
+
# where it is not. The checked value is sent as it is.
|
|
26
|
+
#
|
|
27
|
+
# @return [Object] value
|
|
28
|
+
def check!(value, where)
|
|
29
|
+
# Iterative, not recursive: a deep value must not overflow the stack,
|
|
30
|
+
# which is small on some platforms - a thread's on Windows.
|
|
31
|
+
stack = [[value, where, 0]]
|
|
32
|
+
until stack.empty?
|
|
33
|
+
v, path, depth = stack.pop
|
|
34
|
+
raise InvalidRequestError, "#{path} is nested too deeply, or contains itself" if depth > MAX_DEPTH
|
|
35
|
+
|
|
36
|
+
case v
|
|
37
|
+
when Hash
|
|
38
|
+
v.each do |k, item|
|
|
39
|
+
key!(k, path)
|
|
40
|
+
stack << [item, "#{path}.#{k}", depth + 1]
|
|
41
|
+
end
|
|
42
|
+
when Array then v.each_with_index { |item, i| stack << [item, "#{path}[#{i}]", depth + 1] }
|
|
43
|
+
else scalar!(v, path)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def key!(k, path)
|
|
50
|
+
case k
|
|
51
|
+
when String then string!(k, "#{path} key #{k.inspect}")
|
|
52
|
+
when Symbol then nil
|
|
53
|
+
else raise InvalidRequestError, "#{path} has a #{k.class} key, #{k.inspect}: JSON object keys are strings"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def scalar!(v, path)
|
|
58
|
+
case v
|
|
59
|
+
when String then string!(v, path)
|
|
60
|
+
when Float
|
|
61
|
+
raise InvalidRequestError, "#{path} is #{v}, which is not a JSON number" unless v.finite?
|
|
62
|
+
when Integer, true, false, nil, Symbol then nil
|
|
63
|
+
when JSON::Fragment then fragment!(v, path)
|
|
64
|
+
else
|
|
65
|
+
raise InvalidRequestError,
|
|
66
|
+
"#{path} is a #{v.class}, which is not a JSON value: convert it - " \
|
|
67
|
+
"or encode the payload yourself and pass the JSON as JSON::Fragment.new(json)"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def string!(s, where)
|
|
72
|
+
if s.encoding == Encoding::BINARY
|
|
73
|
+
raise InvalidRequestError,
|
|
74
|
+
"#{where} is a binary (ASCII-8BIT) string: force_encoding(\"UTF-8\") text read as bytes, " \
|
|
75
|
+
"or send binary data base64-encoded in a b64 field, such as b64data"
|
|
76
|
+
end
|
|
77
|
+
raise InvalidRequestError, "#{where} is not valid #{s.encoding}" unless s.valid_encoding?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Raw JSON is checked to be valid JSON, NaN and infinities refused.
|
|
81
|
+
def fragment!(f, where)
|
|
82
|
+
json = f.json
|
|
83
|
+
unless json.is_a?(String)
|
|
84
|
+
raise InvalidRequestError, "#{where} is a JSON::Fragment of a #{json.class}: it holds JSON text"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
string!(json, where)
|
|
88
|
+
JSON.parse(json, allow_nan: false, max_nesting: false)
|
|
89
|
+
rescue JSON::ParserError => e
|
|
90
|
+
raise InvalidRequestError, "#{where} is a JSON::Fragment which is not valid JSON: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/cent/proxy.rb
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# The application side of Centrifugo's HTTP proxy: the requests Centrifugo
|
|
5
|
+
# sends to the backend when a client connects, subscribes, publishes or calls
|
|
6
|
+
# an RPC, the results the backend answers with, and a handler for each proxy
|
|
7
|
+
# type which decodes the request, calls a block and encodes its answer. Each
|
|
8
|
+
# handler is a {Handler}: a Rack app, with a framework-free core.
|
|
9
|
+
#
|
|
10
|
+
# connect = Cent::Proxy::ConnectHandler.new(on_error: ->(_http, err) { logger.error(err) }) do |req, http|
|
|
11
|
+
# user = session_user(http.headers["cookie"]) # passed on with http_headers
|
|
12
|
+
# raise Cent::Proxy::Disconnect.new(4501, "unauthorized") unless user
|
|
13
|
+
#
|
|
14
|
+
# Cent::Proxy::ConnectResult.new(user:) # or a Hash: { user: }
|
|
15
|
+
# end
|
|
16
|
+
# mount connect, at: "/centrifugo/connect" # config/routes.rb
|
|
17
|
+
#
|
|
18
|
+
# and in Centrifugo's configuration:
|
|
19
|
+
#
|
|
20
|
+
# "client": {"proxy": {"connect": {"enabled": true,
|
|
21
|
+
# "endpoint": "http://backend:3000/centrifugo/connect", "http_headers": ["Cookie"]}}}
|
|
22
|
+
#
|
|
23
|
+
# See https://centrifugal.dev/docs/server/proxy.
|
|
24
|
+
#
|
|
25
|
+
# The block gets the decoded request and the {HTTPRequest}, and returns a
|
|
26
|
+
# result - the result type, or a Hash of its fields - to accept the request,
|
|
27
|
+
# or raises:
|
|
28
|
+
#
|
|
29
|
+
# - {ProxyError} rejects the request with that error, which the client
|
|
30
|
+
# receives: use codes in [400, 1999]. The connect, subscribe, publish, RPC,
|
|
31
|
+
# map publish and map remove proxies pass it to the client; the shared poll
|
|
32
|
+
# refresh and channel state proxies take it as a failure.
|
|
33
|
+
# - {Disconnect} closes the client's connection with that code: use codes in
|
|
34
|
+
# [4000, 4999] - [4500, 4999] to tell the client not to reconnect. Only the
|
|
35
|
+
# proxies which pass a ProxyError to the client honour it.
|
|
36
|
+
# - Any other error, or one a proxy cannot carry, fails the request with HTTP
|
|
37
|
+
# status 500, and is passed to +on_error+ for logging - without +on_error+,
|
|
38
|
+
# it is logged nowhere. Centrifugo then
|
|
39
|
+
# answers the client with its temporary internal error; for refresh and sub
|
|
40
|
+
# refresh it keeps the connection or subscription for another minute and
|
|
41
|
+
# asks again.
|
|
42
|
+
#
|
|
43
|
+
# A ProxyError or Disconnect which is the cause of another error counts too.
|
|
44
|
+
# The refresh and sub refresh proxies end a connection or subscription
|
|
45
|
+
# through their result's expired, not an error. A handler answers with
|
|
46
|
+
# exactly one of result, error and disconnect - a block returning nil fails
|
|
47
|
+
# with 500 too. Centrifugo would take a response with none of them as
|
|
48
|
+
# accepting a subscribe, publish, RPC, map publish or map remove as the
|
|
49
|
+
# client sent it; as refusing a connect, unless anonymous connections are
|
|
50
|
+
# allowed; and as a refresh or sub refresh which expired.
|
|
51
|
+
#
|
|
52
|
+
# Payloads - data, info, meta - arrive as JSON.parse gives them, and a result
|
|
53
|
+
# takes them back as they are, or as JSON::Fragment. A proxy with
|
|
54
|
+
# binary_encoding enabled sends them base64-encoded in the b64 fields, with
|
|
55
|
+
# encoding "binary"; a result may answer with either field.
|
|
56
|
+
#
|
|
57
|
+
# Security: Centrifugo trusts what a proxy endpoint answers, and the endpoint
|
|
58
|
+
# trusts what Centrifugo sends - a SubscribeRequest's user is the user
|
|
59
|
+
# Centrifugo authenticated. Anyone else able to reach the endpoint could claim
|
|
60
|
+
# any user. Keep proxy endpoints reachable by Centrifugo only: on a network
|
|
61
|
+
# only Centrifugo reaches; behind a secret header Centrifugo adds to every
|
|
62
|
+
# proxy request - the proxy's static_headers option - which the application
|
|
63
|
+
# checks before the handler; with TLS where the traffic leaves a trusted
|
|
64
|
+
# network.
|
|
65
|
+
#
|
|
66
|
+
# Centrifugo's GRPC proxy is not covered: generate GRPC code from proxy.proto
|
|
67
|
+
# for it.
|
|
68
|
+
module Proxy
|
|
69
|
+
# The default bound of a request's body, 16 MiB: far above what
|
|
70
|
+
# Centrifugo's own limits on client messages let through, it only stops a
|
|
71
|
+
# request which is not Centrifugo's from taking the memory it likes.
|
|
72
|
+
DEFAULT_MAX_BODY = 16 << 20
|
|
73
|
+
|
|
74
|
+
# Raised by a proxy block to reject the client's request with this error:
|
|
75
|
+
# code in [400, 1999], which Centrifugo leaves to applications. temporary
|
|
76
|
+
# tells the client that retrying may succeed.
|
|
77
|
+
class ProxyError < StandardError
|
|
78
|
+
# @return [Integer]
|
|
79
|
+
attr_reader :code
|
|
80
|
+
# @return [String]
|
|
81
|
+
attr_reader :reason
|
|
82
|
+
|
|
83
|
+
# @param code [Integer]
|
|
84
|
+
# @param reason [String] the error's message, sent to the client
|
|
85
|
+
# @param temporary [Boolean]
|
|
86
|
+
def initialize(code, reason = "", temporary: false)
|
|
87
|
+
@code = code
|
|
88
|
+
@reason = reason
|
|
89
|
+
@temporary = temporary
|
|
90
|
+
super("#{reason} (code #{code})")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def temporary? = @temporary
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Raised by a proxy block to close the client's connection: code in [4000,
|
|
98
|
+
# 4999] - [4000, 4499] tells the client to reconnect, [4500, 4999] not to.
|
|
99
|
+
# reason is a short description, at most 32 bytes.
|
|
100
|
+
class Disconnect < StandardError
|
|
101
|
+
# @return [Integer]
|
|
102
|
+
attr_reader :code
|
|
103
|
+
# @return [String]
|
|
104
|
+
attr_reader :reason
|
|
105
|
+
|
|
106
|
+
# @param code [Integer]
|
|
107
|
+
# @param reason [String]
|
|
108
|
+
def initialize(code, reason = "")
|
|
109
|
+
@code = code
|
|
110
|
+
@reason = reason
|
|
111
|
+
super("disconnect: #{reason} (code #{code})")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @api private
|
|
116
|
+
class ProxyHandler < Handler
|
|
117
|
+
# @param on_error [#call, nil] told of every 500 and 400, with the
|
|
118
|
+
# {HTTPRequest} and the error; it must not raise. Pass one: without it,
|
|
119
|
+
# an error in the block is answered with 500 and logged nowhere.
|
|
120
|
+
# @param max_body [Integer] the largest request body read, in bytes
|
|
121
|
+
def initialize(on_error: nil, max_body: DEFAULT_MAX_BODY, &) = super(max_body, on_error, &)
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def start(req, body)
|
|
126
|
+
return HTTPResponse.allow("POST") unless req.method == "POST"
|
|
127
|
+
|
|
128
|
+
name, request_class, = self.class::SPEC
|
|
129
|
+
[request_class.from_wire(JSON.parse(body, allow_nan: false, max_nesting: false), "#{name} request")]
|
|
130
|
+
rescue JSON::ParserError, EncodingError, DecodeError => e
|
|
131
|
+
failure(req, 400, ArgumentError.new("proxy: malformed #{name} request: #{e.message}"))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def finish(req, _args, out, error)
|
|
135
|
+
name, _, result_class, answers = self.class::SPEC
|
|
136
|
+
if error.nil?
|
|
137
|
+
begin
|
|
138
|
+
wire = { "result" => Wire.message(result_class, out, "result").to_wire("result.") }
|
|
139
|
+
rescue InvalidRequestError => e
|
|
140
|
+
return failure(req, 500, TypeError.new("proxy: the #{name} block returned an invalid result: #{e.message}"))
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
d = find(error, Disconnect)
|
|
144
|
+
e = find(error, ProxyError)
|
|
145
|
+
if d && answers == :disconnect
|
|
146
|
+
wire = { "disconnect" => { "code" => d.code, "reason" => d.reason } }
|
|
147
|
+
elsif e && answers != :result
|
|
148
|
+
wire = { "error" => { "code" => e.code, "message" => e.reason } }
|
|
149
|
+
wire["error"]["temporary"] = true if e.temporary?
|
|
150
|
+
elsif d || e
|
|
151
|
+
# Centrifugo would ignore it in this proxy's response.
|
|
152
|
+
return failure(req, 500, ArgumentError.new("proxy: the #{name} proxy cannot answer with #{error.inspect}"))
|
|
153
|
+
else
|
|
154
|
+
return failure(req, 500, error)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
HTTPResponse.new(status: 200, headers: { "content-type" => "application/json" }, body: Client.encode(wire))
|
|
158
|
+
rescue InvalidRequestError => e
|
|
159
|
+
# A payload which is not JSON, say: Centrifugo could not use the
|
|
160
|
+
# response.
|
|
161
|
+
failure(req, 500, e)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The connect proxy: the block authenticates a connecting client and
|
|
166
|
+
# returns its user and initial state. ProxyError rejects the connection;
|
|
167
|
+
# Disconnect closes it with that code.
|
|
168
|
+
class ConnectHandler < ProxyHandler
|
|
169
|
+
# @api private
|
|
170
|
+
SPEC = ["connect", ConnectRequest, ConnectResult, :disconnect].freeze
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The refresh proxy: the block decides whether a connection about to expire
|
|
174
|
+
# lives on, and until when. To end it, return a result with expired set.
|
|
175
|
+
# Any error fails the request, and Centrifugo keeps the connection for
|
|
176
|
+
# another minute before asking again.
|
|
177
|
+
class RefreshHandler < ProxyHandler
|
|
178
|
+
# @api private
|
|
179
|
+
SPEC = ["refresh", RefreshRequest, RefreshResult, :result].freeze
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The subscribe proxy: the block decides whether a client may subscribe to
|
|
183
|
+
# a channel. ProxyError rejects the subscription; Disconnect closes the
|
|
184
|
+
# connection.
|
|
185
|
+
class SubscribeHandler < ProxyHandler
|
|
186
|
+
# @api private
|
|
187
|
+
SPEC = ["subscribe", SubscribeRequest, SubscribeResult, :disconnect].freeze
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The publish proxy: the block decides whether a client may publish into a
|
|
191
|
+
# channel, and may change the publication. ProxyError rejects it;
|
|
192
|
+
# Disconnect closes the connection.
|
|
193
|
+
class PublishHandler < ProxyHandler
|
|
194
|
+
# @api private
|
|
195
|
+
SPEC = ["publish", PublishRequest, PublishResult, :disconnect].freeze
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The RPC proxy: the block answers a client's RPC call. ProxyError fails the
|
|
199
|
+
# call; Disconnect closes the connection.
|
|
200
|
+
class RPCHandler < ProxyHandler
|
|
201
|
+
# @api private
|
|
202
|
+
SPEC = ["rpc", RPCRequest, RPCResult, :disconnect].freeze
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The sub refresh proxy: the block decides whether a subscription about to
|
|
206
|
+
# expire lives on, and until when. To end it, return a result with expired
|
|
207
|
+
# set. Any error fails the request, and Centrifugo keeps the subscription
|
|
208
|
+
# for another minute before asking again.
|
|
209
|
+
class SubRefreshHandler < ProxyHandler
|
|
210
|
+
# @api private
|
|
211
|
+
SPEC = ["sub refresh", SubRefreshRequest, SubRefreshResult, :result].freeze
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The map publish proxy: the block decides whether a client may publish a
|
|
215
|
+
# key into a map channel, and may change the publication.
|
|
216
|
+
class MapPublishHandler < ProxyHandler
|
|
217
|
+
# @api private
|
|
218
|
+
SPEC = ["map publish", MapPublishRequest, MapPublishResult, :disconnect].freeze
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The map remove proxy: the block decides whether a client may remove a key
|
|
222
|
+
# from a map channel.
|
|
223
|
+
class MapRemoveHandler < ProxyHandler
|
|
224
|
+
# @api private
|
|
225
|
+
SPEC = ["map remove", MapRemoveRequest, MapRemoveResult, :disconnect].freeze
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The shared poll refresh proxy: the block returns the current data of the
|
|
229
|
+
# items tracked in a shared poll channel. ProxyError, or any other error,
|
|
230
|
+
# fails the refresh: Centrifugo tries again at the next interval.
|
|
231
|
+
class SharedPollRefreshHandler < ProxyHandler
|
|
232
|
+
# @api private
|
|
233
|
+
SPEC = ["shared poll refresh", SharedPollRefreshRequest, SharedPollRefreshResult, :error].freeze
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The cache empty proxy of Centrifugo PRO: the block learns that a client
|
|
237
|
+
# subscribed to a channel in cache recovery mode with nothing to recover,
|
|
238
|
+
# and may publish the current state - then it returns a result with
|
|
239
|
+
# populated set, for Centrifugo to recover once more. Any error fails the
|
|
240
|
+
# request, and the client which subscribed is disconnected to reconnect.
|
|
241
|
+
class NotifyCacheEmptyHandler < ProxyHandler
|
|
242
|
+
# @api private
|
|
243
|
+
SPEC = ["cache empty", NotifyCacheEmptyRequest, NotifyCacheEmptyResult, :result].freeze
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# The channel state proxy of Centrifugo PRO, in preview: the block receives
|
|
247
|
+
# channels becoming occupied or vacated. ProxyError, or any other error,
|
|
248
|
+
# fails the request.
|
|
249
|
+
class NotifyChannelStateHandler < ProxyHandler
|
|
250
|
+
# @api private
|
|
251
|
+
SPEC = ["channel state", NotifyChannelStateRequest, NotifyChannelStateResult, :error].freeze
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
data/lib/cent/reply.rb
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# Reading Centrifugo's replies, and the rules checked before sending.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module Reply
|
|
8
|
+
# Statuses Centrifugo's transport error mode maps errors to.
|
|
9
|
+
TRANSPORT_MODE_STATUSES = [400, 404, 409, 416, 429, 500].freeze
|
|
10
|
+
MIN_ERROR_CODE = 100
|
|
11
|
+
# nil is "no data", as for every keyword: how to publish JSON null.
|
|
12
|
+
NULL_HINT = " (to publish JSON null, pass data: JSON::Fragment.new(\"null\"))"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def parse(body, method)
|
|
17
|
+
JSON.parse(body, allow_nan: false, max_nesting: false)
|
|
18
|
+
rescue JSON::ParserError, EncodingError => e
|
|
19
|
+
raise DecodeError, "#{method}: the reply is not JSON: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The decoded reply of a method: [its result, its error].
|
|
23
|
+
def envelope(body, method)
|
|
24
|
+
d = parse(body, method)
|
|
25
|
+
raise DecodeError, "#{method}: the reply is not a JSON object" unless d.is_a?(Hash)
|
|
26
|
+
return [{}, api_error(d["error"], "error")] unless d["error"].nil?
|
|
27
|
+
|
|
28
|
+
result = d["result"]
|
|
29
|
+
return [{}, nil] if result.nil?
|
|
30
|
+
raise DecodeError, "#{method}: result: expected an object" unless result.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
[result, nil]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The replies of a batch of n commands.
|
|
36
|
+
def batch(body, n)
|
|
37
|
+
d = parse(body, "batch")
|
|
38
|
+
raise DecodeError, "batch: the reply is not a JSON object" unless d.is_a?(Hash)
|
|
39
|
+
raise api_error(d["error"], "error") unless d["error"].nil?
|
|
40
|
+
|
|
41
|
+
replies = d["replies"] || []
|
|
42
|
+
unless replies.is_a?(Array) && replies.all?(Hash)
|
|
43
|
+
raise DecodeError, "batch: replies: expected an array of objects"
|
|
44
|
+
end
|
|
45
|
+
raise DecodeError, "batch: got #{replies.size} replies for #{n} commands" if replies.size != n
|
|
46
|
+
|
|
47
|
+
replies
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The ApiError of an error object in a reply.
|
|
51
|
+
def api_error(v, where)
|
|
52
|
+
d = Wire.obj(v, where)
|
|
53
|
+
ApiError.for_code(Wire.get(d, "code", where, :int), Wire.get(d, "message", where, :str),
|
|
54
|
+
temporary: Wire.get(d, "temporary", where, :bool))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The error of a batch command's reply, if it has one. A PRO method's code
|
|
58
|
+
# 104 hints that the server may be Centrifugo OSS.
|
|
59
|
+
def command_error(reply, i, method)
|
|
60
|
+
return nil if reply["error"].nil?
|
|
61
|
+
|
|
62
|
+
err = api_error(reply["error"], "replies[#{i}].error")
|
|
63
|
+
err.pro_method!(method) if err.code == NotFoundError::CODE && API_METHODS[method]
|
|
64
|
+
err
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The error of a reply with a status other than 200: a Centrifugo error in
|
|
68
|
+
# the transport error mode, an HTTPError otherwise.
|
|
69
|
+
def http_error(method, status, body, headers)
|
|
70
|
+
if TRANSPORT_MODE_STATUSES.include?(status)
|
|
71
|
+
d = begin
|
|
72
|
+
JSON.parse(body)
|
|
73
|
+
rescue JSON::ParserError, EncodingError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
# A body whose code is an HTTP error status - {"code": 429} from a rate
|
|
77
|
+
# limiting gateway - is a gateway's, not Centrifugo's.
|
|
78
|
+
code = d["code"] if d.is_a?(Hash)
|
|
79
|
+
if code.is_a?(Integer) && code >= MIN_ERROR_CODE && !(400..599).cover?(code)
|
|
80
|
+
begin
|
|
81
|
+
return api_error(d, "error")
|
|
82
|
+
rescue DecodeError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
start = body.byteslice(0, HTTP::MAX_ERROR_BODY_BYTES).strip
|
|
88
|
+
err = (status == 401 ? UnauthorizedError : HTTPError).new(status, start, headers)
|
|
89
|
+
err.pro_method!(method) if API_METHODS[method]
|
|
90
|
+
err
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The rules every Centrifugo holds a request to, checked before sending:
|
|
94
|
+
# the fields the proto marks required, and publish's and broadcast's data.
|
|
95
|
+
# Everything else Centrifugo checks itself.
|
|
96
|
+
def validate(method, w)
|
|
97
|
+
Wire.check_required(w, method)
|
|
98
|
+
case method
|
|
99
|
+
when "publish"
|
|
100
|
+
raise InvalidRequestError, "data or b64data is required#{NULL_HINT}" unless w.key?("data") || w["b64data"]
|
|
101
|
+
when "broadcast"
|
|
102
|
+
raise InvalidRequestError, "data or b64data is required#{NULL_HINT}" unless w.key?("data") || w["b64data"]
|
|
103
|
+
|
|
104
|
+
w["channels"].each_with_index { |ch, i| raise InvalidRequestError, "channels[#{i}] is empty" if ch.empty? }
|
|
105
|
+
end
|
|
106
|
+
w
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/cent/retry.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cent
|
|
4
|
+
# The wait, in seconds, before retrying after a failed attempt, attempt 1
|
|
5
|
+
# being the first failure: +base+ after it, twice as long after each next one,
|
|
6
|
+
# up to +limit+. Each wait is a random time between half of it and all of it,
|
|
7
|
+
# so that clients which failed together do not all retry at the same moment. A
|
|
8
|
+
# negative base counts as zero, a limit below base as base, and an attempt
|
|
9
|
+
# below 1 as 1.
|
|
10
|
+
#
|
|
11
|
+
# In a retry loop of your own - a publication retried with the same
|
|
12
|
+
# idempotency_key:
|
|
13
|
+
#
|
|
14
|
+
# backoff = Cent.exponential_backoff(0.1, 2.0)
|
|
15
|
+
# attempt = 0
|
|
16
|
+
# begin
|
|
17
|
+
# attempt += 1
|
|
18
|
+
# client.publish(channel: "orders:42", data:, idempotency_key: "order-42-paid")
|
|
19
|
+
# rescue Cent::Error => e
|
|
20
|
+
# raise unless Cent.retryable?(e) && attempt < 3
|
|
21
|
+
#
|
|
22
|
+
# sleep(backoff.(attempt))
|
|
23
|
+
# retry
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# Retry a broadcast with broadcast_with_retry instead: repeating it resends to
|
|
27
|
+
# the channels which already got it.
|
|
28
|
+
#
|
|
29
|
+
# @param base [Numeric] seconds
|
|
30
|
+
# @param limit [Numeric] seconds
|
|
31
|
+
# @return [Proc] taking the attempt, returning seconds
|
|
32
|
+
def self.exponential_backoff(base, limit)
|
|
33
|
+
base = [base.to_f, 0.0].max
|
|
34
|
+
limit = [limit.to_f, base].max
|
|
35
|
+
lambda do |attempt|
|
|
36
|
+
d = base
|
|
37
|
+
(1...[attempt, 1].max).each do
|
|
38
|
+
# A zero base never grows: no need to count the attempts out.
|
|
39
|
+
break if d >= limit || d.zero?
|
|
40
|
+
|
|
41
|
+
d *= 2
|
|
42
|
+
end
|
|
43
|
+
d = [d, limit].min
|
|
44
|
+
(d / 2) + (rand * (d / 2))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
DEFAULT_BACKOFF = exponential_backoff(0.1, 5.0)
|
|
49
|
+
private_constant :DEFAULT_BACKOFF
|
|
50
|
+
|
|
51
|
+
# @api private
|
|
52
|
+
def self.default_delay(attempt) = DEFAULT_BACKOFF.call(attempt)
|
|
53
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Cent
|
|
6
|
+
# Signatures for shared poll subscriptions. See
|
|
7
|
+
# https://centrifugal.dev/docs/server/shared_poll. Shared poll subscriptions
|
|
8
|
+
# are experimental in Centrifugo, and this module follows their changes.
|
|
9
|
+
module SharedPoll
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The signature authorizing +user+ to track +keys+ in +channel+ until
|
|
13
|
+
# +expires_at+, as Centrifugo verifies it: "iat:exp:hmac", iat now and exp
|
|
14
|
+
# expires_at in Unix seconds. +expires_at+ is required, as a token's expiry
|
|
15
|
+
# is: a Time, or +:never+ for a signature which never expires - an Integer
|
|
16
|
+
# or a Duration, such as +1.hour+, is refused. +secret+ is Centrifugo's
|
|
17
|
+
# shared_poll.hmac_secret_key; +user+ is the ID of the connection's user,
|
|
18
|
+
# empty for an anonymous one.
|
|
19
|
+
#
|
|
20
|
+
# The signature covers exactly +keys+, in their order: a client tracking
|
|
21
|
+
# other keys, or the same in another order, is refused.
|
|
22
|
+
#
|
|
23
|
+
# Cent::SharedPoll.sign(secret, user: "42", channel: "poll:prices", keys: ["AAPL"], expires_at: Time.now + 3600)
|
|
24
|
+
#
|
|
25
|
+
# @param secret [String]
|
|
26
|
+
# @param user [String]
|
|
27
|
+
# @param channel [String]
|
|
28
|
+
# @param keys [Array<String>]
|
|
29
|
+
# @param expires_at [Time, :never]
|
|
30
|
+
# @return [String]
|
|
31
|
+
def sign(secret, user:, channel:, keys:, expires_at:)
|
|
32
|
+
sign_at(Time.now, secret, user:, channel:, keys:, expires_at:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# sign, at the time +now+: for the spec's test vectors.
|
|
36
|
+
def sign_at(now, secret, user:, channel:, keys:, expires_at:)
|
|
37
|
+
raise Error, "shared poll: the secret must be a non-empty String" unless secret.is_a?(String) && !secret.empty?
|
|
38
|
+
|
|
39
|
+
iat = now.to_i.to_s
|
|
40
|
+
exp = expires_at == :never ? "0" : unix(expires_at).to_s
|
|
41
|
+
keys_hash = OpenSSL::Digest::SHA256.hexdigest(keys.join("\0"))
|
|
42
|
+
# NUL-separated, so that a colon in a user or channel cannot move between
|
|
43
|
+
# fields: ("alice", "news:tech") and ("alice:news", "tech") sign
|
|
44
|
+
# differently.
|
|
45
|
+
msg = [iat, exp, user, channel, keys_hash].join("\0")
|
|
46
|
+
"#{iat}:#{exp}:#{OpenSSL::HMAC.hexdigest("SHA256", secret, msg)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unix(t)
|
|
50
|
+
t = t.to_time if !t.is_a?(Time) && t.respond_to?(:to_time)
|
|
51
|
+
unless t.is_a?(Time)
|
|
52
|
+
raise Error, "shared poll: expires_at must be a Time, or :never for a signature which never expires - " \
|
|
53
|
+
"not #{t.class}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
t.to_i
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private_class_method :sign_at, :unix
|
|
60
|
+
end
|
|
61
|
+
end
|