tonic-rails 0.7.0 → 0.8.1

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: 338a8abe3329b9c34aeb471e4b1b152696ae0a371ded6e375f2d42807775a8a2
4
- data.tar.gz: 3ed3aedfabbde45d5ef7ed7598a2937f88232a0becaf93a0e3f17a9f8073503c
3
+ metadata.gz: cd7973d8a7b1550f1f483912639ec1cbec562297c3368c8751c45780e8472ec1
4
+ data.tar.gz: 385c74ea7100d36e7c128df6f1feda64fb7ac7c3b11a3905afa35d5ac275af26
5
5
  SHA512:
6
- metadata.gz: eefe02d7dbed4dae8bf2d9af2743823877f8468d933b406f0e76f2b848de789c9dee0a95778804186c025aeffc60ea24dc28edc6f5645100e5c7d25dfd1c93ec
7
- data.tar.gz: 2b9eaa3a25ca8fe7091bb761ddaf86a15564f187252045a81a24e8ce96f50da50479f2b2af108ccd5804c3c5b4ee3055000f54624fdc8f97a0aa223d8df421bd
6
+ metadata.gz: d491ec733022fca2b343ca3bb738273945c62614619266a1bccc8e8823f9f377628a2e6b4fb9a4b9c367b623e4c523ace580e18e4094fd66b07f94b7b55a0b97
7
+ data.tar.gz: 58a0d85837cd8f68f063d934b029761ab4239d30b9e2807bed5638b798ac1b18d2efe3e88c20c255dbb5c18aa3a5fee79ba86a8056d785eced8690ce4a6d0c17
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.1] - 2026-10-13
11
+
12
+ ### Fixed
13
+
14
+ - Client: a call whose deadline passed could raise `CANCELLED` ("Timeout
15
+ expired") instead of `DEADLINE_EXCEEDED` (#48), mostly on Windows.
16
+ tonic enforces the deadline too, and when its timer fired first the
17
+ call ended with its status. Calls now raise `DEADLINE_EXCEEDED`
18
+ whichever timer fires first.
19
+
20
+ ## [0.8.0] - 2026-10-12
21
+
22
+ ### Added
23
+
24
+ - Client: `method_retries:` sets retry policies per service
25
+ (`"demo.v1.ItemService"`) or per method
26
+ (`"demo.v1.ItemService/GetItem"`), like gRPC's service config. The most
27
+ specific setting wins: the call's `retries:`, then the method, then the
28
+ service, then the client's `retries:`.
29
+
30
+ ### Fixed
31
+
32
+ - Windows: the process could crash with a segfault under load (#41). The
33
+ extension's locks spun and then called `Sleep`, which the mingw linker
34
+ bound to the `Sleep` that Ruby's DLL exports instead of the system's,
35
+ and Ruby's version crashes on threads Ruby doesn't own. Locks now come
36
+ from Rust's standard library, which doesn't spin through `Sleep`, the
37
+ build binds `Sleep` to kernel32, and CI checks that the Windows build
38
+ 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;
39
+ earlier versions could crash in the same way, but rarely.
40
+
10
41
  ## [0.7.0] - 2026-10-11
11
42
 
12
43
  ### Added
@@ -186,7 +217,9 @@ First public release.
186
217
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
187
218
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
188
219
 
189
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.7.0...HEAD
220
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.8.1...HEAD
221
+ [0.8.1]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.8.0...v0.8.1
222
+ [0.8.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.7.0...v0.8.0
190
223
  [0.7.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.6.0...v0.7.0
191
224
  [0.6.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.5.0...v0.6.0
192
225
  [0.5.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...v0.5.0
data/Cargo.lock CHANGED
@@ -468,15 +468,6 @@ dependencies = [
468
468
  "windows-link",
469
469
  ]
470
470
 
471
- [[package]]
472
- name = "lock_api"
473
- version = "0.4.14"
474
- source = "registry+https://github.com/rust-lang/crates.io-index"
475
- checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
476
- dependencies = [
477
- "scopeguard",
478
- ]
479
-
480
471
  [[package]]
481
472
  name = "log"
482
473
  version = "0.4.34"
@@ -573,29 +564,6 @@ version = "0.2.1"
573
564
  source = "registry+https://github.com/rust-lang/crates.io-index"
574
565
  checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
575
566
 
576
- [[package]]
577
- name = "parking_lot"
578
- version = "0.12.5"
579
- source = "registry+https://github.com/rust-lang/crates.io-index"
580
- checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
581
- dependencies = [
582
- "lock_api",
583
- "parking_lot_core",
584
- ]
585
-
586
- [[package]]
587
- name = "parking_lot_core"
588
- version = "0.9.12"
589
- source = "registry+https://github.com/rust-lang/crates.io-index"
590
- checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
591
- dependencies = [
592
- "cfg-if",
593
- "libc",
594
- "redox_syscall",
595
- "smallvec",
596
- "windows-link",
597
- ]
598
-
599
567
  [[package]]
600
568
  name = "percent-encoding"
601
569
  version = "2.3.2"
@@ -708,15 +676,6 @@ version = "0.2.3"
708
676
  source = "registry+https://github.com/rust-lang/crates.io-index"
709
677
  checksum = "cca7ad6a7e21e72151d56fe2495a259b5670e204c3adac41ee7ef676ea08117a"
710
678
 
711
- [[package]]
712
- name = "redox_syscall"
713
- version = "0.5.18"
714
- source = "registry+https://github.com/rust-lang/crates.io-index"
715
- checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
716
- dependencies = [
717
- "bitflags",
718
- ]
719
-
720
679
  [[package]]
721
680
  name = "regex"
722
681
  version = "1.13.1"
@@ -822,12 +781,6 @@ dependencies = [
822
781
  "windows-sys 0.61.2",
823
782
  ]
824
783
 
825
- [[package]]
826
- name = "scopeguard"
827
- version = "1.2.0"
828
- source = "registry+https://github.com/rust-lang/crates.io-index"
829
- checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
830
-
831
784
  [[package]]
832
785
  name = "security-framework"
833
786
  version = "3.7.0"
@@ -1107,7 +1060,7 @@ dependencies = [
1107
1060
 
1108
1061
  [[package]]
1109
1062
  name = "tonic_rails_native"
1110
- version = "0.7.0"
1063
+ version = "0.8.1"
1111
1064
  dependencies = [
1112
1065
  "axum",
1113
1066
  "base64",
@@ -1118,7 +1071,6 @@ dependencies = [
1118
1071
  "http-body",
1119
1072
  "http-body-util",
1120
1073
  "magnus",
1121
- "parking_lot",
1122
1074
  "prost",
1123
1075
  "prost-types",
1124
1076
  "rb-sys",
data/README.md CHANGED
@@ -518,6 +518,24 @@ items.get_item({ id: 1 }, retries: false) # not this call
518
518
  items.get_item({ id: 1 }, retries: { max_attempts: 5 }) # or differently
519
519
  ```
520
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
+
521
539
  `retries: true` tries a call up to 3 times when it fails with
522
540
  `UNAVAILABLE`. A Hash changes any of these:
523
541
 
@@ -612,6 +630,7 @@ the client is created, not on the first call.
612
630
  | `max_message_size` | 4 MiB | largest message sent or received |
613
631
  | `connect_timeout` | 5 | seconds to wait for a connection |
614
632
  | `retries` | `false` | retry calls the server never started on; see [Retries](#retries) |
633
+ | `method_retries` | `{}` | retries per service or method, by name |
615
634
  | `logger` | `Tonic::Rails.configuration.logger` | where retries are logged |
616
635
 
617
636
  ### Fork safety
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tonic_rails_native"
3
- version = "0.7.0"
3
+ version = "0.8.1"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  publish = false
@@ -41,7 +41,6 @@ tokio-stream = { version = "0.1", features = ["net"] }
41
41
  prost = "0.14"
42
42
  prost-types = "0.14"
43
43
  socket2 = { version = "0.6", features = ["all"] }
44
- parking_lot = "0.12"
45
44
  crossbeam-channel = "0.5"
46
45
  http = "1"
47
46
  http-body = "1"
@@ -1,7 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "mkmf"
4
5
  require "rb_sys/mkmf"
6
+ require "shellwords"
7
+
8
+ # On mingw, Ruby's DLL exports its own `Sleep`, which releases the GVL
9
+ # around the wait: on a thread Ruby doesn't own it crashes, and on a Ruby
10
+ # thread that has already released the GVL it takes the GVL back from
11
+ # whichever thread holds it (#41). rb-sys links Ruby's import library ahead of
12
+ # kernel32, so the extension's `Sleep` (from mingw's DLL startup code, and
13
+ # from std via crossbeam) would bind to Ruby's. Link a one-function import
14
+ # library binding `Sleep` to kernel32 ahead of Ruby's instead. (Putting
15
+ # all of kernel32 first goes too far: some toolchains' kernel32 import
16
+ # libraries claim functions kernel32.dll doesn't export.)
17
+ # script/check_windows_imports.rb checks the result in CI.
18
+ if RbConfig::CONFIG["target_os"].to_s.include?("mingw")
19
+ dir = File.expand_path("kernel32_sleep")
20
+ FileUtils.mkdir_p(dir)
21
+ File.write(File.join(dir, "sleep.def"), "LIBRARY kernel32.dll\nEXPORTS\nSleep\n")
22
+ # Cross-compiling, RbConfig describes the target, so its DLLTOOL may not
23
+ # exist here: try the one next to the C compiler too.
24
+ compiler = Shellwords.split(RbConfig::CONFIG["CC"].to_s).find { |word| word.include?("gcc") }.to_s
25
+ candidates = [RbConfig::CONFIG["DLLTOOL"], compiler.sub(/gcc(\.exe)?\z/, "dlltool"), "dlltool"]
26
+ dlltool = candidates.reject { |tool| tool.to_s.empty? }.uniq.find { |tool| find_executable(tool) }
27
+ abort "tonic-rails: no dlltool found (tried #{candidates.compact.uniq.join(', ')})" unless dlltool
28
+ unless system(dlltool, "-d", File.join(dir, "sleep.def"), "-l", File.join(dir, "libtonic_rails_sleep.a"))
29
+ abort "tonic-rails: #{dlltool} failed to build the kernel32 Sleep import library"
30
+ end
31
+ RbConfig::CONFIG["LIBS"] = "-L#{Shellwords.escape(dir)} -ltonic_rails_sleep #{RbConfig::CONFIG['LIBS']}"
32
+ end
5
33
 
6
34
  # Builds the Rust crate with cargo and installs the library as
7
35
  # lib/tonic/rails/tonic_rails_native.{so,bundle}, matching
@@ -24,16 +24,16 @@ use bytes::{Buf, BufMut, Bytes};
24
24
  use http::uri::PathAndQuery;
25
25
  use http::{HeaderMap, HeaderName, HeaderValue};
26
26
  use magnus::{Error, RArray, RHash, RString, Ruby, r_hash::ForEach};
27
- use parking_lot::Mutex;
28
27
  use tokio::runtime::{Builder, Handle, Runtime};
29
28
  use tokio::sync::mpsc;
30
29
  use tokio::task::JoinHandle;
31
30
  use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder, Streaming};
32
31
  use tonic::metadata::MetadataMap;
33
32
  use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
34
- use tonic::{Code, Request, Status};
33
+ use tonic::{Code, Request, Status, TimeoutExpired};
35
34
 
36
35
  use crate::gvl::{Cancel, Wait, block_on, recv_blocking, without_gvl};
36
+ use crate::sync::Mutex;
37
37
 
38
38
  // ─── Per-process runtime ──────────────────────────────────────────
39
39
 
@@ -218,13 +218,22 @@ async fn with_timeout(call: impl Future<Output = Outcome>, timeout: Option<Durat
218
218
  let started = tokio::time::Instant::now();
219
219
  let outcome = tokio::time::timeout(timeout, call).await;
220
220
  match outcome {
221
- // tonic's channel enforces the grpc-timeout header too, but ends
222
- // the call with its own status.
223
- Ok(outcome) if !(outcome.local && started.elapsed() >= timeout) => outcome,
221
+ Ok(outcome) if !(outcome.local && started.elapsed() >= timeout) && !timeout_expired(&outcome) => {
222
+ outcome
223
+ }
224
224
  _ => Outcome::error(Code::DeadlineExceeded, "deadline exceeded"),
225
225
  }
226
226
  }
227
227
 
228
+ /// tonic's channel enforces the grpc-timeout header too, and when its timer
229
+ /// fires before ours it ends the call with CANCELLED "Timeout expired", with
230
+ /// no source to mark it local. (A tonic server enforcing the same deadline
231
+ /// sends that status too.) Coarse timers, like Windows' 15 ms ticks, let
232
+ /// it fire first even though ours started earlier (#48).
233
+ fn timeout_expired(outcome: &Outcome) -> bool {
234
+ outcome.code == Code::Cancelled && outcome.message == TimeoutExpired(()).to_string()
235
+ }
236
+
228
237
  /// What every call needs: the method, one request message and its metadata.
229
238
  struct CallArgs {
230
239
  channel: Channel,
@@ -663,3 +672,42 @@ fn append_causes(mut message: String, mut source: Option<&dyn std::error::Error>
663
672
  }
664
673
  message
665
674
  }
675
+
676
+ #[cfg(test)]
677
+ mod tests {
678
+ use super::*;
679
+
680
+ fn run(call: impl Future<Output = Outcome>, timeout: Option<Duration>) -> Outcome {
681
+ let runtime = Builder::new_current_thread().enable_time().build().unwrap();
682
+ runtime.block_on(with_timeout(call, timeout))
683
+ }
684
+
685
+ async fn status(status: Status) -> Outcome {
686
+ Outcome::status(status, None)
687
+ }
688
+
689
+ #[test]
690
+ fn tonics_timeout_is_a_deadline_however_early_it_fires() {
691
+ let outcome = run(
692
+ status(Status::cancelled("Timeout expired")),
693
+ Some(Duration::from_secs(60)),
694
+ );
695
+ assert_eq!(outcome.code, Code::DeadlineExceeded);
696
+ }
697
+
698
+ #[test]
699
+ fn other_cancellations_stay_cancelled() {
700
+ let outcome = run(
701
+ status(Status::cancelled("client went away")),
702
+ Some(Duration::from_secs(60)),
703
+ );
704
+ assert_eq!(outcome.code, Code::Cancelled);
705
+ assert_eq!(outcome.message, "client went away");
706
+ }
707
+
708
+ #[test]
709
+ fn our_timer_is_a_deadline() {
710
+ let outcome = run(std::future::pending(), Some(Duration::from_millis(10)));
711
+ assert_eq!(outcome.code, Code::DeadlineExceeded);
712
+ }
713
+ }
@@ -16,11 +16,11 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
16
16
  use std::task::{Context, Poll, Wake, Waker};
17
17
 
18
18
  use crate::log::{Level, LogSink};
19
+ use crate::sync::{Condvar, Mutex};
19
20
  use magnus::{
20
21
  Error, RHash, RString, Ruby, Value, prelude::*, r_hash::ForEach, typed_data::DataTypeFunctions,
21
22
  value::Opaque,
22
23
  };
23
- use parking_lot::{Condvar, Mutex};
24
24
  use rb_sys::{RB_NOGVL_INTR_FAIL, rb_nogvl, rb_thread_call_with_gvl};
25
25
  use tokio::sync::{Notify, mpsc};
26
26
 
@@ -101,7 +101,7 @@ where
101
101
  /// Minimal single-future executor that parks the current (non-tokio)
102
102
  /// thread. tokio's channel and `Notify` futures are runtime-agnostic.
103
103
  ///
104
- /// It parks on a pthread condition variable rather than
104
+ /// It parks on a condition variable rather than
105
105
  /// `std::thread::park`, which on macOS uses libdispatch: libdispatch
106
106
  /// traps in the child of a multi-threaded process, and the client blocks
107
107
  /// here in processes forked after it made calls.
@@ -131,7 +131,7 @@ pub fn block_on<F: Future>(fut: F) -> F::Output {
131
131
  }
132
132
  let mut woken = signal.woken.lock();
133
133
  while !*woken {
134
- signal.condvar.wait(&mut woken);
134
+ woken = signal.condvar.wait(woken);
135
135
  }
136
136
  *woken = false;
137
137
  }
@@ -35,6 +35,7 @@ mod gvl;
35
35
  mod log;
36
36
  mod net;
37
37
  mod server;
38
+ mod sync;
38
39
 
39
40
  use magnus::{Error, Ruby, function, method, prelude::*};
40
41
 
@@ -22,7 +22,6 @@ use std::time::{Duration, Instant};
22
22
  use magnus::{
23
23
  Error, RArray, RHash, RString, Ruby, TryConvert, Value, gc, typed_data::DataTypeFunctions, value::Opaque,
24
24
  };
25
- use parking_lot::Mutex;
26
25
  use prost::Message;
27
26
  use tokio::runtime::Runtime;
28
27
  use tokio::sync::{Semaphore, watch};
@@ -37,6 +36,7 @@ use crate::dispatch::{DispatchInner, DispatchService};
37
36
  use crate::gvl::{self, Cancel, DispatchContext};
38
37
  use crate::log::{Level, LogEvent, LogSink};
39
38
  use crate::net::{LimitedIncoming, bind, listen, listen_inherited};
39
+ use crate::sync::Mutex;
40
40
 
41
41
  /// The overall-server health entry (empty service name).
42
42
  const OVERALL: &str = "";
@@ -0,0 +1,42 @@
1
+ //! `Mutex` and `Condvar` over `std::sync`, with parking_lot's API: `lock`
2
+ //! returns the guard, and a panic while holding a lock doesn't poison it.
3
+ //!
4
+ //! Why not parking_lot: under contention it spins and then calls `Sleep`,
5
+ //! and on Windows (mingw) the extension's `Sleep` import binds to the one
6
+ //! Ruby's DLL exports. Ruby's `Sleep` expects a Ruby thread, so on a tokio
7
+ //! thread it crashed the process (#41). std's locks use SRW locks and
8
+ //! never call `Sleep`.
9
+
10
+ use std::sync::{MutexGuard, PoisonError};
11
+
12
+ #[derive(Default)]
13
+ pub struct Mutex<T>(std::sync::Mutex<T>);
14
+
15
+ impl<T> Mutex<T> {
16
+ pub const fn new(value: T) -> Self {
17
+ Self(std::sync::Mutex::new(value))
18
+ }
19
+
20
+ pub fn lock(&self) -> MutexGuard<'_, T> {
21
+ self.0.lock().unwrap_or_else(PoisonError::into_inner)
22
+ }
23
+
24
+ pub fn get_mut(&mut self) -> &mut T {
25
+ self.0.get_mut().unwrap_or_else(PoisonError::into_inner)
26
+ }
27
+ }
28
+
29
+ #[derive(Default)]
30
+ pub struct Condvar(std::sync::Condvar);
31
+
32
+ impl Condvar {
33
+ /// Block until notified, releasing `guard` meanwhile. Wakes can be
34
+ /// spurious: wait in a loop on the condition.
35
+ pub fn wait<'a, T>(&self, guard: MutexGuard<'a, T>) -> MutexGuard<'a, T> {
36
+ self.0.wait(guard).unwrap_or_else(PoisonError::into_inner)
37
+ }
38
+
39
+ pub fn notify_one(&self) {
40
+ self.0.notify_one();
41
+ }
42
+ }
@@ -16,6 +16,7 @@ module Tonic
16
16
  #
17
17
  # Retries are opt-in: `retries: true` (or a Hash; see RetryPolicy) on
18
18
  # the client or a call retries calls the server never started on.
19
+ # `method_retries:` sets them per service or method.
19
20
  #
20
21
  # Stubs are built from the protobuf descriptors that
21
22
  # `tonic_rails:compile_protos` (protoc --ruby_out) generates; no
@@ -46,6 +47,10 @@ module Tonic
46
47
  # @return [RetryPolicy, nil] how calls are retried, unless they say otherwise
47
48
  attr_reader :retries
48
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
+
49
54
  # @param address [String] "host:port"
50
55
  # @param tls [Boolean] false for plaintext, which must be explicit
51
56
  # @param tls_ca_path [String, nil] CA certificates (PEM) to trust instead
@@ -61,11 +66,15 @@ module Tonic
61
66
  # @param connect_timeout [Numeric] seconds to wait for a connection
62
67
  # @param retries [RetryPolicy, Hash, Boolean] retry calls the server
63
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.
64
73
  # @param logger [Logger, nil] where retries are logged; defaults to
65
74
  # Tonic::Rails.configuration.logger
66
75
  def initialize(address, tls: true, tls_ca_path: nil, tls_cert_path: nil, tls_key_path: nil,
67
76
  tls_server_name: nil, deadline: nil, metadata: {}, max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
68
- connect_timeout: 5, retries: false, logger: nil)
77
+ connect_timeout: 5, retries: false, method_retries: {}, logger: nil)
69
78
  Tonic::Rails.native_extension!
70
79
  validate_address!(address)
71
80
  validate_tls!(tls, tls_cert_path, tls_key_path)
@@ -78,6 +87,7 @@ module Tonic
78
87
  @address = address
79
88
  @deadline = deadline
80
89
  @retries = RetryPolicy.from(retries)
90
+ @method_retries = method_retry_policies(method_retries)
81
91
  @logger = logger
82
92
  @metadata = Metadata.encode(metadata)
83
93
  @stubs = {}
@@ -127,7 +137,7 @@ module Tonic
127
137
  bytes = input.encode(request_message(path, input, request))
128
138
  metadata = call_metadata(metadata)
129
139
  deadline_at = deadline_at(deadline)
130
- policy = retry_policy(retries)
140
+ policy = retry_policy(path, retries)
131
141
 
132
142
  Instrumentation.instrument_request(path, address, metadata) do |payload|
133
143
  code, error_message, response, headers, trailers, details = with_retries(path, policy, deadline_at, payload) do
@@ -150,7 +160,7 @@ module Tonic
150
160
  # Fixed now, so the stream keeps the deadline of the RPC this
151
161
  # thread is handling even if it's read later.
152
162
  deadline_at = deadline_at(deadline)
153
- policy = retry_policy(retries)
163
+ policy = retry_policy(path, retries)
154
164
 
155
165
  ServerStream.new(path) do |stream, block|
156
166
  Instrumentation.instrument_request(path, address, metadata) do |payload|
@@ -238,8 +248,35 @@ module Tonic
238
248
  (remaining * 1000).ceil
239
249
  end
240
250
 
241
- def retry_policy(retries)
242
- retries.nil? ? @retries : RetryPolicy.from(retries)
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
243
280
  end
244
281
 
245
282
  # Yield each message of a native stream, then return its result.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.1"
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tonic-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Tanous
@@ -81,6 +81,7 @@ files:
81
81
  - ext/tonic_rails_native/src/log.rs
82
82
  - ext/tonic_rails_native/src/net.rs
83
83
  - ext/tonic_rails_native/src/server.rs
84
+ - ext/tonic_rails_native/src/sync.rs
84
85
  - lib/generators/tonic_rails/install/install_generator.rb
85
86
  - lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt
86
87
  - lib/generators/tonic_rails/service/service_generator.rb