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/README.md
CHANGED
|
@@ -1,260 +1,807 @@
|
|
|
1
1
|
# cent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ruby SDK for the [HTTP server API](https://centrifugal.dev/docs/server/server_api)
|
|
4
|
+
of [Centrifugo](https://github.com/centrifugal/centrifugo): every API method,
|
|
5
|
+
including those of Centrifugo PRO, with typed results; batches, automatic
|
|
6
|
+
batching and retries; connection, subscription and server API tokens; the
|
|
7
|
+
proxy handlers and the endpoints client SDKs call, as Rack apps.
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
```ruby
|
|
10
|
+
gem "cent", "~> 5.0" # json is the only dependency
|
|
11
|
+
```
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
| | Version |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Ruby | 3.4 or newer |
|
|
16
|
+
| Centrifugo | v6, any release |
|
|
17
|
+
| Map and shared poll methods | Centrifugo v6.8.0 or newer |
|
|
18
|
+
| `ApiError#temporary?`, `TooManyRequestsError`, refused requests reported as such rather than as internal errors | Centrifugo v6.10.0 or newer |
|
|
19
|
+
| Ed25519 tokens verified without JWKS, by `client.token.ed25519_public_key` | Centrifugo v6.10.2 or newer |
|
|
20
|
+
| Methods marked "Centrifugo PRO only" | Centrifugo PRO v6 |
|
|
9
21
|
|
|
10
|
-
|
|
22
|
+
cent 5 replaces cent 4: see [MIGRATING.md](MIGRATING.md) - an application
|
|
23
|
+
which needs time to move can stay on `gem "cent", "~> 4.0"`. cent implements the
|
|
24
|
+
[Centrifugo server SDK specification](https://github.com/centrifugal/gocent/blob/master/spec.md),
|
|
25
|
+
version 0.4.
|
|
11
26
|
|
|
12
|
-
##
|
|
27
|
+
## Example
|
|
13
28
|
|
|
14
29
|
```ruby
|
|
15
|
-
|
|
16
|
-
|
|
30
|
+
require "cent"
|
|
31
|
+
|
|
32
|
+
client = Cent::Client.new(api_endpoint: "http://localhost:8000/api", api_key: ENV.fetch("CENTRIFUGO_API_KEY"))
|
|
17
33
|
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
res = client.publish(channel: "news", data: { text: "hello" })
|
|
35
|
+
puts "published at offset #{res.offset}"
|
|
20
36
|
```
|
|
21
37
|
|
|
22
|
-
|
|
38
|
+
There is a method for every API method, generated from Centrifugo's
|
|
39
|
+
`api.proto` with its documentation. It takes the fields of the method's
|
|
40
|
+
request as keyword arguments and returns its result - a `Data` object - raising
|
|
41
|
+
unless Centrifugo did everything asked. A nested message is its class or a
|
|
42
|
+
Hash of its fields: `since: { offset: 5, epoch: "xyz" }`. A batch and commands
|
|
43
|
+
have the same methods: `batch.publish(...)`, `Cent::Command.publish(...)`.
|
|
44
|
+
|
|
45
|
+
A field `api.proto` marks required is a required keyword - of the method, or of
|
|
46
|
+
a nested message's class - and must have a value: given `nil`, `""`, `0` or a
|
|
47
|
+
message with nothing set, the call raises `Cent::InvalidRequestError` naming
|
|
48
|
+
it, as in `limit_strategy.rate_limit.policies[0].rate is required`, before
|
|
49
|
+
anything is sent. A field at its zero value is not sent, nor a message with
|
|
50
|
+
nothing set - but for the updates of `device_update`, sent whenever given:
|
|
51
|
+
`timezone_update: { timezone: "" }` clears the timezone, and leaving
|
|
52
|
+
`timezone_update` out changes nothing.
|
|
53
|
+
|
|
54
|
+
A field with a fixed set of values has a constant per value, named as the
|
|
55
|
+
specification's table pins it for every SDK, then the value:
|
|
56
|
+
`provider: Cent::PROVIDER_FCM`, `op: Cent::OP_ADD`, `Cent::FILTER_NODE_OP_AND`
|
|
57
|
+
for `FilterNode`'s `op`, and in a proxy handler
|
|
58
|
+
`req.encoding == Cent::Proxy::ENCODING_BINARY` or
|
|
59
|
+
`event.type == Cent::Proxy::CHANNEL_EVENT_TYPE_OCCUPIED`. The fields stay strings: a
|
|
60
|
+
value Centrifugo adds can be sent before cent names it.
|
|
61
|
+
|
|
62
|
+
Create the client once and share it - a constant, an initializer: it is safe
|
|
63
|
+
for concurrent use from threads, and keeps its connections to Centrifugo alive
|
|
64
|
+
between calls. Under a fiber scheduler - Async, Falcon - calls wait without
|
|
65
|
+
blocking other fibers.
|
|
66
|
+
|
|
67
|
+
A result converts to a Hash and to JSON whole, nested results included:
|
|
68
|
+
`res.to_h`, `res.to_json`, `render json: res`.
|
|
69
|
+
|
|
70
|
+
## Rails
|
|
71
|
+
|
|
72
|
+
The client, configured once in an initializer and closed when the process
|
|
73
|
+
exits, so that calls in progress finish:
|
|
23
74
|
|
|
24
75
|
```ruby
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
api_key:
|
|
29
|
-
|
|
30
|
-
timeout: 5
|
|
76
|
+
# config/initializers/centrifugo.rb
|
|
77
|
+
CENTRIFUGO = Cent::Client.new(
|
|
78
|
+
api_endpoint: ENV.fetch("CENTRIFUGO_API_ENDPOINT", "http://localhost:8000/api"),
|
|
79
|
+
api_key: Rails.application.credentials.dig(:centrifugo, :api_key),
|
|
80
|
+
request_timeout: 5
|
|
31
81
|
)
|
|
82
|
+
at_exit { CENTRIFUGO.close(timeout: 5) }
|
|
32
83
|
```
|
|
33
84
|
|
|
34
|
-
|
|
85
|
+
`api_key: nil` means no key, so the initializer loads where credentials are
|
|
86
|
+
absent - `rails assets:precompile` in a Docker build, say. Under Puma's
|
|
87
|
+
`preload_app!` or a Sidekiq swarm the client is shared across the fork: a
|
|
88
|
+
forked process starts its own connections and batch threads on its first call.
|
|
89
|
+
In tests, `at_exit` handlers run in reverse order, so one registered after
|
|
90
|
+
`minitest/autorun` closes the client before the tests run: close it in
|
|
91
|
+
production only, or from Puma's `on_worker_shutdown`.
|
|
35
92
|
|
|
36
|
-
|
|
37
|
-
- On an API-level failure (e.g. unknown channel, namespace not found) `Cent::ResponseError` is raised with Centrifugo's numeric `code` and `message`.
|
|
38
|
-
- On a transport problem (network failure, timeout, non-2xx HTTP, malformed JSON) a `Cent::Error` subclass is raised.
|
|
93
|
+
Publishing from a model or a job, and rendering a result:
|
|
39
94
|
|
|
40
|
-
|
|
95
|
+
```ruby
|
|
96
|
+
class Message < ApplicationRecord
|
|
97
|
+
after_create_commit do
|
|
98
|
+
CENTRIFUGO.publish(channel: "chat:#{room_id}", data: { id:, text:, user: user.name },
|
|
99
|
+
idempotency_key: "message-#{id}")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
41
102
|
|
|
42
|
-
|
|
103
|
+
class HistoryController < ApplicationController
|
|
104
|
+
def show
|
|
105
|
+
render json: CENTRIFUGO.history(channel: "chat:#{params[:room_id]}", limit: 50, reverse: true)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
43
109
|
|
|
44
|
-
|
|
110
|
+
Token endpoints, as plain actions behind your authentication - the token
|
|
111
|
+
functions are all you need from cent here:
|
|
45
112
|
|
|
46
113
|
```ruby
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
114
|
+
# config/initializers/centrifugo.rb
|
|
115
|
+
CENTRIFUGO_KEY = Cent::Token::Key.hmac(Rails.application.credentials.dig(:centrifugo, :token_secret))
|
|
116
|
+
|
|
117
|
+
# app/controllers/centrifugo_controller.rb
|
|
118
|
+
class CentrifugoController < ApplicationController
|
|
119
|
+
# 403, not the 401 authenticate_user! answers: a client SDK stops asking only
|
|
120
|
+
# on 403, and retries anything else.
|
|
121
|
+
before_action { head :forbidden unless current_user }
|
|
122
|
+
|
|
123
|
+
def connection_token
|
|
124
|
+
token = Cent::Token.connection(CENTRIFUGO_KEY, user: current_user.id.to_s, expires_at: 15.minutes.from_now)
|
|
125
|
+
render_token(token)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def subscription_token
|
|
129
|
+
channel = params.require(:channel)
|
|
130
|
+
# 403 tells the client SDK to stop asking, and to unsubscribe.
|
|
131
|
+
return head :forbidden unless current_user.can_read?(channel)
|
|
132
|
+
|
|
133
|
+
render_token(Cent::Token.subscription(CENTRIFUGO_KEY, user: current_user.id.to_s, channel:,
|
|
134
|
+
expires_at: 15.minutes.from_now))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def render_token(token)
|
|
140
|
+
response.headers["Cache-Control"] = "no-store" # a token is never cached
|
|
141
|
+
render json: { token: }
|
|
142
|
+
end
|
|
52
143
|
end
|
|
144
|
+
|
|
145
|
+
# config/routes.rb
|
|
146
|
+
post "centrifugo/connection_token", to: "centrifugo#connection_token"
|
|
147
|
+
post "centrifugo/subscription_token", to: "centrifugo#subscription_token"
|
|
53
148
|
```
|
|
54
149
|
|
|
55
|
-
|
|
150
|
+
A client SDK treats a 403 from a token endpoint as final - it disconnects, or
|
|
151
|
+
unsubscribes - and retries anything else, a 401 or 500 included: a signed-out
|
|
152
|
+
user's browser answered 401 would ask again and again.
|
|
153
|
+
|
|
154
|
+
A proxy handler, which Centrifugo calls on an internal network, is mounted as
|
|
155
|
+
it is. Its block hands the request to a module of your app: the module is
|
|
156
|
+
reloaded in development, where the initializer is not, and its methods can
|
|
157
|
+
`return` early, which a block cannot:
|
|
56
158
|
|
|
57
159
|
```ruby
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
skip_history: false,
|
|
65
|
-
tags: { 'author' => '42' },
|
|
66
|
-
idempotency_key: 'my-idempotency-key',
|
|
67
|
-
delta: true
|
|
68
|
-
)
|
|
69
|
-
```
|
|
160
|
+
# config/initializers/centrifugo.rb
|
|
161
|
+
REPORT = ->(_http, err) { Rails.error.report(err) }
|
|
162
|
+
|
|
163
|
+
SUBSCRIBE_HANDLER = Cent::Proxy::SubscribeHandler.new(on_error: REPORT) do |req, http|
|
|
164
|
+
CentrifugoProxy.subscribe(req, http)
|
|
165
|
+
end
|
|
70
166
|
|
|
71
|
-
|
|
167
|
+
# app/services/centrifugo_proxy.rb
|
|
168
|
+
module CentrifugoProxy
|
|
169
|
+
def self.subscribe(req, _http)
|
|
170
|
+
return {} if req.channel.start_with?("public:")
|
|
171
|
+
raise Cent::Proxy::ProxyError.new(403, "permission denied") unless Room.readable_by?(req.user, req.channel)
|
|
72
172
|
|
|
73
|
-
|
|
173
|
+
{ info: { "role" => Room.role(req.user, req.channel) } }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
74
176
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# response => { "result" => { "responses" => [ {"result" => {...}}, {"result" => {...}} ] } }
|
|
177
|
+
# config/routes.rb
|
|
178
|
+
mount SUBSCRIBE_HANDLER, at: "/centrifugo/subscribe"
|
|
78
179
|
```
|
|
79
180
|
|
|
80
|
-
The
|
|
181
|
+
The module's methods are plain to test:
|
|
182
|
+
`CentrifugoProxy.subscribe(Cent::Proxy::SubscribeRequest.new(user: "42", channel: "room:1"), nil)`.
|
|
183
|
+
|
|
184
|
+
## Pages and positions
|
|
185
|
+
|
|
186
|
+
The methods which return a page at a time - `history`, `device_list`,
|
|
187
|
+
`device_topic_list`, `user_topic_list` and `map_read_state` - each have an
|
|
188
|
+
`_all` method taking the same arguments, which reads page after page: with a
|
|
189
|
+
block it yields every item, without one it returns an `Enumerator`. A page is
|
|
190
|
+
read when the iteration reaches it, so `first(10)` or a `break` reads no more.
|
|
191
|
+
An iteration lasts as long as your loop, so it takes no `timeout:`;
|
|
192
|
+
`page_timeout:` bounds each page's request instead, replacing
|
|
193
|
+
`request_timeout` for it:
|
|
81
194
|
|
|
82
195
|
```ruby
|
|
83
|
-
|
|
84
|
-
|
|
196
|
+
client.history_all(channel: "news", limit: 100) do |item|
|
|
197
|
+
archive(item.data)
|
|
198
|
+
last = item.position # resume with since: last, or give a client recover_since
|
|
85
199
|
end
|
|
200
|
+
client.device_list_all(filter: { users: ["42"] }, page_timeout: 30).each_slice(50) { |devices| notify(devices) }
|
|
86
201
|
```
|
|
87
202
|
|
|
88
|
-
|
|
203
|
+
`history_all` needs a `limit` of 1 or more, the page size, and reads forward
|
|
204
|
+
only. It yields each publication as a `Cent::HistoryItem`, whose fields read
|
|
205
|
+
as the publication's and whose `position` is where it is in the stream;
|
|
206
|
+
`map_read_state_all` yields each entry as a `Cent::MapStateItem`, its
|
|
207
|
+
`position` the state's, which `map_read_stream` follows. `map_read_state_all`
|
|
208
|
+
reads every page after the first at the first one's revision, so that a map reset between pages raises
|
|
209
|
+
`Cent::UnrecoverablePositionError` rather than mixing two states: start again
|
|
210
|
+
then. Items changed while iterating - a device registered meanwhile - may or
|
|
211
|
+
may not be seen.
|
|
212
|
+
|
|
213
|
+
The results of `publish`, `history`, `map_publish`, `map_remove`,
|
|
214
|
+
`map_read_state` and `map_read_stream` give their offset and epoch as a `Cent::StreamPosition`: what came after a
|
|
215
|
+
publication is
|
|
89
216
|
|
|
90
217
|
```ruby
|
|
91
|
-
client.
|
|
92
|
-
client.
|
|
218
|
+
pos = client.publish(channel: "news", data: { text: "hi" }).position
|
|
219
|
+
client.history(channel: "news", since: pos, limit: 100)
|
|
93
220
|
```
|
|
94
221
|
|
|
95
|
-
|
|
222
|
+
A position whose epoch is no longer the stream's raises
|
|
223
|
+
`Cent::UnrecoverablePositionError`: the stream was reset, and reading it from
|
|
224
|
+
its start is the only recovery.
|
|
225
|
+
|
|
226
|
+
## Payloads
|
|
227
|
+
|
|
228
|
+
`data`, `info`, `meta` and the other payloads are JSON values, given as the
|
|
229
|
+
Ruby values `JSON.generate` writes - `Hash`, `Array`, `String`, `Integer`,
|
|
230
|
+
`Float`, `true`, `false`, `nil`, Symbols as strings - and returned as
|
|
231
|
+
`JSON.parse` gives them, Hashes with String keys:
|
|
96
232
|
|
|
97
233
|
```ruby
|
|
98
|
-
client.
|
|
99
|
-
client.disconnect(user: '42', whitelist: %w[keep-this-client-id])
|
|
234
|
+
client.publish(channel: "news", data: { text: "hi", tags: %w[a b] })
|
|
100
235
|
|
|
101
|
-
client.
|
|
236
|
+
pub = client.history(channel: "news", limit: 1).publications.first
|
|
237
|
+
pub.data["text"]
|
|
238
|
+
client.publish(channel: "archive", data: pub.data) # exactly the same JSON value
|
|
102
239
|
```
|
|
103
240
|
|
|
104
|
-
|
|
241
|
+
A payload from a reply has String keys, as JSON has: `pub.data["text"]`, not
|
|
242
|
+
`pub.data[:text]`, so that it goes back out as the same JSON value. To read it
|
|
243
|
+
with Symbols, convert a copy - `pub.data.transform_keys(&:to_sym)`, or
|
|
244
|
+
`pub.data.with_indifferent_access` in Rails - and send the original.
|
|
245
|
+
|
|
246
|
+
A String is a JSON string. JSON you already have, or encode with rules of your
|
|
247
|
+
own - dates, decimals, a faster encoder - goes in a `JSON::Fragment`, sent as it
|
|
248
|
+
is, also inside a Hash:
|
|
105
249
|
|
|
106
250
|
```ruby
|
|
107
|
-
client.
|
|
108
|
-
client.
|
|
251
|
+
client.publish(channel: "news", data: JSON::Fragment.new(row["payload_json"]))
|
|
252
|
+
client.publish(channel: "news", data: { meta:, doc: JSON::Fragment.new(json) })
|
|
109
253
|
```
|
|
110
254
|
|
|
111
|
-
|
|
255
|
+
A payload which is not JSON - NaN, a `Time`, a binary (ASCII-8BIT) string,
|
|
256
|
+
invalid UTF-8, a Hash with Integer keys - raises `Cent::InvalidRequestError`
|
|
257
|
+
naming where it is, before anything is sent. Binary data goes base64-encoded
|
|
258
|
+
in the `b64` fields, such as `b64data`.
|
|
259
|
+
|
|
260
|
+
Write integers which may pass 2^53 - a 64-bit ID, say - as JSON strings:
|
|
261
|
+
browsers and JavaScript backends decode JSON numbers as doubles, and round
|
|
262
|
+
larger integers silently.
|
|
263
|
+
|
|
264
|
+
## Timeouts
|
|
265
|
+
|
|
266
|
+
`request_timeout` - ten seconds by default - bounds every call. A call's
|
|
267
|
+
`timeout:`, in seconds, replaces it for that call, longer or shorter; nil
|
|
268
|
+
leaves `request_timeout`. Neither can be turned off: zero is refused, as a
|
|
269
|
+
negative is - set a large value for long calls. Ruby has
|
|
270
|
+
no deadline a call runs under which cent could read: a `Timeout.timeout` or an
|
|
271
|
+
Async task's timeout around a call can only cut it short, with its own error.
|
|
272
|
+
An automatic batch request lasts as long as the longest of its calls' timeouts,
|
|
273
|
+
and so may outlast a shorter one, whose call still returns at its timeout:
|
|
112
274
|
|
|
113
275
|
```ruby
|
|
114
|
-
client.
|
|
115
|
-
client.history(channel: 'chat', limit: 10, reverse: true)
|
|
116
|
-
client.history(channel: 'chat', limit: 10, since: { 'offset' => 5, 'epoch' => 'xyz' })
|
|
117
|
-
client.history_remove(channel: 'chat')
|
|
276
|
+
client.info(timeout: 0.5)
|
|
118
277
|
```
|
|
119
278
|
|
|
120
|
-
|
|
279
|
+
The timeout bounds the whole call: connecting and TLS, sending, and the
|
|
280
|
+
reply, however slowly a server - a proxy in trouble, say - sends it. However a
|
|
281
|
+
call times out, it raises `Cent::TimeoutError`. `broadcast_with_retry` makes
|
|
282
|
+
several requests in one call: its `timeout:` bounds them all, waits between
|
|
283
|
+
them included, and `attempt_timeout:` each one.
|
|
284
|
+
|
|
285
|
+
## Errors
|
|
286
|
+
|
|
287
|
+
| Error | Meaning |
|
|
288
|
+
|---|---|
|
|
289
|
+
| `Cent::ApiError` | Centrifugo refused the request or failed to carry it out: `code`, `server_message`, `temporary?`. Known codes are subclasses: `UnknownChannelError`, `NotFoundError`, `BadRequestError`, `NotAvailableError`, `TooManyRequestsError`, `InternalError`... |
|
|
290
|
+
| `Cent::BroadcastFailedChannelsError`, `Cent::BatchFailedCommandsError` | some parts of a broadcast or batch failed - see below |
|
|
291
|
+
| `Cent::HTTPError` | the request did not reach the API method: `status_code`, `body`, `headers`; `UnauthorizedError` for rejected credentials |
|
|
292
|
+
| `Cent::DecodeError` | the reply was not Centrifugo's - usually `api_endpoint` points elsewhere |
|
|
293
|
+
| `Cent::InvalidRequestError` | the client could tell the request is wrong and did not send it |
|
|
294
|
+
| `Cent::InvalidConfigError` | the client is refused as configured: not created, or a call given an endpoint by `api_endpoint_func` or a token by `bearer_token_func` which cannot be sent. The message names the option and the fix |
|
|
295
|
+
| `Cent::FunctionError` | `api_endpoint_func` or `bearer_token_func` raised: `function` is the option's name, which the message names too, `cause` is its exception, and `Cent.retryable?` judges it by that |
|
|
296
|
+
| `Cent::NetworkError`, `Cent::TimeoutError`, `Cent::ClosedError` | as their names say |
|
|
297
|
+
|
|
298
|
+
All are `Cent::Error`. Centrifugo adds error codes over time: an unknown code
|
|
299
|
+
is still an `ApiError`, so rescue the codes you handle and treat the rest by
|
|
300
|
+
class.
|
|
301
|
+
|
|
302
|
+
## Retries
|
|
303
|
+
|
|
304
|
+
`Cent.retryable?(err)` reports whether a new attempt, with time left, may
|
|
305
|
+
succeed: the error came from a temporary condition - a broker being
|
|
306
|
+
unavailable, a rate limit, the network - not from the request.
|
|
307
|
+
|
|
308
|
+
A temporary error does not mean nothing was done: after a timeout, or even an
|
|
309
|
+
internal error, the publication may have happened. So retry a publication only
|
|
310
|
+
with an `idempotency_key`, and back off between a bounded number of attempts:
|
|
121
311
|
|
|
122
312
|
```ruby
|
|
123
|
-
|
|
124
|
-
|
|
313
|
+
backoff = Cent.exponential_backoff(0.2, 5.0)
|
|
314
|
+
attempt = 0
|
|
315
|
+
begin
|
|
316
|
+
attempt += 1
|
|
317
|
+
# The same key on every attempt: a publication which happened in an
|
|
318
|
+
# earlier attempt, even one reported as failed, is not published again.
|
|
319
|
+
client.publish(channel: "orders:42", data:, idempotency_key: "order-42-paid")
|
|
320
|
+
rescue Cent::Error => e
|
|
321
|
+
raise unless Cent.retryable?(e) && attempt < 3
|
|
322
|
+
|
|
323
|
+
sleep(backoff.(attempt))
|
|
324
|
+
retry
|
|
325
|
+
end
|
|
125
326
|
```
|
|
126
327
|
|
|
127
|
-
|
|
328
|
+
Choosing keys:
|
|
329
|
+
|
|
330
|
+
- One key per publication or broadcast, reused only to retry that same one. A
|
|
331
|
+
broadcast needs one key, not one per channel.
|
|
332
|
+
- Centrifugo remembers a key per channel, for five minutes by default. A retry
|
|
333
|
+
within that time is answered with the first result and not published again -
|
|
334
|
+
and so is a *different* publication into the same channel under the same
|
|
335
|
+
key. A key built from an ID must name the event too: `order-42-paid`, not
|
|
336
|
+
`order-42`.
|
|
337
|
+
- The memory, Redis and PostgreSQL brokers keep keys. The NATS broker ignores
|
|
338
|
+
them, so with it a retry publishes again.
|
|
339
|
+
|
|
340
|
+
Retry a broadcast with `broadcast_with_retry` rather than a loop: see below.
|
|
341
|
+
|
|
342
|
+
## Broadcast and batch
|
|
343
|
+
|
|
344
|
+
`broadcast` publishes the same data into many channels in one request; a batch
|
|
345
|
+
sends many commands at once. Both raise unless every part succeeded:
|
|
128
346
|
|
|
129
347
|
```ruby
|
|
130
|
-
client.
|
|
348
|
+
client.broadcast(channels: ["user:1", "user:2"], data: { text: "hello" }, idempotency_key: message_id)
|
|
349
|
+
|
|
350
|
+
batch = client.new_batch
|
|
351
|
+
pub = batch.publish(channel: "news", data:)
|
|
352
|
+
stats = batch.presence_stats(channel: "news")
|
|
353
|
+
batch.send
|
|
354
|
+
pub.value.offset # each command has its own result, or raises its own error
|
|
355
|
+
stats.value.num_clients
|
|
131
356
|
```
|
|
132
357
|
|
|
133
|
-
|
|
358
|
+
Parts succeed or fail on their own. When Centrifugo carried the request out
|
|
359
|
+
and some parts failed, up to all, it raises `BroadcastFailedChannelsError` or
|
|
360
|
+
`BatchFailedCommandsError`: its `failed` lists them, and its `result` holds
|
|
361
|
+
every part's outcome. Any other error means the request failed as a whole - the
|
|
362
|
+
network, the credentials, an invalid request - and no part got a reply; after a
|
|
363
|
+
timeout or a network error Centrifugo may still have carried it out.
|
|
134
364
|
|
|
135
|
-
|
|
365
|
+
A partial error is not an error of its parts: `rescue Cent::UnknownChannelError`
|
|
366
|
+
does not catch a broadcast in which a channel was unknown, nor one into a
|
|
367
|
+
single channel - use `publish` for one channel. To go on despite some failed
|
|
368
|
+
parts:
|
|
136
369
|
|
|
137
370
|
```ruby
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
# => { "replies" => [ {"publish" => {...}}, {"publish" => {...}}, {"presence_stats" => {...}} ] }
|
|
371
|
+
begin
|
|
372
|
+
client.broadcast(channels:, data:)
|
|
373
|
+
rescue Cent::BroadcastFailedChannelsError => e
|
|
374
|
+
logger.warn("broadcast failed in #{e.result.failed.map(&:channel)}")
|
|
375
|
+
end
|
|
144
376
|
```
|
|
145
377
|
|
|
146
|
-
|
|
378
|
+
Retrying:
|
|
379
|
+
|
|
380
|
+
- a **broadcast**: `broadcast_with_retry` does it. It sends again only the
|
|
381
|
+
channels which may still succeed, all attempts with one `idempotency_key` -
|
|
382
|
+
made for the call when none is given - and returns every channel's last
|
|
383
|
+
outcome, as `broadcast` does:
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
res = client.broadcast_with_retry(channels:, data:, max_attempts: 5, attempt_timeout: 2, timeout: 10)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
By default it retries once, after about 100 milliseconds. Its block, when
|
|
390
|
+
given, is called after each failed attempt which will be retried, with a
|
|
391
|
+
`Cent::BroadcastAttempt` - the channels to retry and their errors: it returns
|
|
392
|
+
the seconds to wait before the next attempt - or nil for the default
|
|
393
|
+
backoff - and may log or count meanwhile; it raises to stop. A `HTTPError`
|
|
394
|
+
in `attempt.error` carries the response's `headers`: wait at least its
|
|
395
|
+
`retry-after` to honour one.
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
backoff = Cent.exponential_backoff(0.2, 5.0)
|
|
399
|
+
client.broadcast_with_retry(channels:, data:) do |attempt|
|
|
400
|
+
logger.info("attempt #{attempt.attempt}: #{attempt.retry.size} channels to retry")
|
|
401
|
+
backoff.(attempt.attempt)
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`res.idempotency_key` is the key the attempts used: to retry the broadcast
|
|
406
|
+
later, after it gave up - from a job, say - send it with that key. Repeating
|
|
407
|
+
`broadcast` on any error would instead send it to every channel again, those
|
|
408
|
+
which already got it included.
|
|
409
|
+
|
|
410
|
+
It waits between attempts with `sleep`, in the thread calling it. In a web
|
|
411
|
+
request, give it a short `timeout:`, so that a Centrifugo which is down does
|
|
412
|
+
not hold the request for every attempt's `request_timeout`. Retry for longer
|
|
413
|
+
from a job - Sidekiq, Active Job - with an `idempotency_key` of its own, so
|
|
414
|
+
that the job retries under the same key whichever way the call failed:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
class BroadcastJob
|
|
418
|
+
include Sidekiq::Job
|
|
419
|
+
|
|
420
|
+
def perform(channels, data, key)
|
|
421
|
+
CENTRIFUGO.broadcast_with_retry(channels:, data:, idempotency_key: key, max_attempts: 3)
|
|
422
|
+
rescue Cent::BroadcastFailedChannelsError => e
|
|
423
|
+
retried = e.retryable_channels
|
|
424
|
+
self.class.perform_in(30, retried, data, key) unless retried.empty?
|
|
425
|
+
# Failed for good - an unknown channel, say: no retry fixes it.
|
|
426
|
+
lost = e.failed.reject { retried.include?(it.channel) }
|
|
427
|
+
Sidekiq.logger.error("broadcast #{key} failed in #{lost.map(&:channel)}: #{e.message}") unless lost.empty?
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
```
|
|
431
|
+
- a **batch**: build a new one, with the same keys, from the commands
|
|
432
|
+
`e.failed` lists with a retryable error. A batch is sent once.
|
|
433
|
+
|
|
434
|
+
`new_batch` runs the commands one by one, in order; `parallel: true` runs them
|
|
435
|
+
concurrently. Centrifugo PRO sends a batch's publications to its broker
|
|
436
|
+
together: each channel's publications still take effect in order, but
|
|
437
|
+
publications into different channels may not, unless the namespace sets
|
|
438
|
+
`publication_grouping_disabled`.
|
|
439
|
+
|
|
440
|
+
## Automatic batching
|
|
441
|
+
|
|
442
|
+
With `auto_batch`, calls made concurrently from one process go out together
|
|
443
|
+
once `max_in_flight` requests are in flight, with no delay otherwise, since
|
|
444
|
+
nothing waits for a timer. It is for processes which publish a lot at once - a
|
|
445
|
+
Sidekiq process at concurrency 10 or more whose jobs publish, an app on Falcon -
|
|
446
|
+
and does nothing for a Puma web process of a few threads: leave it off there.
|
|
147
447
|
|
|
148
|
-
|
|
149
|
-
|
|
448
|
+
```ruby
|
|
449
|
+
# A Sidekiq process at concurrency 16: about half the threads which publish.
|
|
450
|
+
client = Cent::Client.new(api_endpoint:, api_key:, auto_batch: Cent::AutoBatch.new(max_in_flight: 8))
|
|
451
|
+
```
|
|
150
452
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
```
|
|
453
|
+
Set `max_in_flight` to about half the threads - or fibers - which call the
|
|
454
|
+
client at once. It batches only calls made while that many requests are in
|
|
455
|
+
flight: above the concurrency, it never does; just below, it batches calls one
|
|
456
|
+
at a time, and only limits them. Under heavy publishing it sends a fraction of
|
|
457
|
+
the requests, for up to twice the publications a second, with Centrifugo, Redis
|
|
458
|
+
and your process doing about half the work per publication.
|
|
158
459
|
|
|
159
|
-
|
|
460
|
+
Every call still gets its own result or error, and keeps its own timeout. Calls
|
|
461
|
+
sharing a batch share its request, though: if the request itself fails - the
|
|
462
|
+
network, a body too large for a proxy in front of Centrifugo (nginx refuses
|
|
463
|
+
bodies over 1 MB by default) - every call in it fails.
|
|
160
464
|
|
|
161
|
-
|
|
465
|
+
A batch belongs to no single caller: it carries no caller's `Fiber[]` values. `batch_context`
|
|
466
|
+
gets the callers' fiber storages and a block sending the batch - to tag its
|
|
467
|
+
logs, or start a tracing span linking each caller's:
|
|
162
468
|
|
|
163
469
|
```ruby
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
rescue Cent::ResponseError => e
|
|
167
|
-
# Centrifugo rejected the request (e.g. unknown channel, namespace not found).
|
|
168
|
-
puts "Centrifugo error #{e.code}: #{e.message}"
|
|
169
|
-
rescue Cent::TimeoutError
|
|
170
|
-
# request timed out
|
|
171
|
-
rescue Cent::NetworkError
|
|
172
|
-
# connection refused / DNS failure / etc.
|
|
173
|
-
rescue Cent::UnauthorizedError => e
|
|
174
|
-
# HTTP 401 — API key is wrong
|
|
175
|
-
rescue Cent::TransportError => e
|
|
176
|
-
# other 4xx/5xx — e.status has the HTTP code
|
|
177
|
-
rescue Cent::DecodeError
|
|
178
|
-
# response body wasn't valid JSON
|
|
470
|
+
batch_context = lambda do |callers, &send_batch|
|
|
471
|
+
Rails.logger.tagged("batch of #{callers.filter_map { it[:request_id] }.join(",")}") { send_batch.call }
|
|
179
472
|
end
|
|
180
473
|
```
|
|
181
474
|
|
|
182
|
-
|
|
475
|
+
## Authentication
|
|
183
476
|
|
|
184
|
-
|
|
477
|
+
`api_key` is sent in the `X-API-Key` header. `bearer_token_func` instead sends a
|
|
478
|
+
token as `Authorization: Bearer`, for Centrifugo PRO's JWKS authentication - it
|
|
479
|
+
is called for every request, so return a cached token. Leave both unset when
|
|
480
|
+
mutual TLS, set up in `http_options`, or a proxy authenticates instead.
|
|
481
|
+
|
|
482
|
+
To sign the tokens yourself, `Cent::Token::ApiTokenSource` issues them and reuses
|
|
483
|
+
each for half its lifetime:
|
|
185
484
|
|
|
186
485
|
```ruby
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
486
|
+
api_token_source = Cent::Token::ApiTokenSource.new(
|
|
487
|
+
key: api_key, # its own key - never one of the client tokens
|
|
488
|
+
audience: "centrifugo-api", scope: ["centrifugo:api"]
|
|
489
|
+
)
|
|
490
|
+
client = Cent::Client.new(api_endpoint: "https://centrifugo.example.com/api",
|
|
491
|
+
bearer_token_func: api_token_source.method(:token))
|
|
190
492
|
```
|
|
191
493
|
|
|
192
|
-
|
|
494
|
+
Centrifugo PRO accepts a token signed with any key of `http_api.jwks.endpoint`,
|
|
495
|
+
and checks the audience, issuer and scope only when configured to. So keep API
|
|
496
|
+
tokens apart from the connection and subscription tokens your users hold - or
|
|
497
|
+
any user can call the server API with their connection token:
|
|
498
|
+
|
|
499
|
+
- publish the API keys with `Cent::Endpoints::JWKS.new(*api_keys)` at an
|
|
500
|
+
endpoint of their own, not the one of `client.token.jwks_public_endpoint`;
|
|
501
|
+
- set `http_api.jwks.audience` and `http_api.jwks.scope` to values only API
|
|
502
|
+
tokens carry - `Cent::Token.api` refuses claims with neither.
|
|
193
503
|
|
|
194
|
-
|
|
504
|
+
The API keys rotate as client keys do: see [Key rotation with JWKS](#key-rotation-with-jwks).
|
|
505
|
+
|
|
506
|
+
Rejected credentials make every call raise `Cent::UnauthorizedError`.
|
|
507
|
+
|
|
508
|
+
## HTTP, tracing and metrics
|
|
509
|
+
|
|
510
|
+
cent sends requests with Net::HTTP, from Ruby itself, keeping connections
|
|
511
|
+
alive between calls and never following redirects. TLS and other settings of the connections go in `http_options`:
|
|
195
512
|
|
|
196
513
|
```ruby
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
# With any of the standard/Centrifugo claims:
|
|
201
|
-
notary.issue_connection_token(
|
|
202
|
-
sub: '42', exp: 1735689600, iat: 1735686000, jti: SecureRandom.uuid,
|
|
203
|
-
aud: 'centrifugo', iss: 'my-app',
|
|
204
|
-
info: { role: 'admin' }, meta: { tenant: 'acme' },
|
|
205
|
-
channels: %w[user:42 news],
|
|
206
|
-
subs: { 'room:1' => { 'data' => { 'welcome' => true } } }
|
|
207
|
-
)
|
|
514
|
+
client = Cent::Client.new(api_endpoint:, http_options: { ca_file: "/etc/ssl/centrifugo-ca.pem",
|
|
515
|
+
cert: client_cert, key: client_key })
|
|
208
516
|
```
|
|
209
517
|
|
|
210
|
-
|
|
518
|
+
`http_client` sends requests another way - another HTTP library, or a fake in
|
|
519
|
+
tests: a callable taking the URL, the headers, the body and the seconds left,
|
|
520
|
+
and returning `[status, headers, body]`. WebMock and VCR see the requests of
|
|
521
|
+
the default transport too, as any Net::HTTP request.
|
|
211
522
|
|
|
212
|
-
|
|
523
|
+
Tracing comes from the HTTP library: OpenTelemetry's Net::HTTP
|
|
524
|
+
instrumentation, `opentelemetry-instrumentation-net_http`, traces every
|
|
525
|
+
request. `on_request` is told of every request - method, commands and how many
|
|
526
|
+
failed, duration, status, error - for metrics without a dependency of cent's:
|
|
213
527
|
|
|
214
528
|
```ruby
|
|
215
|
-
|
|
216
|
-
notary.issue_channel_token(
|
|
217
|
-
sub: '42', channel: 'private-chat',
|
|
218
|
-
info: { role: 'writer' },
|
|
219
|
-
override: { 'presence' => { 'value' => true } }
|
|
220
|
-
)
|
|
529
|
+
on_request = ->(info) { StatsD.measure("centrifugo.#{info.method}", info.duration * 1000) }
|
|
221
530
|
```
|
|
222
531
|
|
|
223
|
-
|
|
532
|
+
It must not raise: what it raises goes to the caller in place of the call's
|
|
533
|
+
outcome.
|
|
534
|
+
|
|
535
|
+
## Shutdown
|
|
224
536
|
|
|
225
|
-
|
|
537
|
+
`client.close` refuses new calls with `ClosedError` and waits for the ones in
|
|
538
|
+
progress, automatic batches included, so that what was accepted is sent before
|
|
539
|
+
the process exits - from `at_exit`, Puma's `on_worker_shutdown` or Sidekiq's
|
|
540
|
+
`on(:shutdown)`. With `timeout:` it raises `Cent::TimeoutError` when calls are
|
|
541
|
+
still in progress then, and they go on. It may be called again, concurrently
|
|
542
|
+
too: each call waits the same way. A `broadcast_with_retry` stops at its next
|
|
543
|
+
attempt, which raises `ClosedError`.
|
|
226
544
|
|
|
227
|
-
|
|
228
|
-
- **Error handling is unchanged for the common case.** `Cent::ResponseError` still exists and is still raised when Centrifugo returns a top-level API error. Existing `rescue Cent::ResponseError => e` blocks using `e.code` / `e.message` keep working. The new additions are typed transport errors — `Cent::TimeoutError`, `Cent::NetworkError`, `Cent::TransportError`, `Cent::UnauthorizedError`, `Cent::DecodeError` — all subclassed under `Cent::Error`.
|
|
229
|
-
- **Keyword arg rename**: `Cent::Notary#issue_channel_token(client:)` → `issue_channel_token(sub:)` to match Centrifugo's standard `sub` JWT claim.
|
|
230
|
-
- **Ruby 3.0+** is required (was 2.5+).
|
|
231
|
-
- **New methods** added for common Centrifugo operations: `subscribe`, `refresh`, `history_remove`, `batch`.
|
|
232
|
-
- **Richer kwargs** on existing methods (e.g. `publish` now accepts `tags`, `skip_history`, `idempotency_key`, `delta`, `version`, `version_epoch`, `b64data`; `history` accepts `limit`, `since`, `reverse`; `channels` accepts `pattern`).
|
|
545
|
+
## HTTP handlers
|
|
233
546
|
|
|
234
|
-
|
|
547
|
+
The proxy handlers and the endpoints client SDKs call are Rack apps, which
|
|
548
|
+
Rails, Sinatra, Hanami and every Rack server mount:
|
|
235
549
|
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
550
|
+
```ruby
|
|
551
|
+
# config/routes.rb
|
|
552
|
+
mount CONNECT_HANDLER, at: "/centrifugo/connect"
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Each takes a block and calls it with what the request asks for, and with a
|
|
556
|
+
`Cent::HTTPRequest`: the method, headers, query and the server's own request
|
|
557
|
+
in `raw` - the Rack env. Mounted in Rails, a handler runs behind the app's
|
|
558
|
+
middleware, so the env carries the session: for the connect proxy, with
|
|
559
|
+
`Cookie` in Centrifugo's `http_headers`, Devise's user is
|
|
560
|
+
`http.raw["warden"].user`.
|
|
561
|
+
|
|
562
|
+
Proxy handlers are mounted in every framework: Centrifugo calls them, and the
|
|
563
|
+
user is in the request. The endpoints client SDKs call - tokens, shared poll
|
|
564
|
+
signatures - suit an app without its own authentication layer: plain Rack,
|
|
565
|
+
Sinatra, Roda, a token service. In Rails, write those as controller actions
|
|
566
|
+
calling `Cent::Token`: see [Rails](#rails).
|
|
567
|
+
|
|
568
|
+
Any other server calls the framework-free core, `handle(method, headers, body)`,
|
|
569
|
+
which returns a `Cent::HTTPResponse`.
|
|
570
|
+
|
|
571
|
+
Pass every handler an `on_error:`, told of the request and the error whenever
|
|
572
|
+
the handler answers 500 or 400. Without it, an error in your block is answered
|
|
573
|
+
with 500 and logged nowhere: the handler catches it to answer Centrifugo, so it
|
|
574
|
+
never reaches your framework's error reporting.
|
|
575
|
+
|
|
576
|
+
## Proxy
|
|
577
|
+
|
|
578
|
+
`Cent::Proxy` is the backend side of Centrifugo's HTTP proxy, which calls your
|
|
579
|
+
application when a client connects, subscribes, publishes or makes an RPC
|
|
580
|
+
call: a handler per proxy type, taking a block.
|
|
581
|
+
|
|
582
|
+
```ruby
|
|
583
|
+
REPORT = ->(_http, err) { logger.error(err) }
|
|
584
|
+
|
|
585
|
+
SUBSCRIBE_HANDLER = Cent::Proxy::SubscribeHandler.new(on_error: REPORT) { |req, http| Proxy.subscribe(req, http) }
|
|
586
|
+
|
|
587
|
+
module Proxy
|
|
588
|
+
# req: a Cent::Proxy::SubscribeRequest; http: the Cent::HTTPRequest, its
|
|
589
|
+
# headers those Centrifugo passed on.
|
|
590
|
+
def self.subscribe(req, _http)
|
|
591
|
+
raise Cent::Proxy::ProxyError.new(403, "permission denied") unless can_read?(req.user, req.channel)
|
|
592
|
+
|
|
593
|
+
{} # a Cent::Proxy::SubscribeResult, or a Hash of its fields
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Return a result to accept the request, raise `Cent::Proxy::ProxyError` to
|
|
599
|
+
reject it with an error the client receives, or `Cent::Proxy::Disconnect` to
|
|
600
|
+
close the client's connection; any other error fails the request, and goes to
|
|
601
|
+
`on_error` to be logged - without `on_error`, nowhere; `on_error` itself must
|
|
602
|
+
not raise. Error codes are in [400, 1999], disconnect codes in [4000, 4999],
|
|
603
|
+
and a disconnect reason is at most 32 bytes. A block returning nil fails the
|
|
604
|
+
request too: Centrifugo would take a response with nothing in it as accepting
|
|
605
|
+
a subscribe, publish, RPC, map publish or map remove as sent, as refusing a
|
|
606
|
+
connect unless anonymous connections are allowed, and as an expired refresh or
|
|
607
|
+
sub refresh. A block calling a method,
|
|
608
|
+
as here, keeps the logic where it can `return` early and be tested alone; see
|
|
609
|
+
[Rails](#rails) for where each part goes there.
|
|
610
|
+
|
|
611
|
+
Keep proxy endpoints reachable by Centrifugo only: whoever can call them can
|
|
612
|
+
claim to be any user. The ways to do it:
|
|
613
|
+
|
|
614
|
+
- Serve them on a network only Centrifugo reaches, not the one your public
|
|
615
|
+
routes are on.
|
|
616
|
+
- Have Centrifugo add a secret header to every proxy request, with the proxy's
|
|
617
|
+
`static_headers` option, and check it in your application before the
|
|
618
|
+
handler - a Rack middleware or a route constraint - answering 403 without it.
|
|
619
|
+
- Use TLS where the traffic leaves a trusted network.
|
|
620
|
+
|
|
621
|
+
A handler's own share of the protection is its bound on a request's body,
|
|
622
|
+
`max_body:` (16 MiB by default), and the deadline it passes on.
|
|
623
|
+
|
|
624
|
+
## Tokens
|
|
625
|
+
|
|
626
|
+
`Cent::Token` issues the JWTs Centrifugo authenticates clients with -
|
|
627
|
+
connection and subscription tokens, with Centrifugo's claims, signed with
|
|
628
|
+
HMAC, RSA (2048 bits or more), ECDSA (P-256, P-384, P-521) or Ed25519 from
|
|
629
|
+
Ruby's OpenSSL - and `Cent::Endpoints` serves
|
|
630
|
+
the endpoints the `getToken` callbacks of client SDKs call, in a Rack app or
|
|
631
|
+
Sinatra (in Rails, a controller action: see [Rails](#rails)):
|
|
632
|
+
|
|
633
|
+
```ruby
|
|
634
|
+
KEY = Cent::Token::Key.hmac(ENV.fetch("CENTRIFUGO_TOKEN_SECRET")) # client.token.hmac_secret_key
|
|
635
|
+
|
|
636
|
+
CONNECTION_TOKEN = Cent::Endpoints::ConnectionToken.new(on_error: REPORT) do |req|
|
|
637
|
+
user = session_user(req.raw)
|
|
638
|
+
raise Cent::Endpoints::ForbiddenError unless user # 403: the client stops asking
|
|
639
|
+
|
|
640
|
+
Cent::Token.connection(KEY, user: user.id.to_s, expires_at: Time.now + 3600)
|
|
641
|
+
end
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
`Cent::Endpoints::ForbiddenError` answers 403, which client SDKs report as
|
|
645
|
+
their `UnauthorizedError`, and stop asking; it is not the API client's
|
|
646
|
+
`Cent::UnauthorizedError`, which means Centrifugo refused your API key.
|
|
647
|
+
|
|
648
|
+
An expiry is required: a `Time`, or `:never` for a token accepted forever.
|
|
649
|
+
`Cent::Endpoints::SubscriptionToken` serves subscription tokens the same way,
|
|
650
|
+
its block given the channel the client asks for. To rotate keys without
|
|
651
|
+
restarting Centrifugo, see [Key rotation with JWKS](#key-rotation-with-jwks).
|
|
652
|
+
|
|
653
|
+
## Key rotation with JWKS
|
|
654
|
+
|
|
655
|
+
By default Centrifugo verifies tokens with a single key from its
|
|
656
|
+
configuration: `client.token.hmac_secret_key`, or an RSA, ECDSA or Ed25519
|
|
657
|
+
public key. Changing that key means restarting Centrifugo, and from that moment
|
|
658
|
+
it refuses every token signed with the old key. To rotate keys without that,
|
|
659
|
+
your application can serve the keys itself, as a JSON Web Key Set (JWKS) at
|
|
660
|
+
Centrifugo's `client.token.jwks_public_endpoint`. Each key in the set has an
|
|
661
|
+
ID, and Centrifugo verifies a token with the key its `kid` header names:
|
|
662
|
+
|
|
663
|
+
```ruby
|
|
664
|
+
# An ID for each key, never reused for another key: Centrifugo caches keys by ID.
|
|
665
|
+
key = Cent::Token::Key.from_pem(File.read("keys/2026-10.pem")).with_key_id("2026-10")
|
|
666
|
+
|
|
667
|
+
JWKS = Cent::Endpoints::JWKS.new(key) # mounted at the URL of jwks_public_endpoint
|
|
668
|
+
token = Cent::Token.connection(key, user: "42", expires_at:)
|
|
240
669
|
```
|
|
241
670
|
|
|
242
|
-
|
|
671
|
+
```json
|
|
672
|
+
{"client": {"token": {"jwks_public_endpoint": "https://app.example.com/centrifugo/jwks"}}}
|
|
673
|
+
```
|
|
243
674
|
|
|
244
|
-
|
|
675
|
+
Only RSA, ECDSA and Ed25519 keys can go in a set: an HMAC secret has no public
|
|
676
|
+
part to publish. Once `jwks_public_endpoint` is set, Centrifugo verifies tokens
|
|
677
|
+
with the set only, and refuses tokens signed with the key it was configured
|
|
678
|
+
with before. So moving to a JWKS is a one-time restart.
|
|
679
|
+
|
|
680
|
+
How Centrifugo reads the set:
|
|
681
|
+
|
|
682
|
+
- when a token names a key ID Centrifugo doesn't know, it fetches the set
|
|
683
|
+
again, at most once every 5 seconds;
|
|
684
|
+
- it keeps the keys it fetched for 5 minutes (an hour before Centrifugo
|
|
685
|
+
v6.10.2), so a key dropped from the set stops working within that time;
|
|
686
|
+
- when the endpoint can't be reached, it keeps using the keys it already has;
|
|
687
|
+
- a token whose key isn't in the set is refused as expired, and the client SDK
|
|
688
|
+
asks for a new token.
|
|
689
|
+
|
|
690
|
+
Rotate a key in three steps. Each step is one deploy of your application, and
|
|
691
|
+
every instance should finish a step before any instance moves to the next:
|
|
692
|
+
|
|
693
|
+
1. **Publish the new key alongside the current one,** and keep signing with
|
|
694
|
+
the current one: `Cent::Endpoints::JWKS.new(current, new)`.
|
|
695
|
+
2. **Sign with the new key.** Centrifugo fetches the set the first time it sees
|
|
696
|
+
the new ID.
|
|
697
|
+
3. **Drop the old key** once every token it signed has expired: wait longer
|
|
698
|
+
than the longest `expires_at` you give.
|
|
699
|
+
|
|
700
|
+
If a key leaks, do all three steps now, without waiting in step 3. Within 5
|
|
701
|
+
minutes Centrifugo refuses new connections with the leaked key's tokens.
|
|
702
|
+
Clients that connected before stay connected until their tokens expire:
|
|
703
|
+
disconnect them with the server API if needed.
|
|
704
|
+
|
|
705
|
+
Centrifugo PRO's API tokens rotate the same way, from a set of their own at
|
|
706
|
+
`http_api.jwks.endpoint` (see [Authentication](#authentication)).
|
|
707
|
+
`Cent::Token::ApiTokenSource.new(current_key: -> { ... })` takes the key to sign
|
|
708
|
+
with from a callable, so it can switch to the new key at step 2. Each API token
|
|
709
|
+
is reused for half its `ttl`, so in step 3 wait at least `ttl` after the
|
|
710
|
+
switch.
|
|
711
|
+
|
|
712
|
+
## Shared poll signatures
|
|
713
|
+
|
|
714
|
+
Clients tracking keys of a [shared poll](https://centrifugal.dev/docs/server/shared_poll)
|
|
715
|
+
channel need a signature from the backend. `Cent::SharedPoll.sign` makes one,
|
|
716
|
+
and `Cent::Endpoints::SharedPollSignature` serves the endpoint a subscription's
|
|
717
|
+
`getSignature` callback calls - in Rails, an action answering
|
|
718
|
+
`{signature:, keys:}` does the same:
|
|
245
719
|
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
|
|
720
|
+
```ruby
|
|
721
|
+
SIGNATURE = Cent::Endpoints::SharedPollSignature.new(on_error: REPORT) do |channel, keys, req|
|
|
722
|
+
user = session_user(req.raw)
|
|
723
|
+
allowed = readable_keys(user, channel, keys) # the rest are revoked
|
|
724
|
+
[allowed, Cent::SharedPoll.sign(SECRET, user: user.id.to_s, channel:, keys: allowed, expires_at: Time.now + 3600)]
|
|
725
|
+
end
|
|
249
726
|
```
|
|
250
727
|
|
|
251
|
-
|
|
728
|
+
Shared poll subscriptions are experimental in Centrifugo, and this part
|
|
729
|
+
follows their changes.
|
|
252
730
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
731
|
+
## Commands for async consumers
|
|
732
|
+
|
|
733
|
+
`Cent::Command.publish(...)` - and a method like it for every API method -
|
|
734
|
+
checks a call as a client would and encodes it, for Centrifugo's
|
|
735
|
+
[async consumers](https://centrifugal.dev/docs/server/consumers): its `method`
|
|
736
|
+
and `payload` for an outbox table, or `to_json` for a Kafka message. `payload`
|
|
737
|
+
is JSON text: the outbox's `payload` column is `jsonb`, and ActiveRecord's json
|
|
738
|
+
types take the object, so parse it - given the text, they store a JSON string,
|
|
739
|
+
which consumers drop:
|
|
740
|
+
|
|
741
|
+
```ruby
|
|
742
|
+
cmd = Cent::Command.publish(channel: "news", data: { text: "hi" })
|
|
743
|
+
CentrifugoOutbox.create!(method: cmd.method, payload: JSON.parse(cmd.payload), partition: 0)
|
|
256
744
|
```
|
|
257
745
|
|
|
258
|
-
|
|
746
|
+
`as_json` gives the command with its payload as a Hash, so a command inside
|
|
747
|
+
another value - rendered by Rails, or in a job's arguments - is an object too.
|
|
748
|
+
|
|
749
|
+
A consumer keeps commands in order only within a partition: to keep a
|
|
750
|
+
channel's commands in order, derive their partition from the channel the same
|
|
751
|
+
way in every writer.
|
|
752
|
+
|
|
753
|
+
## Streaming AI output
|
|
754
|
+
|
|
755
|
+
Publish a model's output into a channel of its own, in a namespace with history
|
|
756
|
+
and recovery, from one thread - each publish waiting for the previous one - in
|
|
757
|
+
chunks of what arrived every 50 ms or so, not a publication per token. See
|
|
758
|
+
[Centrifugo for AI apps](https://centrifugal.dev/docs/getting-started/ai_apps),
|
|
759
|
+
which also covers sizing history for a whole response.
|
|
760
|
+
|
|
761
|
+
## Testing
|
|
762
|
+
|
|
763
|
+
Test code which uses cent without Centrifugo: give the client an
|
|
764
|
+
`http_client` answering as Centrifugo does - each method at `/api/<method>`,
|
|
765
|
+
its reply `{"result": {...}}` or `{"error": {"code": ..., "message": ...}}`:
|
|
766
|
+
|
|
767
|
+
```ruby
|
|
768
|
+
REPLIES = {
|
|
769
|
+
"publish" => { result: { offset: 1, epoch: "e" } },
|
|
770
|
+
# One response per channel, in order.
|
|
771
|
+
"broadcast" => { result: { responses: [{ result: { offset: 1 } },
|
|
772
|
+
{ error: { code: 102, message: "unknown channel" } }] } },
|
|
773
|
+
# One reply per command, in order.
|
|
774
|
+
"batch" => { replies: [{ publish: { offset: 1 } }, { error: { code: 102, message: "unknown channel" } }] }
|
|
775
|
+
}.freeze
|
|
776
|
+
|
|
777
|
+
centrifugo = ->(url, _headers, _body, _timeout) { [200, {}, JSON.generate(REPLIES.fetch(url.split("/").last))] }
|
|
778
|
+
client = Cent::Client.new(api_endpoint: "http://centrifugo.test/api", http_client: centrifugo)
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
A test double of your own returns cent's results and raises its errors,
|
|
782
|
+
partial failures included:
|
|
783
|
+
|
|
784
|
+
```ruby
|
|
785
|
+
ok = Cent::ChannelResult.new(channel: "news", result: Cent::PublishResult.new(offset: 1))
|
|
786
|
+
bad = Cent::ChannelResult.new(channel: "unknown:x", error: Cent::ApiError.for_code(102, "unknown channel"))
|
|
787
|
+
raise Cent::BroadcastFailedChannelsError.new([bad], 2, Cent::BroadcastResult.new(channels: [ok, bad], replied: true))
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
and for a batch, `Cent::Pending.resolved(result)` or
|
|
791
|
+
`Cent::Pending.resolved(error:)` as each command's outcome, with
|
|
792
|
+
`Cent::BatchFailedCommandsError.new(failed, total, Cent::BatchResult.new(replied: true, failed:))`
|
|
793
|
+
of `Cent::FailedCommand.new(index:, method:, error:)`.
|
|
794
|
+
|
|
795
|
+
## More
|
|
796
|
+
|
|
797
|
+
- The API reference, every class, method and field, from the YARD docs:
|
|
798
|
+
[rubydoc.info/gems/cent](https://rubydoc.info/gems/cent), or `yard server`
|
|
799
|
+
locally.
|
|
800
|
+
- Docs for agents and LLMs: [centrifugal.dev/llms.txt](https://centrifugal.dev/llms.txt),
|
|
801
|
+
an index of the current (v6) documentation.
|
|
802
|
+
- Development: see [AGENTS.md](AGENTS.md); `bundle exec rake check` runs what
|
|
803
|
+
CI does but the integration tests, `bundle exec rake test:integration` those.
|
|
259
804
|
|
|
260
|
-
MIT
|
|
805
|
+
MIT license. Centrifugo PRO, which the methods marked "Centrifugo PRO only"
|
|
806
|
+
call, is commercial software under its own
|
|
807
|
+
[license](https://centrifugal.dev/license).
|