cent 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,281 +1,807 @@
1
- [![Code Climate](https://codeclimate.com/github/centrifugal/centrifuge-ruby/badges/gpa.svg)](https://codeclimate.com/github/centrifugal/centrifuge-ruby)
2
- ![Build Status](https://github.com/centrifugal/rubycent/actions/workflows/main.yml/badge.svg)
1
+ # cent
3
2
 
4
- [Centrifugo HTTP API](https://centrifugal.dev/docs/server/server_api) client in Ruby.
5
-
6
- ## Installation
7
-
8
- Add this line to your application's Gemfile:
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.
9
8
 
10
9
  ```ruby
11
- gem 'cent'
10
+ gem "cent", "~> 5.0" # json is the only dependency
12
11
  ```
13
12
 
14
- And then execute:
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 |
15
21
 
16
- $ bundle
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.
17
26
 
18
- Or install it yourself as:
27
+ ## Example
19
28
 
20
- $ gem install cent
29
+ ```ruby
30
+ require "cent"
21
31
 
22
- ## Usage
32
+ client = Cent::Client.new(api_endpoint: "http://localhost:8000/api", api_key: ENV.fetch("CENTRIFUGO_API_KEY"))
23
33
 
24
- Functionality is split between two classes:
25
- - `Cent::Client` to call API methods
26
- - `Cent::Notary` to generate tokens
34
+ res = client.publish(channel: "news", data: { text: "hello" })
35
+ puts "published at offset #{res.offset}"
36
+ ```
27
37
 
28
- ### Token Generation
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:
29
74
 
30
75
  ```ruby
31
- notary = Cent::Notary.new(secret: 'secret')
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
81
+ )
82
+ at_exit { CENTRIFUGO.close(timeout: 5) }
32
83
  ```
33
84
 
34
- By default it uses HS256 to generate tokens, but you can set it to one of the HMAC, RSA or ECDSA family.
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
- #### RSA
93
+ Publishing from a model or a job, and rendering a result:
37
94
 
38
95
  ```ruby
39
- secret = OpenSSL::PKey::RSA.new(File.read('./rsa_secret.pem'))
40
- notary = Cent::Notary.new(secret: secret, algorithm: 'RS256')
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
102
+
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
41
108
  ```
42
109
 
43
- #### ECDSA
110
+ Token endpoints, as plain actions behind your authentication - the token
111
+ functions are all you need from cent here:
44
112
 
45
113
  ```ruby
46
- secret = OpenSSL::PKey::EC.new(File.read('./ecdsa_secret.pem'))
47
- notary = Cent::Notary.new(secret: secret, algorithm: 'ES256')
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
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"
48
148
  ```
49
149
 
50
- #### Connection token
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.
51
153
 
52
- When connecting to Centrifugo client [must provide connection JWT token](https://centrifugal.github.io/centrifugo/server/authentication/) with several predefined credential claims.
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:
53
158
 
54
159
  ```ruby
55
- notary.issue_connection_token(sub: '42')
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
56
166
 
57
- #=> "eyJhbGciOiJIUzI1NiJ9..."
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)
172
+
173
+ { info: { "role" => Room.role(req.user, req.channel) } }
174
+ end
175
+ end
176
+
177
+ # config/routes.rb
178
+ mount SUBSCRIBE_HANDLER, at: "/centrifugo/subscribe"
58
179
  ```
59
180
 
60
- `info` and `exp` are supported as well:
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:
61
194
 
62
195
  ```ruby
63
- notary.issue_connection_token(sub: '42', info: { scope: 'admin' }, exp: 1629050099)
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
199
+ end
200
+ client.device_list_all(filter: { users: ["42"] }, page_timeout: 30).each_slice(50) { |devices| notify(devices) }
201
+ ```
202
+
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
64
216
 
65
- #=> "eyJhbGciOiJIUzI1NiJ9..."
217
+ ```ruby
218
+ pos = client.publish(channel: "news", data: { text: "hi" }).position
219
+ client.history(channel: "news", since: pos, limit: 100)
66
220
  ```
67
221
 
68
- ### Private channel token
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
69
227
 
70
- All channels starting with $ considered private and require a **channel token** to subscribe.
71
- Private channel subscription token is also JWT([see the claims](https://centrifugal.github.io/centrifugo/server/private_channels/))
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:
72
232
 
73
233
  ```ruby
74
- notary.issue_channel_token(client: 'client', channel: 'channel', exp: 1629050099, info: { scope: 'admin' })
234
+ client.publish(channel: "news", data: { text: "hi", tags: %w[a b] })
75
235
 
76
- #=> "eyJhbGciOiJIUzI1NiJ9..."
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
77
239
  ```
78
-
79
- ### API Client
80
240
 
81
- A client requires your Centrifugo API key to execute all requests.
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:
82
249
 
83
250
  ```ruby
84
- client = Cent::Client.new(api_key: 'key')
251
+ client.publish(channel: "news", data: JSON::Fragment.new(row["payload_json"]))
252
+ client.publish(channel: "news", data: { meta:, doc: JSON::Fragment.new(json) })
85
253
  ```
86
254
 
87
- you can customize your connection as you wish, just remember it's a [Faraday::Connection](https://lostisland.github.io/faraday/usage/#customizing-faradayconnection) instance:
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:
274
+
275
+ ```ruby
276
+ client.info(timeout: 0.5)
277
+ ```
278
+
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:
88
311
 
89
312
  ```ruby
90
- client = Cent::Client.new(api_key: 'key', endpoint: 'https://centrifu.go/api') do |connection|
91
- connection.headers['User-Agent'] = 'Centrifugo Ruby Client'
92
- connection.options.open_timeout = 3
93
- connection.options.timeout = 7
94
- connection.adapter :typhoeus
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
95
325
  end
96
326
  ```
97
327
 
98
- #### Publish
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.
99
341
 
100
- Send data to the channel.
342
+ ## Broadcast and batch
101
343
 
102
- [publish](https://centrifugal.dev/docs/server/server_api#publish)
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:
103
346
 
104
347
  ```ruby
105
- client.publish(channel: 'chat', data: 'hello') # => {}
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
106
356
  ```
107
357
 
108
- #### Broadcast
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.
109
364
 
110
- Sends data to multiple channels.
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:
369
+
370
+ ```ruby
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
376
+ ```
111
377
 
112
- [broadcast](https://centrifugal.dev/docs/server/server_api#broadcast)
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.
113
447
 
114
448
  ```ruby
115
- client.broadcast(channels: ["clients", "staff"], data: 'hello') # => {}
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))
116
451
  ```
117
452
 
118
- #### Unsubscribe
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.
119
459
 
120
- Unsubscribe user from channel. Receives to arguments: channel and user (user ID you want to unsubscribe)
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.
121
464
 
122
- [unsubscribe](https://centrifugal.dev/docs/server/server_api#unsubscribe)
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:
123
468
 
124
469
  ```ruby
125
- client.unsubscribe(channel: 'chat', user: '1') # => {}
470
+ batch_context = lambda do |callers, &send_batch|
471
+ Rails.logger.tagged("batch of #{callers.filter_map { it[:request_id] }.join(",")}") { send_batch.call }
472
+ end
126
473
  ```
127
474
 
128
- #### Disconnect
475
+ ## Authentication
129
476
 
130
- Allows to disconnect user by it's ID. Receives user ID as an argument.
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.
131
481
 
132
- [disconnect](https://centrifugal.dev/docs/server/server_api#disconnect)
482
+ To sign the tokens yourself, `Cent::Token::ApiTokenSource` issues them and reuses
483
+ each for half its lifetime:
133
484
 
134
485
  ```ruby
135
- # Disconnect user with `id = 1`
136
- #
137
- client.disconnect(user: '1') # => {}
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))
138
492
  ```
139
493
 
140
- #### Presence
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.
503
+
504
+ The API keys rotate as client keys do: see [Key rotation with JWKS](#key-rotation-with-jwks).
141
505
 
142
- Get channel presence information(all clients currently subscribed on this channel).
506
+ Rejected credentials make every call raise `Cent::UnauthorizedError`.
143
507
 
144
- [presence](https://centrifugal.dev/docs/server/server_api#presence)
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`:
145
512
 
146
513
  ```ruby
147
- client.presence(channel: 'chat')
148
-
149
- # {
150
- # 'result' => {
151
- # 'presence' => {
152
- # 'c54313b2-0442-499a-a70c-051f8588020f' => {
153
- # 'client' => 'c54313b2-0442-499a-a70c-051f8588020f',
154
- # 'user' => '42'
155
- # },
156
- # 'adad13b1-0442-499a-a70c-051f858802da' => {
157
- # 'client' => 'adad13b1-0442-499a-a70c-051f858802da',
158
- # 'user' => '42'
159
- # }
160
- # }
161
- # }
162
- # }
163
- ```
164
-
165
- #### Presence stats
166
-
167
- Get short channel presence information.
168
-
169
- [presence_stats](https://centrifugal.dev/docs/server/server_api#presence_stats)
514
+ client = Cent::Client.new(api_endpoint:, http_options: { ca_file: "/etc/ssl/centrifugo-ca.pem",
515
+ cert: client_cert, key: client_key })
516
+ ```
517
+
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.
522
+
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:
170
527
 
171
528
  ```ruby
172
- client.presence_stats(channel: 'chat')
173
-
174
- # {
175
- # "result" => {
176
- # "num_clients" => 0,
177
- # "num_users" => 0
178
- # }
179
- # }
529
+ on_request = ->(info) { StatsD.measure("centrifugo.#{info.method}", info.duration * 1000) }
180
530
  ```
181
531
 
182
- #### History
532
+ It must not raise: what it raises goes to the caller in place of the call's
533
+ outcome.
534
+
535
+ ## Shutdown
536
+
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`.
183
544
 
184
- Get channel history information (list of last messages published into channel).
545
+ ## HTTP handlers
185
546
 
186
- [history](https://centrifugal.dev/docs/server/server_api#history)
547
+ The proxy handlers and the endpoints client SDKs call are Rack apps, which
548
+ Rails, Sinatra, Hanami and every Rack server mount:
187
549
 
188
550
  ```ruby
189
- client.history(channel: 'chat')
190
-
191
- # {
192
- # 'result' => {
193
- # 'publications' => [
194
- # {
195
- # 'data' => {
196
- # 'text' => 'hello'
197
- # }
198
- # },
199
- # {
200
- # 'data' => {
201
- # 'text' => 'hi!'
202
- # }
203
- # }
204
- # ]
205
- # }
206
- # }
551
+ # config/routes.rb
552
+ mount CONNECT_HANDLER, at: "/centrifugo/connect"
207
553
  ```
208
554
 
209
- #### Channels
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`.
210
570
 
211
- Get list of active(with one or more subscribers) channels.
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.
212
575
 
213
- [channels](https://centrifugal.dev/docs/server/server_api#channels)
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.
214
581
 
215
582
  ```ruby
216
- client.channels
217
-
218
- # {
219
- # 'result' => {
220
- # 'channels' => [
221
- # 'chat'
222
- # ]
223
- # }
224
- # }
225
- ```
583
+ REPORT = ->(_http, err) { logger.error(err) }
584
+
585
+ SUBSCRIBE_HANDLER = Cent::Proxy::SubscribeHandler.new(on_error: REPORT) { |req, http| Proxy.subscribe(req, http) }
226
586
 
227
- #### Info
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)
228
592
 
229
- Get running Centrifugo nodes information.
593
+ {} # a Cent::Proxy::SubscribeResult, or a Hash of its fields
594
+ end
595
+ end
596
+ ```
230
597
 
231
- [info](https://centrifugal.dev/docs/server/server_api#info)
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)):
232
632
 
233
633
  ```ruby
234
- client.info
235
-
236
- # {
237
- # 'result' => {
238
- # 'nodes' => [
239
- # {
240
- # 'name' => 'Alexanders-MacBook-Pro.local_8000',
241
- # 'num_channels' => 0,
242
- # 'num_clients' => 0,
243
- # 'num_users' => 0,
244
- # 'uid' => 'f844a2ed-5edf-4815-b83c-271974003db9',
245
- # 'uptime' => 0,
246
- # 'version' => ''
247
- # }
248
- # ]
249
- # }
250
- # }
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
251
642
  ```
252
643
 
253
- ### Errors
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).
254
652
 
255
- Network errors are not wrapped and will raise `Faraday::ClientError`.
653
+ ## Key rotation with JWKS
256
654
 
257
- In cases when Centrifugo returns 200 with `error` key in the body we wrap it and return custom error:
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:
258
662
 
259
663
  ```ruby
260
- # Raised when response from Centrifugo contains any error as result of API command execution.
261
- #
262
- begin
263
- client.publish(channel: 'channel', data: { foo: :bar })
264
- rescue Cent::ResponseError => ex
265
- ex.message # => "Invalid format"
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:)
669
+ ```
670
+
671
+ ```json
672
+ {"client": {"token": {"jwks_public_endpoint": "https://app.example.com/centrifugo/jwks"}}}
673
+ ```
674
+
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:
719
+
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)]
266
725
  end
267
726
  ```
268
727
 
269
- ## Development
728
+ Shared poll subscriptions are experimental in Centrifugo, and this part
729
+ follows their changes.
730
+
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)
744
+ ```
745
+
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
270
754
 
271
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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.
272
760
 
273
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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
+ ```
274
789
 
275
- ## Contributing
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:)`.
276
794
 
277
- Bug reports and pull requests are welcome on GitHub at https://github.com/centrifugal/rubycent. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
795
+ ## More
278
796
 
279
- ## License
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.
280
804
 
281
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/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).