tonic-rails 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b39b4d814c854c16c5a1f9c7ec05415402fdbb49716846bc3883442931f5165
4
- data.tar.gz: f4dcedf8d10ec7cd0786232a7637e192ec5dd7746ee15542322465d56bc11436
3
+ metadata.gz: 338a8abe3329b9c34aeb471e4b1b152696ae0a371ded6e375f2d42807775a8a2
4
+ data.tar.gz: 3ed3aedfabbde45d5ef7ed7598a2937f88232a0becaf93a0e3f17a9f8073503c
5
5
  SHA512:
6
- metadata.gz: b1b5406e16afb900514ae85ae11350d7a7546c0a08ed27c3c1042622de85b4ea9ac692185e3a0d27792f95ca36e61a1f2ff0b5729bf8e38eb645ed9e45c011ee
7
- data.tar.gz: 7b3b7386f1c8342cf0c25fce33ea1564c38e1be027788b7b919377354b9a9a53f3c58338e334185d8dd0377f261b9775fadeee1cc4a2b0ac627764737329fd2b
6
+ metadata.gz: eefe02d7dbed4dae8bf2d9af2743823877f8468d933b406f0e76f2b848de789c9dee0a95778804186c025aeffc60ea24dc28edc6f5645100e5c7d25dfd1c93ec
7
+ data.tar.gz: 2b9eaa3a25ca8fe7091bb761ddaf86a15564f187252045a81a24e8ce96f50da50479f2b2af108ccd5804c3c5b4ee3055000f54624fdc8f97a0aa223d8df421bd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - Client: opt-in retries, per client or per call (`retries: true` or a
15
+ Hash of `max_attempts`, `codes`, `initial_backoff`, `max_backoff` and
16
+ `multiplier`), following gRPC's retry design. Only calls the server
17
+ never started on are retried: a failed connection, or a status sent
18
+ without response headers. Waits are random with exponential caps, stop
19
+ before the deadline and follow `grpc-retry-pushback-ms`. Each retry is
20
+ logged, and `request_rpc.tonic_rails` reports `:attempts`. Closes #39.
21
+
22
+ ### Changed
23
+
24
+ - The server answers calls it turns away before a handler runs (shutting
25
+ down, dispatch queue full, malformed requests) with a Trailers-Only
26
+ response: the status in the headers and no body. Clients that follow
27
+ gRPC's retry rules can tell those calls never started and retry them
28
+ safely.
29
+
10
30
  ## [0.6.0] - 2026-10-11
11
31
 
12
32
  ### Added
@@ -166,7 +186,8 @@ First public release.
166
186
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
167
187
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
168
188
 
