tonic-rails 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/Cargo.lock +1 -49
- data/README.md +85 -1
- data/ext/tonic_rails_native/Cargo.toml +1 -2
- data/ext/tonic_rails_native/extconf.rb +28 -0
- data/ext/tonic_rails_native/src/body.rs +1 -40
- data/ext/tonic_rails_native/src/client.rs +22 -12
- data/ext/tonic_rails_native/src/dispatch.rs +10 -3
- data/ext/tonic_rails_native/src/gvl.rs +3 -3
- data/ext/tonic_rails_native/src/lib.rs +1 -0
- data/ext/tonic_rails_native/src/server.rs +1 -1
- data/ext/tonic_rails_native/src/sync.rs +42 -0
- data/lib/tonic/rails/client/retry_policy.rb +73 -0
- data/lib/tonic/rails/client.rb +152 -26
- data/lib/tonic/rails/instrumentation.rb +5 -4
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +4 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6cc5ee93380e004201e88f63289b88dff01dd6cc6566c92a3d972b9c934150d
|
|
4
|
+
data.tar.gz: 20a5a45adb019fa7275330b2d5b9f256a9f259765e1ffc83f4f1278902b38620
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 452568660e7a6f19c16d77ff8d272691c0f54c7ebf67261e5199dab6ad59f6c1df89b4b8e0efab84748e089bd74f4e4c2bf4de7dd599e628de0fc11f6ce03922
|
|
7
|
+
data.tar.gz: 52d5199c4d503c0792d84d254442a87cc25fc962b9af87561129c08c95974c48424c1a8914bd0cfcf664331d5c2f892c6cd8f31e507309bacb8c32b3ecc2fb53
|
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.
|
|
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/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.
|
|
1063
|
+
version = "0.8.0"
|
|
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
|
@@ -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
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "tonic_rails_native"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
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
|
|
@@ -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);
|
|
@@ -24,7 +24,6 @@ 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;
|
|
@@ -34,6 +33,7 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity
|
|
|
34
33
|
use tonic::{Code, Request, Status};
|
|
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
|
|
|
@@ -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),
|
|
153
|
+
..Self::status(Status::new(code, message), None)
|
|
150
154
|
}
|
|
151
155
|
}
|
|
152
156
|
|
|
153
|
-
|
|
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]`,
|
|
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(
|
|
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
|
|
256
|
-
|
|
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::{
|
|
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::
|
|
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> {
|
|
@@ -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
|
|
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(
|
|
134
|
+
woken = signal.condvar.wait(woken);
|
|
135
135
|
}
|
|
136
136
|
*woken = false;
|
|
137
137
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
data/lib/tonic/rails/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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,
|
|
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
|
-
#
|
|
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
|
data/lib/tonic/rails/version.rb
CHANGED
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
|
-
|
|
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.
|
|
4
|
+
version: 0.8.0
|
|
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
|
|
@@ -92,6 +93,7 @@ files:
|
|
|
92
93
|
- lib/tonic-rails.rb
|
|
93
94
|
- lib/tonic/rails/base.rb
|
|
94
95
|
- lib/tonic/rails/client.rb
|
|
96
|
+
- lib/tonic/rails/client/retry_policy.rb
|
|
95
97
|
- lib/tonic/rails/cluster.rb
|
|
96
98
|
- lib/tonic/rails/configuration.rb
|
|
97
99
|
- lib/tonic/rails/health.rb
|