tonic-rails 0.6.0-x86_64-linux-gnu → 0.8.0-x86_64-linux-gnu

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69a7ded16d01d0e9fc54dd2a24eecc85b36aa49362c4984dd5f4f234429e24a4
4
- data.tar.gz: 267553cdb615846fd3e3c39c0e3417154cce6e7cbdb470b42b4a7229717908d1
3
+ metadata.gz: 53065348a46d7db7f65fe581260768aa899eb661e7bbd6ad5dfe6b87473523ed
4
+ data.tar.gz: 4b00c60ff0bafe215288329b21aa41fcc7c780c6cc589172ef8915c87ca079c8
5
5
  SHA512:
6
- metadata.gz: 50ad4688167a303a40c8b64bca81de9301c997c9bc29e2d355cd773bcbec1696f029c357ec343fc96fa1eca9b1e936ba40e8e602a015dc49a804f6428090e425
7
- data.tar.gz: d14c5ab406b10f432169da4917c44647cbca451c1e6d1b607b9ee1fab8d1038edfe998f2f7b597edf94404323a0654f9d6e98b8c18d682c747c7cf0b4bf042e1
6
+ metadata.gz: 742b224bdcd584ef89677e7e072a07c55c975604ed64eb9aa369996e71588b8e980a5503441ddd8787d3c1bc348af0b5d28ce20159c3e06a8930ab712c42c031
7
+ data.tar.gz: e3ef7c4c71295ce9c03f17b74570234feae6e27d9a1a278478e0c851e9f98f5d8ea82c4e4c1272cf97a52858aa83af0e47abeac9b541c08afd5f1eacc9890a69
data/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-10-12
11
+
12
+ ### Added
13
+
14
+ - Client: `method_retries:` sets retry policies per service
15
+ (`"demo.v1.ItemService"`) or per method
16
+ (`"demo.v1.ItemService/GetItem"`), like gRPC's service config. The most
17
+ specific setting wins: the call's `retries:`, then the method, then the
18
+ service, then the client's `retries:`.
19
+
20
+ ### Fixed
21
+
22
+ - Windows: the process could crash with a segfault under load (#41). The
23
+ extension's locks spun and then called `Sleep`, which the mingw linker
24
+ bound to the `Sleep` that Ruby's DLL exports instead of the system's,
25
+ and Ruby's version crashes on threads Ruby doesn't own. Locks now come
26
+ from Rust's standard library, which doesn't spin through `Sleep`, the
27
+ build binds `Sleep` to kernel32, and CI checks that the Windows build
28
+ imports nothing but Ruby's C API from Ruby's DLL. 0.6.0 and 0.7.0 crashed in about 1 in 5 runs of the test suite;
29
+ earlier versions could crash in the same way, but rarely.
30
+
31
+ ## [0.7.0] - 2026-10-11
32
+
33
+ ### Added
34
+
35
+ - Client: opt-in retries, per client or per call (`retries: true` or a
36
+ Hash of `max_attempts`, `codes`, `initial_backoff`, `max_backoff` and
37
+ `multiplier`), following gRPC's retry design. Only calls the server
38
+ never started on are retried: a failed connection, or a status sent
39
+ without response headers. Waits are random with exponential caps, stop
40
+ before the deadline and follow `grpc-retry-pushback-ms`. Each retry is
41
+ logged, and `request_rpc.tonic_rails` reports `:attempts`. Closes #39.
42
+
43
+ ### Changed
44
+
45
+ - The server answers calls it turns away before a handler runs (shutting
46
+ down, dispatch queue full, malformed requests) with a Trailers-Only
47
+ response: the status in the headers and no body. Clients that follow
48
+ gRPC's retry rules can tell those calls never started and retry them
49
+ safely.
50
+
10
51
  ## [0.6.0] - 2026-10-11
11
52
 
12
53
  ### Added
@@ -166,7 +207,9 @@ First public release.
166
207
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
167
208
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
168
209
 
169
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.6.0...HEAD
210
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.8.0...HEAD
211
+ [0.8.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.7.0...v0.8.0
212
+ [0.7.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.6.0...v0.7.0
170
213
  [0.6.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.5.0...v0.6.0
171
214
  [0.5.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...v0.5.0
172
215
  [0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
data/README.md CHANGED
@@ -507,6 +507,86 @@ If a handler lets the error propagate, its caller receives the same
507
507
  status, message, details and trailing metadata. Rescue it and raise your
508
508
  own error when those are internal.
509
509
 
510
+ ### Retries
511
+
512
+ Retries are off unless you turn them on, for a client or a single call:
513
+
514
+ ```ruby
515
+ ITEMS = Tonic::Rails::Client.new("items.internal:50051", deadline: 2, retries: true)
516
+
517
+ items.get_item({ id: 1 }, retries: false) # not this call
518
+ items.get_item({ id: 1 }, retries: { max_attempts: 5 }) # or differently
519
+ ```
520
+
521
+ Different methods often need different policies: a read can retry more
522
+ eagerly than a write. `method_retries:` sets them per service or per
523
+ method, by their protobuf names:
524
+
525
+ ```ruby
526
+ ITEMS = Tonic::Rails::Client.new(
527
+ "items.internal:50051",
528
+ retries: true, # every call...
529
+ method_retries: {
530
+ "demo.v1.ItemService/GetItem" => { max_attempts: 5 }, # ...but this method,
531
+ "demo.v1.AdminService" => false, # and this whole service
532
+ },
533
+ )
534
+ ```
535
+
536
+ The most specific setting wins: the call's own `retries:`, then its
537
+ method's entry, then its service's, then the client's `retries:`.
538
+
539
+ `retries: true` tries a call up to 3 times when it fails with
540
+ `UNAVAILABLE`. A Hash changes any of these:
541
+
542
+ | Field | Default | |
543
+ |---|---|---|
544
+ | `max_attempts` | 3 | attempts in all, from 2 to 5 |
545
+ | `codes` | `[:unavailable]` | status codes worth retrying |
546
+ | `initial_backoff` | 0.1 | seconds; the cap on the first wait |
547
+ | `max_backoff` | 1 | seconds; the cap on any wait |
548
+ | `multiplier` | 2 | how much the cap grows per attempt |
549
+
550
+ The rules come from gRPC's own retry design, so they avoid the usual
551
+ mistakes of a hand-written retry loop:
552
+
553
+ - **A call is retried only if the server never started on it.** That
554
+ means the connection failed (refused, reset, a pod restarting), or the
555
+ server answered with only a status and no response headers. Servers do
556
+ that when they turn a call away before any handler runs: a
557
+ tonic-rails server that's shutting down or whose queue is full, or a
558
+ proxy with no healthy backend. Once response headers arrive, the call
559
+ is never repeated, even if it then fails with a code you listed.
560
+ - **Waits are random.** Each one is anywhere from zero up to a cap that
561
+ starts at `initial_backoff` and grows by `multiplier` per attempt, up to
562
+ `max_backoff`. Clients that failed together don't retry together.
563
+ - **The deadline covers every attempt.** If the next attempt couldn't
564
+ start before the deadline, the client stops and raises the last
565
+ failure.
566
+ - **The server can steer.** A `grpc-retry-pushback-ms` trailer sets the
567
+ wait, and a negative one stops retries.
568
+ - A server stream is retried only before its first message.
569
+
570
+ Which calls are safe to retry? With the default `[:unavailable]`, nearly
571
+ all of them. By gRPC convention, `UNAVAILABLE` means the call can be tried
572
+ again, and from a tonic-rails server it's retried only when no handler
573
+ ran. For a tonic-rails server, `:resource_exhausted` is worth adding too:
574
+ a full dispatch queue returns it before any handler runs.
575
+
576
+ Take more care with codes a handler might raise after doing some work,
577
+ like `:aborted`. A tonic-rails server sends headers before it runs a
578
+ handler, so those calls still won't be retried. Other servers may answer
579
+ with only a status after a handler ran, so list those codes only for
580
+ methods that are safe to run twice.
581
+
582
+ Each retry is logged as a warning, through the client's `logger:` (by
583
+ default `Tonic::Rails.configuration.logger`, which is the Rails logger in
584
+ a Rails app):
585
+
586
+ ```
587
+ tonic-rails: /demo.v1.ItemService/GetItem failed with UNAVAILABLE: ... (attempt 1 of 3); retrying in 64ms
588
+ ```
589
+
510
590
  ### Metadata
511
591
 
512
592
  `metadata:` on a call, and on the client for every call, works like
@@ -549,6 +629,9 @@ the client is created, not on the first call.
549
629
  | `metadata` | `{}` | sent with every call |
550
630
  | `max_message_size` | 4 MiB | largest message sent or received |
551
631
  | `connect_timeout` | 5 | seconds to wait for a connection |
632
+ | `retries` | `false` | retry calls the server never started on; see [Retries](#retries) |
633
+ | `method_retries` | `{}` | retries per service or method, by name |
634
+ | `logger` | `Tonic::Rails.configuration.logger` | where retries are logged |
552
635
 
553
636
  ### Fork safety
554
637
 
@@ -830,7 +913,8 @@ When a call fails, `event.payload[:exception_object]` is the
830
913
  unexpected, that error's `cause` is the original exception.
831
914
 
832
915
  Calls made with `Tonic::Rails::Client` publish `request_rpc.tonic_rails`,
833
- with the same payload plus `:address`, the server called. For a
916
+ with the same payload plus `:address`, the server called, and
917
+ `:attempts`, how many times it was tried. For a
834
918
  server-streaming call, the event covers the whole stream, including the
835
919
  time your block spends on each message.
836
920
 
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ class Client
6
+ # When and how often a call is retried, following gRPC's retry design
7
+ # (A6). Pass one, a Hash of its fields or `true` for the defaults as
8
+ # `retries:` to Client.new or to a single call.
9
+ #
10
+ # Tonic::Rails::Client.new("items.internal:50051", retries: true)
11
+ # Tonic::Rails::Client.new("items.internal:50051", retries: { max_attempts: 4 })
12
+ # items.get_item({ id: 1 }, retries: false)
13
+ #
14
+ # Only calls the server hasn't started on are retried: the connection
15
+ # failed, or the server answered with a status and no response
16
+ # headers. Each wait is random, up to a cap that grows from
17
+ # `initial_backoff` by `multiplier` per attempt to `max_backoff`.
18
+ # Retrying stops when the next attempt couldn't start before the
19
+ # call's deadline.
20
+ RetryPolicy = Data.define(:max_attempts, :codes, :initial_backoff, :max_backoff, :multiplier) do
21
+ # Defaults for the fields left out.
22
+ def self.defaults
23
+ { max_attempts: 3, codes: [:unavailable], initial_backoff: 0.1, max_backoff: 1, multiplier: 2 }
24
+ end
25
+
26
+ # @param option [RetryPolicy, Hash, true, false, nil]
27
+ # @return [RetryPolicy, nil] nil for no retries
28
+ def self.from(option)
29
+ case option
30
+ when nil, false then nil
31
+ when true then new(**defaults)
32
+ when Hash then new(**defaults, **option)
33
+ when self then option
34
+ else raise ArgumentError, "retries must be true, false, a Hash or a #{name}, got #{option.class}"
35
+ end
36
+ end
37
+
38
+ # @param max_attempts [Integer] attempts in all, from 2 to 5. gRPC
39
+ # caps it at 5, so a misconfigured client can't multiply an
40
+ # outage's traffic by more than that.
41
+ # @param codes [Array<Symbol, Integer>] status codes worth retrying
42
+ # @param initial_backoff [Numeric] seconds; the cap on the first wait
43
+ # @param max_backoff [Numeric] seconds; the cap on any wait
44
+ # @param multiplier [Numeric] how much the cap grows per attempt
45
+ def initialize(max_attempts:, codes:, initial_backoff:, max_backoff:, multiplier:)
46
+ unless max_attempts.is_a?(Integer) && max_attempts.between?(2, 5)
47
+ raise ArgumentError, "max_attempts must be an Integer from 2 to 5"
48
+ end
49
+
50
+ codes = Array(codes).map { |code| RpcError.normalize_code(code) }.uniq.freeze
51
+ raise ArgumentError, "codes must name at least one status code other than OK" if codes.empty? || codes.include?(0)
52
+
53
+ [[:initial_backoff, initial_backoff], [:max_backoff, max_backoff]].each do |field, value|
54
+ raise ArgumentError, "#{field} must be a positive number of seconds" unless value.is_a?(Numeric) && value.positive?
55
+ end
56
+ raise ArgumentError, "multiplier must be at least 1" unless multiplier.is_a?(Numeric) && multiplier >= 1
57
+
58
+ super
59
+ end
60
+
61
+ # @param code [Integer]
62
+ def retry?(code)
63
+ codes.include?(code)
64
+ end
65
+
66
+ # Seconds to wait after failed attempt number `attempt` (from 1).
67
+ def backoff(attempt)
68
+ rand * [initial_backoff * (multiplier**(attempt - 1)), max_backoff].min
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -14,6 +14,10 @@ module Tonic
14
14
  # item = items.get_item({ id: 1 }, deadline: 0.5, metadata: { "x-request-id" => request_id })
15
15
  # items.list_items(category: "books").each { |item| ... }
16
16
  #
17
+ # Retries are opt-in: `retries: true` (or a Hash; see RetryPolicy) on
18
+ # the client or a call retries calls the server never started on.
19
+ # `method_retries:` sets them per service or method.
20
+ #
17
21
  # Stubs are built from the protobuf descriptors that
18
22
  # `tonic_rails:compile_protos` (protoc --ruby_out) generates; no
19
23
  # `_services_pb.rb` is needed. A failed call raises
@@ -29,12 +33,24 @@ module Tonic
29
33
 
30
34
  DEFAULT_MAX_MESSAGE_SIZE = 4 * 1024 * 1024
31
35
 
36
+ # Trailer a server sends to ask for a retry after so many
37
+ # milliseconds, or (with a negative value) for none.
38
+ RETRY_PUSHBACK = "grpc-retry-pushback-ms"
39
+ private_constant :RETRY_PUSHBACK
40
+
32
41
  # @return [String] "host:port"
33
42
  attr_reader :address
34
43
 
35
44
  # @return [Numeric, nil] default deadline in seconds
36
45
  attr_reader :deadline
37
46
 
47
+ # @return [RetryPolicy, nil] how calls are retried, unless they say otherwise
48
+ attr_reader :retries
49
+
50
+ # @return [Hash{String=>RetryPolicy, nil}] retry policies by service or
51
+ # method name, which take precedence over #retries
52
+ attr_reader :method_retries
53
+
38
54
  # @param address [String] "host:port"
39
55
  # @param tls [Boolean] false for plaintext, which must be explicit
40
56
  # @param tls_ca_path [String, nil] CA certificates (PEM) to trust instead
@@ -48,9 +64,17 @@ module Tonic
48
64
  # @param metadata [Hash] sent with every call (see Tonic::Rails::Metadata)
49
65
  # @param max_message_size [Integer] largest message sent or received, in bytes
50
66
  # @param connect_timeout [Numeric] seconds to wait for a connection
67
+ # @param retries [RetryPolicy, Hash, Boolean] retry calls the server
68
+ # never started on (see RetryPolicy); off by default
69
+ # @param method_retries [Hash{String=>RetryPolicy, Hash, Boolean}]
70
+ # retries for some services ("demo.v1.ItemService") or methods
71
+ # ("demo.v1.ItemService/GetItem"), instead of `retries`. A method's
72
+ # entry beats its service's.
73
+ # @param logger [Logger, nil] where retries are logged; defaults to
74
+ # Tonic::Rails.configuration.logger
51
75
  def initialize(address, tls: true, tls_ca_path: nil, tls_cert_path: nil, tls_key_path: nil,
52
76
  tls_server_name: nil, deadline: nil, metadata: {}, max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
53
- connect_timeout: 5)
77
+ connect_timeout: 5, retries: false, method_retries: {}, logger: nil)
54
78
  Tonic::Rails.native_extension!
55
79
  validate_address!(address)
56
80
  validate_tls!(tls, tls_cert_path, tls_key_path)
@@ -62,6 +86,9 @@ module Tonic
62
86
 
63
87
  @address = address
64
88
  @deadline = deadline
89
+ @retries = RetryPolicy.from(retries)
90
+ @method_retries = method_retry_policies(method_retries)
91
+ @logger = logger
65
92
  @metadata = Metadata.encode(metadata)
66
93
  @stubs = {}
67
94
  tls_options = [read_pem(tls_ca_path, "CA"), read_pem(tls_cert_path, "certificate"),
@@ -106,14 +133,16 @@ module Tonic
106
133
 
107
134
  # Make a unary call. Stubs call this; use them instead.
108
135
  # @api private
109
- def unary(path, input, output, request, deadline: nil, metadata: {}, return_call: false)
110
- message = request_message(path, input, request)
136
+ def unary(path, input, output, request, deadline: nil, metadata: {}, return_call: false, retries: nil)
137
+ bytes = input.encode(request_message(path, input, request))
111
138
  metadata = call_metadata(metadata)
112
- timeout_ms = remaining_ms(deadline_at(deadline))
139
+ deadline_at = deadline_at(deadline)
140
+ policy = retry_policy(path, retries)
113
141
 
114
- Instrumentation.instrument_request(path, address, metadata) do
115
- code, error_message, response, headers, trailers, details =
116
- @native.unary(path, input.encode(message), metadata, timeout_ms)
142
+ Instrumentation.instrument_request(path, address, metadata) do |payload|
143
+ code, error_message, response, headers, trailers, details = with_retries(path, policy, deadline_at, payload) do
144
+ @native.unary(path, bytes, metadata, remaining_ms(deadline_at))
145
+ end
117
146
  headers = decode_metadata(headers)
118
147
  trailers = decode_metadata(trailers)
119
148
  check_status!(code, error_message, trailers, details)
@@ -125,28 +154,25 @@ module Tonic
125
154
 
126
155
  # Start a server-streaming call. Stubs call this; use them instead.
127
156
  # @api private
128
- def server_streaming(path, input, output, request, deadline: nil, metadata: {})
129
- message = request_message(path, input, request)
157
+ def server_streaming(path, input, output, request, deadline: nil, metadata: {}, retries: nil)
158
+ bytes = input.encode(request_message(path, input, request))
130
159
  metadata = call_metadata(metadata)
131
160
  # Fixed now, so the stream keeps the deadline of the RPC this
132
161
  # thread is handling even if it's read later.
133
162
  deadline_at = deadline_at(deadline)
163
+ policy = retry_policy(path, retries)
134
164
 
135
165
  ServerStream.new(path) do |stream, block|
136
- timeout_ms = remaining_ms(deadline_at)
137
- Instrumentation.instrument_request(path, address, metadata) do
138
- native = @native.server_streaming(path, input.encode(message), metadata, timeout_ms)
139
- begin
140
- while (bytes = native.next)
141
- block.call(decode_response(output, bytes, path))
142
- end
143
- code, error_message, _, headers, trailers, details = native.result
144
- trailers = decode_metadata(trailers)
145
- stream.finish(decode_metadata(headers), trailers)
146
- check_status!(code, error_message, trailers, details)
147
- ensure
148
- native.close
166
+ Instrumentation.instrument_request(path, address, metadata) do |payload|
167
+ # A message arrives only after the response headers, which
168
+ # commit the call, so an attempt that yielded anything is never
169
+ # retried.
170
+ code, error_message, _, headers, trailers, details = with_retries(path, policy, deadline_at, payload) do
171
+ read_stream(@native.server_streaming(path, bytes, metadata, remaining_ms(deadline_at)), output, path, &block)
149
172
  end
173
+ trailers = decode_metadata(trailers)
174
+ stream.finish(decode_metadata(headers), trailers)
175
+ check_status!(code, error_message, trailers, details)
150
176
  end
151
177
  end
152
178
  end
@@ -222,6 +248,102 @@ module Tonic
222
248
  (remaining * 1000).ceil
223
249
  end
224
250
 
251
+ METHOD_NAME = %r{\A[A-Za-z_][\w.]*(/[A-Za-z_]\w*)?\z}
252
+ private_constant :METHOD_NAME
253
+
254
+ def method_retry_policies(option)
255
+ raise ArgumentError, "method_retries must be a Hash" unless option.is_a?(Hash)
256
+
257
+ option.to_h do |name, retries|
258
+ name = name.to_s.delete_prefix("/")
259
+ unless name.match?(METHOD_NAME)
260
+ raise ArgumentError, "method_retries keys must be \"package.Service\" or \"package.Service/Method\", " \
261
+ "got #{name.inspect}"
262
+ end
263
+
264
+ [name, RetryPolicy.from(retries)]
265
+ end.freeze
266
+ end
267
+
268
+ # The most specific policy for a call: its own, its method's, its
269
+ # service's, then the client's.
270
+ def retry_policy(path, retries)
271
+ return RetryPolicy.from(retries) unless retries.nil?
272
+ return @retries if @method_retries.empty?
273
+
274
+ method = path.delete_prefix("/")
275
+ service = method.split("/", 2).first
276
+ return @method_retries[method] if @method_retries.key?(method)
277
+ return @method_retries[service] if @method_retries.key?(service)
278
+
279
+ @retries
280
+ end
281
+
282
+ # Yield each message of a native stream, then return its result.
283
+ def read_stream(native, output, path)
284
+ while (bytes = native.next)
285
+ yield decode_response(output, bytes, path)
286
+ end
287
+ native.result
288
+ ensure
289
+ native.close
290
+ end
291
+
292
+ # Run attempts of a call (the block returns a native result) until one
293
+ # succeeds or shouldn't be retried; return its result.
294
+ def with_retries(path, policy, deadline_at, payload)
295
+ attempt = 1
296
+ loop do
297
+ payload[:attempts] = attempt if payload
298
+ result = yield
299
+ delay = retry_delay(policy, attempt, result, deadline_at)
300
+ if delay.nil?
301
+ log_gave_up(path, attempt, result) if attempt > 1 && !result[0].zero?
302
+ return result
303
+ end
304
+
305
+ log_retry(path, attempt, policy.max_attempts, result, delay)
306
+ sleep(delay)
307
+ attempt += 1
308
+ end
309
+ end
310
+
311
+ # Seconds to wait before retrying, or nil not to retry. The server
312
+ # can ask for a delay, or for no retry, with grpc-retry-pushback-ms.
313
+ def retry_delay(policy, attempt, result, deadline_at)
314
+ code, _, _, _, trailers, _, committed = result
315
+ return if policy.nil? || code.zero? || committed || attempt >= policy.max_attempts || !policy.retry?(code)
316
+
317
+ pushback = trailers.assoc(RETRY_PUSHBACK)&.last
318
+ delay = if pushback.nil? then policy.backoff(attempt)
319
+ elsif pushback.match?(/\A\d+\z/) then pushback.to_i / 1000.0
320
+ end
321
+ return if delay.nil?
322
+ return if deadline_at && Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay >= deadline_at
323
+
324
+ delay
325
+ end
326
+
327
+ def logger
328
+ @logger || Tonic::Rails.configuration.logger
329
+ end
330
+
331
+ def log_retry(path, attempt, max_attempts, result, delay)
332
+ logger&.warn(format("tonic-rails: %<path>s failed with %<status>s (attempt %<attempt>d of %<max>d); " \
333
+ "retrying in %<ms>dms", path: path, status: status_text(result), attempt: attempt,
334
+ max: max_attempts, ms: (delay * 1000).round))
335
+ end
336
+
337
+ def log_gave_up(path, attempts, result)
338
+ logger&.warn("tonic-rails: #{path} failed with #{status_text(result)} after #{attempts} attempts")
339
+ end
340
+
341
+ def status_text(result)
342
+ code, message = result
343
+ text = RpcError::CODE_NAMES.fetch(code, code).to_s.upcase
344
+ message.empty? ? text : "#{text}: #{message}"
345
+ end
346
+
225
347
  def check_status!(code, message, trailers, details)
226
348
  return if code.zero?
227
349
 
@@ -282,16 +404,18 @@ module Tonic
282
404
  input = method.input_type.msgclass
283
405
  output = method.output_type.msgclass
284
406
  if method.server_streaming
285
- define_method(name) do |request = nil, deadline: nil, metadata: {}, **fields|
407
+ define_method(name) do |request = nil, deadline: nil, metadata: {}, retries: nil, **fields|
286
408
  request = rpc_request(name, request, fields)
287
- @client.server_streaming(path, input, output, request, deadline: deadline, metadata: metadata)
409
+ @client.server_streaming(path, input, output, request, deadline: deadline, metadata: metadata,
410
+ retries: retries)
288
411
  end
289
412
  else
290
413
  # `get_item(id: 1)` works as well as `get_item({ id: 1 })`.
291
- define_method(name) do |request = nil, deadline: nil, metadata: {}, return_call: false, **fields|
414
+ define_method(name) do |request = nil, deadline: nil, metadata: {}, return_call: false, retries: nil,
415
+ **fields|
292
416
  request = rpc_request(name, request, fields)
293
417
  @client.unary(path, input, output, request, deadline: deadline, metadata: metadata,
294
- return_call: return_call)
418
+ return_call: return_call, retries: retries)
295
419
  end
296
420
  end
297
421
  end
@@ -369,3 +493,5 @@ module Tonic
369
493
  end
370
494
  end
371
495
  end
496
+
497
+ require "tonic/rails/client/retry_policy"
@@ -25,8 +25,8 @@ module Tonic
25
25
  EVENT = "process_rpc.tonic_rails"
26
26
 
27
27
  # Outgoing calls made with Tonic::Rails::Client publish
28
- # "request_rpc.tonic_rails", with the same payload plus :address,
29
- # the server called.
28
+ # "request_rpc.tonic_rails", with the same payload plus :address, the
29
+ # server called, and :attempts, how many times it was tried.
30
30
  REQUEST_EVENT = "request_rpc.tonic_rails"
31
31
 
32
32
  module_function
@@ -41,16 +41,17 @@ module Tonic
41
41
  publish(REQUEST_EVENT, path, metadata, address: address, &)
42
42
  end
43
43
 
44
+ # Yields the payload, or nil when nothing is listening.
44
45
  # @api private
45
46
  def publish(event, path, metadata, **extra)
46
47
  # Without subscribers, skip building the payload: this runs on
47
48
  # every RPC.
48
- return yield unless ActiveSupport::Notifications.notifier.listening?(event)
49
+ return yield(nil) unless ActiveSupport::Notifications.notifier.listening?(event)
49
50
 
50
51
  service, rpc = path.delete_prefix("/").split("/", 2)
51
52
  payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil, **extra }
52
53
  ActiveSupport::Notifications.instrument(event, payload) do
53
- result = yield
54
+ result = yield(payload)
54
55
  payload[:code] = 0
55
56
  payload[:status] = :ok
56
57
  result
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
data/lib/tonic-rails.rb CHANGED
@@ -81,11 +81,13 @@ module Tonic
81
81
  # Precompiled platform gems ship one binary per Ruby minor version.
82
82
  ruby_version = RUBY_VERSION[/\d+\.\d+/]
83
83
  require "tonic/rails/#{ruby_version}/tonic_rails_native"
84
- rescue LoadError
84
+ rescue LoadError => versioned
85
85
  begin
86
86
  require "tonic/rails/tonic_rails_native"
87
87
  rescue LoadError => e
88
- @native_load_error = e
88
+ # A precompiled binary that exists but won't load says more than
89
+ # the missing fallback does.
90
+ @native_load_error = versioned.message.include?("cannot load such file") ? e : versioned
89
91
  end
90
92
  end
91
93
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tonic-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Matt Tanous
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-11 00:00:00.000000000 Z
11
+ date: 2026-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -68,6 +68,7 @@ files:
68
68
  - lib/tonic/rails/4.0/tonic_rails_native.so
69
69
  - lib/tonic/rails/base.rb
70
70
  - lib/tonic/rails/client.rb
71
+ - lib/tonic/rails/client/retry_policy.rb
71
72
  - lib/tonic/rails/cluster.rb
72
73
  - lib/tonic/rails/configuration.rb
73
74
  - lib/tonic/rails/health.rb