169
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.6.0...HEAD
189
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.7.0...HEAD
190
+ [0.7.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.6.0...v0.7.0
170
191
  [0.6.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.5.0...v0.6.0
171
192
  [0.5.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...v0.5.0
172
193
  [0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
data/Cargo.lock CHANGED
@@ -1107,7 +1107,7 @@ dependencies = [
1107
1107
 
1108
1108
  [[package]]
1109
1109
  name = "tonic_rails_native"
1110
- version = "0.6.0"
1110
+ version = "0.7.0"
1111
1111
  dependencies = [
1112
1112
  "axum",
1113
1113
  "base64",
data/README.md CHANGED
@@ -507,6 +507,68 @@ 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
+ `retries: true` tries a call up to 3 times when it fails with
522
+ `UNAVAILABLE`. A Hash changes any of these:
523
+
524
+ | Field | Default | |
525
+ |---|---|---|
526
+ | `max_attempts` | 3 | attempts in all, from 2 to 5 |
527
+ | `codes` | `[:unavailable]` | status codes worth retrying |
528
+ | `initial_backoff` | 0.1 | seconds; the cap on the first wait |
529
+ | `max_backoff` | 1 | seconds; the cap on any wait |
530
+ | `multiplier` | 2 | how much the cap grows per attempt |
531
+
532
+ The rules come from gRPC's own retry design, so they avoid the usual
533
+ mistakes of a hand-written retry loop:
534
+
535
+ - **A call is retried only if the server never started on it.** That
536
+ means the connection failed (refused, reset, a pod restarting), or the
537
+ server answered with only a status and no response headers. Servers do
538
+ that when they turn a call away before any handler runs: a
539
+ tonic-rails server that's shutting down or whose queue is full, or a
540
+ proxy with no healthy backend. Once response headers arrive, the call
541
+ is never repeated, even if it then fails with a code you listed.
542
+ - **Waits are random.** Each one is anywhere from zero up to a cap that
543
+ starts at `initial_backoff` and grows by `multiplier` per attempt, up to
544
+ `max_backoff`. Clients that failed together don't retry together.
545
+ - **The deadline covers every attempt.** If the next attempt couldn't
546
+ start before the deadline, the client stops and raises the last
547
+ failure.
548
+ - **The server can steer.** A `grpc-retry-pushback-ms` trailer sets the
549
+ wait, and a negative one stops retries.
550
+ - A server stream is retried only before its first message.
551
+
552
+ Which calls are safe to retry? With the default `[:unavailable]`, nearly
553
+ all of them. By gRPC convention, `UNAVAILABLE` means the call can be tried
554
+ again, and from a tonic-rails server it's retried only when no handler
555
+ ran. For a tonic-rails server, `:resource_exhausted` is worth adding too:
556
+ a full dispatch queue returns it before any handler runs.
557
+
558
+ Take more care with codes a handler might raise after doing some work,
559
+ like `:aborted`. A tonic-rails server sends headers before it runs a
560
+ handler, so those calls still won't be retried. Other servers may answer
561
+ with only a status after a handler ran, so list those codes only for
562
+ methods that are safe to run twice.
563
+
564
+ Each retry is logged as a warning, through the client's `logger:` (by
565
+ default `Tonic::Rails.configuration.logger`, which is the Rails logger in
566
+ a Rails app):
567
+
568
+ ```
569
+ tonic-rails: /demo.v1.ItemService/GetItem failed with UNAVAILABLE: ... (attempt 1 of 3); retrying in 64ms
570
+ ```
571
+
510
572
  ### Metadata
511
573
 
512
574
  `metadata:` on a call, and on the client for every call, works like
@@ -549,6 +611,8 @@ the client is created, not on the first call.
549
611
  | `metadata` | `{}` | sent with every call |
550
612
  | `max_message_size` | 4 MiB | largest message sent or received |
551
613
  | `connect_timeout` | 5 | seconds to wait for a connection |
614
+ | `retries` | `false` | retry calls the server never started on; see [Retries](#retries) |
615
+ | `logger` | `Tonic::Rails.configuration.logger` | where retries are logged |
552
616
 
553
617
  ### Fork safety
554
618
 
@@ -830,7 +894,8 @@ When a call fails, `event.payload[:exception_object]` is the
830
894
  unexpected, that error's `cause` is the original exception.
831
895
 
832
896
  Calls made with `Tonic::Rails::Client` publish `request_rpc.tonic_rails`,
833
- with the same payload plus `:address`, the server called. For a
897
+ with the same payload plus `:address`, the server called, and
898
+ `:attempts`, how many times it was tried. For a
834
899
  server-streaming call, the event covers the whole stream, including the
835
900
  time your block spends on each message.
836
901
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tonic_rails_native"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  publish = false
@@ -12,7 +12,6 @@ use std::task::{Context, Poll};
12
12
  use std::time::Instant;
13
13
 
14
14
  use bytes::Bytes;
15
- use http::HeaderMap;
16
15
  use http_body::{Body, Frame};
17
16
  use tokio::sync::mpsc;
18
17
  use tonic::{Code, Status};
@@ -71,35 +70,6 @@ impl ServerStats {
71
70
  }
72
71
  }
73
72
 
74
- /// Trailers-only error response for requests rejected before dispatch.
75
- pub struct ErrorBody {
76
- trailers: Option<HeaderMap>,
77
- }
78
-
79
- impl ErrorBody {
80
- pub fn new(code: Code, message: &str) -> Self {
81
- Self {
82
- trailers: Some(status_trailers(code as u32, message)),
83
- }
84
- }
85
- }
86
-
87
- impl Body for ErrorBody {
88
- type Data = Bytes;
89
- type Error = Status;
90
-
91
- fn poll_frame(
92
- mut self: Pin<&mut Self>,
93
- _cx: &mut Context<'_>,
94
- ) -> Poll<Option<Result<Frame<Bytes>, Status>>> {
95
- Poll::Ready(self.trailers.take().map(|t| Ok(Frame::trailers(t))))
96
- }
97
-
98
- fn is_end_stream(&self) -> bool {
99
- self.trailers.is_none()
100
- }
101
- }
102
-
103
73
  /// Streaming response body fed by the Ruby handler through an mpsc
104
74
  /// channel. Each `Data` message becomes a gRPC frame; `End` (or the
105
75
  /// deadline) produces the trailers.
@@ -232,6 +202,7 @@ impl Drop for ResponseBody {
232
202
  #[cfg(test)]
233
203
  mod tests {
234
204
  use super::*;
205
+ use http::HeaderMap;
235
206
  use std::task::Waker;
236
207
 
237
208
  type PolledFrame = Poll<Option<Result<Frame<Bytes>, Status>>>;
@@ -262,16 +233,6 @@ mod tests {
262
233
  }
263
234
  }
264
235
 
265
- #[test]
266
- fn error_body_yields_trailers_once() {
267
- let mut body = ErrorBody::new(Code::NotFound, "missing");
268
- let t = trailers(poll(&mut body));
269
- assert_eq!(t["grpc-status"], "5");
270
- assert_eq!(t["grpc-message"], "missing");
271
- assert!(matches!(poll(&mut body), Poll::Ready(None)));
272
- assert!(body.is_end_stream());
273
- }
274
-
275
236
  #[test]
276
237
  fn response_body_streams_data_then_ok() {
277
238
  let (tx, rx) = mpsc::channel(4);
@@ -124,6 +124,9 @@ struct Outcome {
124
124
  /// The failure happened here (a broken connection, a timeout), not
125
125
  /// on the server.
126
126
  local: bool,
127
+ /// Response headers arrived, so the server has started on the call
128
+ /// and retrying it could repeat its work.
129
+ committed: bool,
127
130
  details: Bytes,
128
131
  response: Option<Bytes>,
129
132
  headers: HeaderMap,
@@ -136,6 +139,7 @@ impl Outcome {
136
139
  code: Code::Ok,
137
140
  message: String::new(),
138
141
  local: false,
142
+ committed: true,
139
143
  details: Bytes::new(),
140
144
  response,
141
145
  headers,
@@ -146,11 +150,13 @@ impl Outcome {
146
150
  fn error(code: Code, message: impl Into<String>) -> Self {
147
151
  Self {
148
152
  local: true,
149
- ..Self::status(Status::new(code, message), HeaderMap::new())
153
+ ..Self::status(Status::new(code, message), None)
150
154
  }
151
155
  }
152
156
 
153
- fn status(status: Status, headers: HeaderMap) -> Self {
157
+ /// `headers` are the response headers, if they arrived before the
158
+ /// status.
159
+ fn status(status: Status, headers: Option<HeaderMap>) -> Self {
154
160
  // A status with a source is a local transport failure, not one the
155
161
  // server sent. The source holds the cause (e.g. "Connection
156
162
  // refused"), and tonic reports broken connections as UNKNOWN or
@@ -170,24 +176,26 @@ impl Outcome {
170
176
  code,
171
177
  message,
172
178
  local,
179
+ committed: headers.is_some(),
173
180
  details: Bytes::copy_from_slice(status.details()),
174
181
  response: None,
175
- headers,
182
+ headers: headers.unwrap_or_default(),
176
183
  trailers: status.metadata().clone().into_headers(),
177
184
  }
178
185
  }
179
186
 
180
- /// `[code, message, response, headers, trailers, details]`, with
181
- /// metadata as `[name, value]` pairs in wire form (`-bin` values
187
+ /// `[code, message, response, headers, trailers, details, committed]`,
188
+ /// with metadata as `[name, value]` pairs in wire form (`-bin` values
182
189
  /// base64-encoded).
183
190
  fn into_ruby(self, ruby: &Ruby) -> Result<RArray, Error> {
184
- let result = ruby.ary_new_capa(6);
191
+ let result = ruby.ary_new_capa(7);
185
192
  result.push(self.code as u32)?;
186
193
  result.push(ruby.str_new(&self.message))?;
187
194
  result.push(self.response.map(|bytes| ruby.str_from_slice(&bytes)))?;
188
195
  result.push(metadata_pairs(ruby, &self.headers)?)?;
189
196
  result.push(metadata_pairs(ruby, &self.trailers)?)?;
190
197
  result.push((!self.details.is_empty()).then(|| ruby.str_from_slice(&self.details)))?;
198
+ result.push(self.committed)?;
191
199
  Ok(result)
192
200
  }
193
201
  }
@@ -252,8 +260,10 @@ async fn start(args: CallArgs) -> Result<(HeaderMap, Streaming<Bytes>), Box<Outc
252
260
  let (headers, stream, _) = response.into_parts();
253
261
  Ok((headers.into_headers(), stream))
254
262
  }
255
- // Includes trailers-only responses, whose status comes in the headers.
256
- Err(status) => Err(Box::new(Outcome::status(status, HeaderMap::new()))),
263
+ // Includes trailers-only responses, whose status comes in the
264
+ // headers. They don't commit the call: gRPC retries treat a server
265
+ // that answers with only a status as not having started on it.
266
+ Err(status) => Err(Box::new(Outcome::status(status, None))),
257
267
  }
258
268
  }
259
269
 
@@ -261,7 +271,7 @@ async fn start(args: CallArgs) -> Result<(HeaderMap, Streaming<Bytes>), Box<Outc
261
271
  async fn finish(stream: &mut Streaming<Bytes>, headers: &HeaderMap) -> Result<HeaderMap, Box<Outcome>> {
262
272
  match stream.trailers().await {
263
273
  Ok(trailers) => Ok(trailers.map(MetadataMap::into_headers).unwrap_or_default()),
264
- Err(status) => Err(Box::new(Outcome::status(status, headers.clone()))),
274
+ Err(status) => Err(Box::new(Outcome::status(status, Some(headers.clone())))),
265
275
  }
266
276
  }
267
277
 
@@ -272,7 +282,7 @@ async fn unary(args: CallArgs) -> Outcome {
272
282
  };
273
283
  let message = match stream.message().await {
274
284
  Ok(message) => message,
275
- Err(status) => return Outcome::status(status, headers),
285
+ Err(status) => return Outcome::status(status, Some(headers)),
276
286
  };
277
287
  let trailers = match finish(&mut stream, &headers).await {
278
288
  Ok(trailers) => trailers,
@@ -300,7 +310,7 @@ async fn server_stream(args: CallArgs, tx: mpsc::Sender<Bytes>) -> Outcome {
300
310
  }
301
311
  }
302
312
  Ok(None) => break,
303
- Err(status) => return Outcome::status(status, headers),
313
+ Err(status) => return Outcome::status(status, Some(headers)),
304
314
  }
305
315
  }
306
316
  match finish(&mut stream, &headers).await {
@@ -23,8 +23,8 @@ use tonic::Code;
23
23
  use tonic::body::Body as TonicBody;
24
24
  use tonic::transport::server::{TcpConnectInfo, TlsConnectInfo};
25
25
 
26
- use crate::body::{ErrorBody, ResponseBody, ServerStats};
27
- use crate::framing::{MessageReader, append_metadata, parse_grpc_timeout};
26
+ use crate::body::{ResponseBody, ServerStats};
27
+ use crate::framing::{MessageReader, append_metadata, parse_grpc_timeout, status_trailers};
28
28
  use crate::gvl::{DispatchContext, Peer, RequestError, StreamMessage};
29
29
  use crate::log::Level;
30
30
 
@@ -84,9 +84,16 @@ fn grpc_response(body: TonicBody, compressed: bool) -> Response<TonicBody> {
84
84
  }
85
85
 
86
86
  impl DispatchInner {
87
+ /// A Trailers-Only response (the status in the headers, no body) for a
88
+ /// call rejected before it reached a handler. Clients may retry those
89
+ /// safely, and gRPC retries count on the form to tell them apart.
87
90
  fn reject(&self, path: &str, start: Instant, code: Code, message: &str) -> Response<TonicBody> {
88
91
  self.stats.record(path, code as u32, message, start, true);
89
- grpc_response(TonicBody::new(ErrorBody::new(code, message)), false)
92
+ let mut response = grpc_response(TonicBody::empty(), false);
93
+ response
94
+ .headers_mut()
95
+ .extend(status_trailers(code as u32, message));
96
+ response
90
97
  }
91
98
 
92
99
  async fn handle(&self, req: Request<axum::body::Body>) -> Response<TonicBody> {
@@ -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,9 @@ 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
+ #
17
20
  # Stubs are built from the protobuf descriptors that
18
21
  # `tonic_rails:compile_protos` (protoc --ruby_out) generates; no
19
22
  # `_services_pb.rb` is needed. A failed call raises
@@ -29,12 +32,20 @@ module Tonic
29
32
 
30
33
  DEFAULT_MAX_MESSAGE_SIZE = 4 * 1024 * 1024
31
34
 
35
+ # Trailer a server sends to ask for a retry after so many
36
+ # milliseconds, or (with a negative value) for none.
37
+ RETRY_PUSHBACK = "grpc-retry-pushback-ms"
38
+ private_constant :RETRY_PUSHBACK
39
+
32
40
  # @return [String] "host:port"
33
41
  attr_reader :address
34
42
 
35
43
  # @return [Numeric, nil] default deadline in seconds
36
44
  attr_reader :deadline
37
45
 
46
+ # @return [RetryPolicy, nil] how calls are retried, unless they say otherwise
47
+ attr_reader :retries
48
+
38
49
  # @param address [String] "host:port"
39
50
  # @param tls [Boolean] false for plaintext, which must be explicit
40
51
  # @param tls_ca_path [String, nil] CA certificates (PEM) to trust instead
@@ -48,9 +59,13 @@ module Tonic
48
59
  # @param metadata [Hash] sent with every call (see Tonic::Rails::Metadata)
49
60
  # @param max_message_size [Integer] largest message sent or received, in bytes
50
61
  # @param connect_timeout [Numeric] seconds to wait for a connection
62
+ # @param retries [RetryPolicy, Hash, Boolean] retry calls the server
63
+ # never started on (see RetryPolicy); off by default
64
+ # @param logger [Logger, nil] where retries are logged; defaults to
65
+ # Tonic::Rails.configuration.logger
51
66
  def initialize(address, tls: true, tls_ca_path: nil, tls_cert_path: nil, tls_key_path: nil,
52
67
  tls_server_name: nil, deadline: nil, metadata: {}, max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
53
- connect_timeout: 5)
68
+ connect_timeout: 5, retries: false, logger: nil)
54
69
  Tonic::Rails.native_extension!
55
70
  validate_address!(address)
56
71
  validate_tls!(tls, tls_cert_path, tls_key_path)
@@ -62,6 +77,8 @@ module Tonic
62
77
 
63
78
  @address = address
64
79
  @deadline = deadline
80
+ @retries = RetryPolicy.from(retries)
81
+ @logger = logger
65
82
  @metadata = Metadata.encode(metadata)
66
83
  @stubs = {}
67
84
  tls_options = [read_pem(tls_ca_path, "CA"), read_pem(tls_cert_path, "certificate"),
@@ -106,14 +123,16 @@ module Tonic
106
123
 
107
124
  # Make a unary call. Stubs call this; use them instead.
108
125
  # @api private
109
- def unary(path, input, output, request, deadline: nil, metadata: {}, return_call: false)
110
- message = request_message(path, input, request)
126
+ def unary(path, input, output, request, deadline: nil, metadata: {}, return_call: false, retries: nil)
127
+ bytes = input.encode(request_message(path, input, request))
111
128
  metadata = call_metadata(metadata)
112
- timeout_ms = remaining_ms(deadline_at(deadline))
129
+ deadline_at = deadline_at(deadline)
130
+ policy = retry_policy(retries)
113
131
 
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)
132
+ Instrumentation.instrument_request(path, address, metadata) do |payload|
133
+ code, error_message, response, headers, trailers, details = with_retries(path, policy, deadline_at, payload) do
134
+ @native.unary(path, bytes, metadata, remaining_ms(deadline_at))
135
+ end
117
136
  headers = decode_metadata(headers)
118
137
  trailers = decode_metadata(trailers)
119
138
  check_status!(code, error_message, trailers, details)
@@ -125,28 +144,25 @@ module Tonic
125
144
 
126
145
  # Start a server-streaming call. Stubs call this; use them instead.
127
146
  # @api private
128
- def server_streaming(path, input, output, request, deadline: nil, metadata: {})
129
- message = request_message(path, input, request)
147
+ def server_streaming(path, input, output, request, deadline: nil, metadata: {}, retries: nil)
148
+ bytes = input.encode(request_message(path, input, request))
130
149
  metadata = call_metadata(metadata)
131
150
  # Fixed now, so the stream keeps the deadline of the RPC this
132
151
  # thread is handling even if it's read later.
133
152
  deadline_at = deadline_at(deadline)
153
+ policy = retry_policy(retries)
134
154
 
135
155
  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
156
+ Instrumentation.instrument_request(path, address, metadata) do |payload|
157
+ # A message arrives only after the response headers, which
158
+ # commit the call, so an attempt that yielded anything is never
159
+ # retried.
160
+ code, error_message, _, headers, trailers, details = with_retries(path, policy, deadline_at, payload) do
161
+ read_stream(@native.server_streaming(path, bytes, metadata, remaining_ms(deadline_at)), output, path, &block)
149
162
  end
163
+ trailers = decode_metadata(trailers)
164
+ stream.finish(decode_metadata(headers), trailers)
165
+ check_status!(code, error_message, trailers, details)
150
166
  end
151
167
  end
152
168
  end
@@ -222,6 +238,75 @@ module Tonic
222
238
  (remaining * 1000).ceil
223
239
  end
224
240
 
241
+ def retry_policy(retries)
242
+ retries.nil? ? @retries : RetryPolicy.from(retries)
243
+ end
244
+
245
+ # Yield each message of a native stream, then return its result.
246
+ def read_stream(native, output, path)
247
+ while (bytes = native.next)
248
+ yield decode_response(output, bytes, path)
249
+ end
250
+ native.result
251
+ ensure
252
+ native.close
253
+ end
254
+
255
+ # Run attempts of a call (the block returns a native result) until one
256
+ # succeeds or shouldn't be retried; return its result.
257
+ def with_retries(path, policy, deadline_at, payload)
258
+ attempt = 1
259
+ loop do
260
+ payload[:attempts] = attempt if payload
261
+ result = yield
262
+ delay = retry_delay(policy, attempt, result, deadline_at)
263
+ if delay.nil?
264
+ log_gave_up(path, attempt, result) if attempt > 1 && !result[0].zero?
265
+ return result
266
+ end
267
+
268
+ log_retry(path, attempt, policy.max_attempts, result, delay)
269
+ sleep(delay)
270
+ attempt += 1
271
+ end
272
+ end
273
+
274
+ # Seconds to wait before retrying, or nil not to retry. The server
275
+ # can ask for a delay, or for no retry, with grpc-retry-pushback-ms.
276
+ def retry_delay(policy, attempt, result, deadline_at)
277
+ code, _, _, _, trailers, _, committed = result
278
+ return if policy.nil? || code.zero? || committed || attempt >= policy.max_attempts || !policy.retry?(code)
279
+
280
+ pushback = trailers.assoc(RETRY_PUSHBACK)&.last
281
+ delay = if pushback.nil? then policy.backoff(attempt)
282
+ elsif pushback.match?(/\A\d+\z/) then pushback.to_i / 1000.0
283
+ end
284
+ return if delay.nil?
285
+ return if deadline_at && Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay >= deadline_at
286
+
287
+ delay
288
+ end
289
+
290
+ def logger
291
+ @logger || Tonic::Rails.configuration.logger
292
+ end
293
+
294
+ def log_retry(path, attempt, max_attempts, result, delay)
295
+ logger&.warn(format("tonic-rails: %<path>s failed with %<status>s (attempt %<attempt>d of %<max>d); " \
296
+ "retrying in %<ms>dms", path: path, status: status_text(result), attempt: attempt,
297
+ max: max_attempts, ms: (delay * 1000).round))
298
+ end
299
+
300
+ def log_gave_up(path, attempts, result)
301
+ logger&.warn("tonic-rails: #{path} failed with #{status_text(result)} after #{attempts} attempts")
302
+ end
303
+
304
+ def status_text(result)
305
+ code, message = result
306
+ text = RpcError::CODE_NAMES.fetch(code, code).to_s.upcase
307
+ message.empty? ? text : "#{text}: #{message}"
308
+ end
309
+
225
310
  def check_status!(code, message, trailers, details)
226
311
  return if code.zero?
227
312
 
@@ -282,16 +367,18 @@ module Tonic
282
367
  input = method.input_type.msgclass
283
368
  output = method.output_type.msgclass
284
369
  if method.server_streaming
285
- define_method(name) do |request = nil, deadline: nil, metadata: {}, **fields|
370
+ define_method(name) do |request = nil, deadline: nil, metadata: {}, retries: nil, **fields|
286
371
  request = rpc_request(name, request, fields)
287
- @client.server_streaming(path, input, output, request, deadline: deadline, metadata: metadata)
372
+ @client.server_streaming(path, input, output, request, deadline: deadline, metadata: metadata,
373
+ retries: retries)
288
374
  end
289
375
  else
290
376
  # `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|
377
+ define_method(name) do |request = nil, deadline: nil, metadata: {}, return_call: false, retries: nil,
378
+ **fields|
292
379
  request = rpc_request(name, request, fields)
293
380
  @client.unary(path, input, output, request, deadline: deadline, metadata: metadata,
294
- return_call: return_call)
381
+ return_call: return_call, retries: retries)
295
382
  end
296
383
  end
297
384
  end
@@ -369,3 +456,5 @@ module Tonic
369
456
  end
370
457
  end
371
458
  end
459
+
460
+ 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.7.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Tanous
@@ -92,6 +92,7 @@ files:
92
92
  - lib/tonic-rails.rb
93
93
  - lib/tonic/rails/base.rb
94
94
  - lib/tonic/rails/client.rb
95
+ - lib/tonic/rails/client/retry_policy.rb
95
96
  - lib/tonic/rails/cluster.rb
96
97
  - lib/tonic/rails/configuration.rb
97
98
  - lib/tonic/rails/health.rb