tonic-rails 0.5.0-x86_64-linux-gnu → 0.7.0-x86_64-linux-gnu
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/README.md +265 -0
- data/lib/tonic/rails/3.3/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/3.4/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/4.0/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/base.rb +4 -1
- data/lib/tonic/rails/client/retry_policy.rb +73 -0
- data/lib/tonic/rails/client.rb +460 -0
- data/lib/tonic/rails/instrumentation.rb +21 -5
- data/lib/tonic/rails/rpc_error.rb +61 -0
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +1 -0
- 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: 83bded7e4b04dc72602f73fa065ac32bde6f97631fe4450525432ef6f93df506
|
|
4
|
+
data.tar.gz: 13bb870aa4bfb1487fa9827745aceac8c1a0a211053a9b5adafebe494975307e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 539c6220bfa47fb98b506c826112ad5728bde68693c37a3479dd2727baff3b14fd07cb689c4c72419d05228a4c3042ea4ce636b44f7c1cdbad06013a2084f8ba
|
|
7
|
+
data.tar.gz: 9ac35b6b569b9f4083ea342c5e55fe321dda57b89ec4d797ec41624ae2477e47957f71cd4569f9c42fb592d5b1860037ba0fdf2378a75fbba2cf3ed894e1c3da
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@ 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
|
+
|
|
30
|
+
## [0.6.0] - 2026-10-11
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- Client: `Tonic::Rails::Client` makes unary calls to other gRPC
|
|
35
|
+
services without the grpc gem. Stubs come from the descriptors that
|
|
36
|
+
`tonic_rails:compile_protos` generates. Calls take deadlines (and
|
|
37
|
+
default to the deadline of the RPC being handled), send metadata,
|
|
38
|
+
return headers and trailers with `return_call: true`, and raise
|
|
39
|
+
`RpcError` with rich details. TLS uses the system's root certificates,
|
|
40
|
+
a custom CA or mutual TLS. Clients keep working across `fork` with no
|
|
41
|
+
hooks, and `Timeout` and `Thread#raise` interrupt calls. Each call
|
|
42
|
+
publishes a `request_rpc.tonic_rails` event. Closes #37.
|
|
43
|
+
- Client: server-streaming calls. The stub method returns a stream that
|
|
44
|
+
yields each message as it arrives. A slow reader holds the server back
|
|
45
|
+
through HTTP/2 flow control, and stopping early (`break`, `first(n)`,
|
|
46
|
+
an exception) cancels the call. The deadline covers the whole stream,
|
|
47
|
+
an error partway through raises `RpcError` after the messages before
|
|
48
|
+
it, and `headers` and `trailers` are available once the stream ends.
|
|
49
|
+
Closes #38.
|
|
50
|
+
|
|
10
51
|
## [0.5.0] - 2026-10-11
|
|
11
52
|
|
|
12
53
|
### Added
|
|
@@ -145,7 +186,9 @@ First public release.
|
|
|
145
186
|
- Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
|
|
146
187
|
macOS (x86_64, arm64), Ruby 3.3 to 4.0.
|
|
147
188
|
|
|
148
|
-
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.
|
|
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
|
|
191
|
+
[0.6.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.5.0...v0.6.0
|
|
149
192
|
[0.5.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...v0.5.0
|
|
150
193
|
[0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
|
|
151
194
|
[0.3.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...v0.3.0
|
data/README.md
CHANGED
|
@@ -44,6 +44,10 @@ end
|
|
|
44
44
|
INTERNAL error (the details go to your logs and `Rails.error`).
|
|
45
45
|
Reflection is off unless you turn it on. TLS problems stop the server
|
|
46
46
|
instead of silently falling back to plaintext.
|
|
47
|
+
- **A client too.** Make unary and server-streaming calls to other gRPC
|
|
48
|
+
services, with deadlines that follow your handler's, without the
|
|
49
|
+
`grpc` gem and its fork crashes. See
|
|
50
|
+
[Calling other services](#calling-other-services).
|
|
47
51
|
- **No Rust toolchain needed** on the platforms we ship precompiled gems
|
|
48
52
|
for: Linux and macOS (x86_64 and arm64) and Windows (x64).
|
|
49
53
|
|
|
@@ -401,6 +405,261 @@ const item = await client.getItem({ id: 1n });
|
|
|
401
405
|
warning). `grpc_web_allowed_headers` adds request headers beyond the
|
|
402
406
|
standard ones (`authorization` is already allowed).
|
|
403
407
|
|
|
408
|
+
## Calling other services
|
|
409
|
+
|
|
410
|
+
tonic-rails includes a client for unary and server-streaming calls to
|
|
411
|
+
other gRPC services, so
|
|
412
|
+
an app that serves gRPC (or just calls it) doesn't need the `grpc` gem.
|
|
413
|
+
It runs on the same Rust stack as the server, and it keeps working after
|
|
414
|
+
`fork`. That matters because the `grpc` gem crashes or hangs in forked
|
|
415
|
+
processes (Puma with `preload_app!`, Sidekiq swarm, Resque) unless you
|
|
416
|
+
opt in to its Linux-only fork support and call `GRPC.prefork` and
|
|
417
|
+
friends around every fork yourself.
|
|
418
|
+
|
|
419
|
+
Create a client once and share it; it holds one HTTP/2 connection and is
|
|
420
|
+
safe to use from any thread:
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
# config/initializers/grpc_clients.rb
|
|
424
|
+
ITEMS = Tonic::Rails::Client.new("items.internal:50051", deadline: 2)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Then get a stub for a service and call its methods, named in snake_case:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
items = ITEMS.stub("demo.v1.ItemService")
|
|
431
|
+
|
|
432
|
+
item = items.get_item(Demo::V1::GetItemRequest.new(id: 1)) # a message
|
|
433
|
+
item = items.get_item(id: 1) # or its fields
|
|
434
|
+
item = items.get_item({ id: 1 }, deadline: 0.5, metadata: { "x-request-id" => request.request_id })
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Stubs are built from the protobuf descriptors in the `_pb.rb` files that
|
|
438
|
+
`tonic_rails:compile_protos` generates, the same ones your handlers use.
|
|
439
|
+
There's no `grpc_tools_ruby_protoc` step and no `_services_pb.rb`.
|
|
440
|
+
Responses are protobuf messages.
|
|
441
|
+
|
|
442
|
+
### Server streaming
|
|
443
|
+
|
|
444
|
+
A server-streaming method returns a stream you read with `each`, or any
|
|
445
|
+
Enumerable method:
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
items.list_items(category: "books").each { |item| index(item) }
|
|
449
|
+
items.list_items(category: "books").first(10) # stops after ten
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
- Nothing is sent until you start reading. Then each message is yielded
|
|
453
|
+
as it arrives, not when the stream ends.
|
|
454
|
+
- Stopping early, with `break`, `first(n)` or an exception, cancels the
|
|
455
|
+
call, and the server sees it cancelled.
|
|
456
|
+
- A slow reader holds the server back. A few messages are read ahead,
|
|
457
|
+
and HTTP/2 flow control stops the server after a couple of megabytes,
|
|
458
|
+
so a fast server can't fill your memory.
|
|
459
|
+
- If the server fails partway through, you get every message it sent
|
|
460
|
+
first, then `Tonic::Rails::RpcError`.
|
|
461
|
+
- Once the stream has ended, `headers` and `trailers` return its
|
|
462
|
+
metadata.
|
|
463
|
+
- A stream can be read once. Call the method again for a new one.
|
|
464
|
+
|
|
465
|
+
Client and bidirectional streaming aren't supported yet; their stub
|
|
466
|
+
methods raise `NotImplementedError`.
|
|
467
|
+
|
|
468
|
+
### Deadlines
|
|
469
|
+
|
|
470
|
+
- `deadline:` on a call is seconds from now (`0.5`) or a `Time`. Without
|
|
471
|
+
one, the client's `deadline:` applies.
|
|
472
|
+
- **Inside a handler, outgoing calls default to the time left on the
|
|
473
|
+
handler's own deadline**, so a service that's out of time doesn't keep
|
|
474
|
+
its dependencies working on an answer nobody will read. If the client
|
|
475
|
+
also has a default deadline, the earlier of the two wins. A deadline on
|
|
476
|
+
the call itself always wins.
|
|
477
|
+
- When a deadline passes, the call raises `DEADLINE_EXCEEDED`, and the
|
|
478
|
+
server sees the call cancelled.
|
|
479
|
+
- A stream's deadline covers the whole stream, including the time your
|
|
480
|
+
block spends on each message. It's set when you call the method, so a
|
|
481
|
+
stream read later still keeps the handler's deadline.
|
|
482
|
+
|
|
483
|
+
Set a deadline. Without one, a call to a server that never answers waits
|
|
484
|
+
forever (or until `Timeout` interrupts it, which also works).
|
|
485
|
+
|
|
486
|
+
### Errors
|
|
487
|
+
|
|
488
|
+
A call that fails raises `Tonic::Rails::RpcError`, the same class your
|
|
489
|
+
handlers raise:
|
|
490
|
+
|
|
491
|
+
```ruby
|
|
492
|
+
begin
|
|
493
|
+
items.get_item(id: 1)
|
|
494
|
+
rescue Tonic::Rails::RpcError => e
|
|
495
|
+
e.code_name # :not_found, :unavailable, :deadline_exceeded, ...
|
|
496
|
+
e.message # the server's status message
|
|
497
|
+
e.details # rich error details (google.rpc.Status), as messages
|
|
498
|
+
e.metadata # trailing metadata
|
|
499
|
+
end
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Rich error details come back as their message class when its `_pb.rb` is
|
|
503
|
+
loaded, and as `Google::Protobuf::Any` otherwise. A server that can't be
|
|
504
|
+
reached raises `UNAVAILABLE`.
|
|
505
|
+
|
|
506
|
+
If a handler lets the error propagate, its caller receives the same
|
|
507
|
+
status, message, details and trailing metadata. Rescue it and raise your
|
|
508
|
+
own error when those are internal.
|
|
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
|
+
|
|
572
|
+
### Metadata
|
|
573
|
+
|
|
574
|
+
`metadata:` on a call, and on the client for every call, works like
|
|
575
|
+
response metadata in handlers: `-bin` names take binary values. To read
|
|
576
|
+
what the server sent back, pass `return_call: true`:
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
call = items.get_item({ id: 1 }, return_call: true)
|
|
580
|
+
call.response # the message
|
|
581
|
+
call.headers # response headers, -bin values decoded
|
|
582
|
+
call.trailers # trailing metadata
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
### TLS
|
|
586
|
+
|
|
587
|
+
TLS is on unless you turn it off, and plaintext needs an explicit
|
|
588
|
+
`tls: false`:
|
|
589
|
+
|
|
590
|
+
```ruby
|
|
591
|
+
Tonic::Rails::Client.new("items.internal:50051") # system root certificates
|
|
592
|
+
Tonic::Rails::Client.new("items.internal:50051", tls_ca_path: "ca.pem") # your own CA instead
|
|
593
|
+
Tonic::Rails::Client.new("items.internal:50051", tls_ca_path: "ca.pem",
|
|
594
|
+
tls_cert_path: "client.pem", tls_key_path: "client.key") # mutual TLS
|
|
595
|
+
Tonic::Rails::Client.new("localhost:50051", tls: false) # plaintext
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
`tls_server_name:` checks the server's certificate against another name
|
|
599
|
+
than the host you connect to. Unreadable certificates or keys raise when
|
|
600
|
+
the client is created, not on the first call.
|
|
601
|
+
|
|
602
|
+
### Options
|
|
603
|
+
|
|
604
|
+
| Option | Default | |
|
|
605
|
+
|---|---|---|
|
|
606
|
+
| `tls` | `true` | `false` for plaintext |
|
|
607
|
+
| `tls_ca_path` | system roots | CA certificates (PEM) to trust instead |
|
|
608
|
+
| `tls_cert_path`, `tls_key_path` | none | client certificate and key (PEM), for mutual TLS |
|
|
609
|
+
| `tls_server_name` | the host | name to verify the server's certificate against |
|
|
610
|
+
| `deadline` | none | seconds each call may take, unless the call sets its own |
|
|
611
|
+
| `metadata` | `{}` | sent with every call |
|
|
612
|
+
| `max_message_size` | 4 MiB | largest message sent or received |
|
|
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 |
|
|
616
|
+
|
|
617
|
+
### Fork safety
|
|
618
|
+
|
|
619
|
+
A client created before `fork` works in both processes, on Linux and
|
|
620
|
+
macOS, with nothing to call. Each process opens its own connection on
|
|
621
|
+
its first call, and a child never touches the threads or sockets it
|
|
622
|
+
inherited from its parent. Create clients in initializers, before Puma or
|
|
623
|
+
Sidekiq forks, and use them anywhere.
|
|
624
|
+
|
|
625
|
+
On macOS, also create the first client that uses the system's root
|
|
626
|
+
certificates before forking. Loading them goes through macOS's security
|
|
627
|
+
APIs, which abort in a process forked from a multi-threaded one. Loaded
|
|
628
|
+
once, they're kept and inherited, so clients created later in children
|
|
629
|
+
are fine. Linux has no such limit.
|
|
630
|
+
|
|
631
|
+
### Migrating from the grpc gem
|
|
632
|
+
|
|
633
|
+
Call sites barely change; how you build the stub does:
|
|
634
|
+
|
|
635
|
+
```ruby
|
|
636
|
+
# Before: grpc gem
|
|
637
|
+
stub = Demo::V1::ItemService::Stub.new("items.internal:50051", GRPC::Core::ChannelCredentials.new)
|
|
638
|
+
item = stub.get_item(Demo::V1::GetItemRequest.new(id: 1), deadline: Time.now + 2, metadata: { "x-id" => "1" })
|
|
639
|
+
|
|
640
|
+
# After: tonic-rails
|
|
641
|
+
stub = Tonic::Rails::Client.new("items.internal:50051").stub("demo.v1.ItemService")
|
|
642
|
+
item = stub.get_item(Demo::V1::GetItemRequest.new(id: 1), deadline: Time.now + 2, metadata: { "x-id" => "1" })
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
- `stub` also accepts the grpc gem's service class
|
|
646
|
+
(`Demo::V1::ItemService::Service`), which helps while both gems are
|
|
647
|
+
installed.
|
|
648
|
+
- `deadline:` takes a `Time`, as before, or seconds.
|
|
649
|
+
- `return_op: true` becomes `return_call: true`, which returns the
|
|
650
|
+
response with its headers and trailers instead of an operation.
|
|
651
|
+
- `GRPC::BadStatus` subclasses become one `Tonic::Rails::RpcError`:
|
|
652
|
+
`rescue GRPC::NotFound` becomes `rescue Tonic::Rails::RpcError => e`
|
|
653
|
+
and a check of `e.code_name == :not_found`. The `raise_grpc_error`
|
|
654
|
+
matcher and `assert_grpc_error` accept both.
|
|
655
|
+
- `:this_channel_is_insecure` becomes `tls: false`.
|
|
656
|
+
- Server-streaming methods return a stream instead of an Enumerator.
|
|
657
|
+
Code that calls `each` or other Enumerable methods on the result works
|
|
658
|
+
unchanged; for `next`, call `each` first to get an Enumerator.
|
|
659
|
+
- Client and bidirectional streaming aren't supported yet. Stubs refuse
|
|
660
|
+
those methods with a `NotImplementedError`; keep the grpc gem for them
|
|
661
|
+
for now.
|
|
662
|
+
|
|
404
663
|
## Testing handlers
|
|
405
664
|
|
|
406
665
|
`Tonic::Rails::Testing` calls a handler in-process, with real protobuf
|
|
@@ -634,6 +893,12 @@ When a call fails, `event.payload[:exception_object]` is the
|
|
|
634
893
|
`Tonic::Rails::RpcError` sent to the client. If a handler raised something
|
|
635
894
|
unexpected, that error's `cause` is the original exception.
|
|
636
895
|
|
|
896
|
+
Calls made with `Tonic::Rails::Client` publish `request_rpc.tonic_rails`,
|
|
897
|
+
with the same payload plus `:address`, the server called, and
|
|
898
|
+
`:attempts`, how many times it was tried. For a
|
|
899
|
+
server-streaming call, the event covers the whole stream, including the
|
|
900
|
+
time your block spends on each message.
|
|
901
|
+
|
|
637
902
|
## How it works
|
|
638
903
|
|
|
639
904
|
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/tonic/rails/base.rb
CHANGED
|
@@ -248,7 +248,10 @@ module Tonic
|
|
|
248
248
|
|
|
249
249
|
@request = build_request(reader, spec)
|
|
250
250
|
response = nil
|
|
251
|
-
|
|
251
|
+
# Calls this handler makes with Tonic::Rails::Client default to its deadline.
|
|
252
|
+
Client.with_deadline(@grpc_deadline_at) do
|
|
253
|
+
run_callbacks(:rpc) { response = respond(public_send(spec[:handler], @request), spec, sender) }
|
|
254
|
+
end
|
|
252
255
|
response
|
|
253
256
|
rescue RpcError
|
|
254
257
|
raise
|
|
@@ -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
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/inflector"
|
|
4
|
+
|
|
5
|
+
module Tonic
|
|
6
|
+
module Rails
|
|
7
|
+
# Calls other gRPC services, without the grpc gem.
|
|
8
|
+
#
|
|
9
|
+
# ITEMS = Tonic::Rails::Client.new("items.internal:50051", deadline: 2)
|
|
10
|
+
#
|
|
11
|
+
# items = ITEMS.stub("demo.v1.ItemService")
|
|
12
|
+
# item = items.get_item(Demo::V1::GetItemRequest.new(id: 1))
|
|
13
|
+
# item = items.get_item(id: 1)
|
|
14
|
+
# item = items.get_item({ id: 1 }, deadline: 0.5, metadata: { "x-request-id" => request_id })
|
|
15
|
+
# items.list_items(category: "books").each { |item| ... }
|
|
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
|
+
#
|
|
20
|
+
# Stubs are built from the protobuf descriptors that
|
|
21
|
+
# `tonic_rails:compile_protos` (protoc --ruby_out) generates; no
|
|
22
|
+
# `_services_pb.rb` is needed. A failed call raises
|
|
23
|
+
# Tonic::Rails::RpcError.
|
|
24
|
+
#
|
|
25
|
+
# A client holds one HTTP/2 connection and is safe to share between
|
|
26
|
+
# threads, so create it once (a constant or an initializer). It keeps
|
|
27
|
+
# working across `fork`: each process connects on its own, with no
|
|
28
|
+
# hooks to call.
|
|
29
|
+
class Client
|
|
30
|
+
# A response with its headers and trailers; see `return_call:`.
|
|
31
|
+
Call = Data.define(:response, :headers, :trailers)
|
|
32
|
+
|
|
33
|
+
DEFAULT_MAX_MESSAGE_SIZE = 4 * 1024 * 1024
|
|
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
|
+
|
|
40
|
+
# @return [String] "host:port"
|
|
41
|
+
attr_reader :address
|
|
42
|
+
|
|
43
|
+
# @return [Numeric, nil] default deadline in seconds
|
|
44
|
+
attr_reader :deadline
|
|
45
|
+
|
|
46
|
+
# @return [RetryPolicy, nil] how calls are retried, unless they say otherwise
|
|
47
|
+
attr_reader :retries
|
|
48
|
+
|
|
49
|
+
# @param address [String] "host:port"
|
|
50
|
+
# @param tls [Boolean] false for plaintext, which must be explicit
|
|
51
|
+
# @param tls_ca_path [String, nil] CA certificates (PEM) to trust instead
|
|
52
|
+
# of the system's root certificates
|
|
53
|
+
# @param tls_cert_path [String, nil] client certificate (PEM), for mutual TLS
|
|
54
|
+
# @param tls_key_path [String, nil] client private key (PEM), for mutual TLS
|
|
55
|
+
# @param tls_server_name [String, nil] name to verify the server's
|
|
56
|
+
# certificate against, if not the host in `address`
|
|
57
|
+
# @param deadline [Numeric, nil] seconds each call may take, unless the
|
|
58
|
+
# call sets its own
|
|
59
|
+
# @param metadata [Hash] sent with every call (see Tonic::Rails::Metadata)
|
|
60
|
+
# @param max_message_size [Integer] largest message sent or received, in bytes
|
|
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
|
|
66
|
+
def initialize(address, tls: true, tls_ca_path: nil, tls_cert_path: nil, tls_key_path: nil,
|
|
67
|
+
tls_server_name: nil, deadline: nil, metadata: {}, max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
|
|
68
|
+
connect_timeout: 5, retries: false, logger: nil)
|
|
69
|
+
Tonic::Rails.native_extension!
|
|
70
|
+
validate_address!(address)
|
|
71
|
+
validate_tls!(tls, tls_cert_path, tls_key_path)
|
|
72
|
+
positive!(:deadline, deadline) unless deadline.nil?
|
|
73
|
+
positive!(:connect_timeout, connect_timeout)
|
|
74
|
+
unless max_message_size.is_a?(Integer) && max_message_size.positive?
|
|
75
|
+
raise ArgumentError, "max_message_size must be a positive Integer"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@address = address
|
|
79
|
+
@deadline = deadline
|
|
80
|
+
@retries = RetryPolicy.from(retries)
|
|
81
|
+
@logger = logger
|
|
82
|
+
@metadata = Metadata.encode(metadata)
|
|
83
|
+
@stubs = {}
|
|
84
|
+
tls_options = [read_pem(tls_ca_path, "CA"), read_pem(tls_cert_path, "certificate"),
|
|
85
|
+
read_pem(tls_key_path, "key"), tls_server_name]
|
|
86
|
+
@native = TonicRails::Native::Channel.new(
|
|
87
|
+
"#{tls ? 'https' : 'http'}://#{address}", max_message_size, (connect_timeout * 1000).ceil,
|
|
88
|
+
(tls_options if tls)
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A stub with one method per RPC of the service, named in snake_case
|
|
93
|
+
# (`GetItem` becomes `get_item`).
|
|
94
|
+
#
|
|
95
|
+
# @param service [String, Google::Protobuf::ServiceDescriptor, #service_name]
|
|
96
|
+
# the fully-qualified service name, its descriptor, or a grpc gem
|
|
97
|
+
# service class (`Demo::V1::ItemService::Service`)
|
|
98
|
+
# @return [Tonic::Rails::Client::Stub]
|
|
99
|
+
def stub(service)
|
|
100
|
+
descriptor = service_descriptor(service)
|
|
101
|
+
@stubs[descriptor.name] ||= Stub.for(descriptor).new(self)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def inspect
|
|
105
|
+
"#<#{self.class.name} #{address}>"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The deadline of the RPC this thread is handling, as a monotonic
|
|
109
|
+
# clock time; outgoing calls default to it.
|
|
110
|
+
# @api private
|
|
111
|
+
def self.current_deadline
|
|
112
|
+
Thread.current[:tonic_rails_deadline]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @api private
|
|
116
|
+
def self.with_deadline(deadline)
|
|
117
|
+
previous = Thread.current[:tonic_rails_deadline]
|
|
118
|
+
Thread.current[:tonic_rails_deadline] = deadline
|
|
119
|
+
yield
|
|
120
|
+
ensure
|
|
121
|
+
Thread.current[:tonic_rails_deadline] = previous
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Make a unary call. Stubs call this; use them instead.
|
|
125
|
+
# @api private
|
|
126
|
+
def unary(path, input, output, request, deadline: nil, metadata: {}, return_call: false, retries: nil)
|
|
127
|
+
bytes = input.encode(request_message(path, input, request))
|
|
128
|
+
metadata = call_metadata(metadata)
|
|
129
|
+
deadline_at = deadline_at(deadline)
|
|
130
|
+
policy = retry_policy(retries)
|
|
131
|
+
|
|
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
|
|
136
|
+
headers = decode_metadata(headers)
|
|
137
|
+
trailers = decode_metadata(trailers)
|
|
138
|
+
check_status!(code, error_message, trailers, details)
|
|
139
|
+
|
|
140
|
+
response = decode_response(output, response, path)
|
|
141
|
+
return_call ? Call.new(response: response, headers: headers, trailers: trailers) : response
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Start a server-streaming call. Stubs call this; use them instead.
|
|
146
|
+
# @api private
|
|
147
|
+
def server_streaming(path, input, output, request, deadline: nil, metadata: {}, retries: nil)
|
|
148
|
+
bytes = input.encode(request_message(path, input, request))
|
|
149
|
+
metadata = call_metadata(metadata)
|
|
150
|
+
# Fixed now, so the stream keeps the deadline of the RPC this
|
|
151
|
+
# thread is handling even if it's read later.
|
|
152
|
+
deadline_at = deadline_at(deadline)
|
|
153
|
+
policy = retry_policy(retries)
|
|
154
|
+
|
|
155
|
+
ServerStream.new(path) do |stream, block|
|
|
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)
|
|
162
|
+
end
|
|
163
|
+
trailers = decode_metadata(trailers)
|
|
164
|
+
stream.finish(decode_metadata(headers), trailers)
|
|
165
|
+
check_status!(code, error_message, trailers, details)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def validate_address!(address)
|
|
173
|
+
return if address.is_a?(String) && address.match?(%r{\A[^\s/]+:\d+\z})
|
|
174
|
+
|
|
175
|
+
raise ArgumentError, "address must be \"host:port\", got #{address.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_tls!(tls, cert_path, key_path)
|
|
179
|
+
raise ArgumentError, "tls must be true or false" unless [true, false].include?(tls)
|
|
180
|
+
raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if cert_path.nil? != key_path.nil?
|
|
181
|
+
raise ArgumentError, "tls_cert_path and tls_key_path need tls: true" if cert_path && !tls
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def positive!(name, value)
|
|
185
|
+
raise ArgumentError, "#{name} must be a positive number of seconds" unless value.is_a?(Numeric) && value.positive?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def read_pem(path, what)
|
|
189
|
+
return unless path
|
|
190
|
+
raise ArgumentError, "TLS #{what} file not found: #{path}" unless File.file?(path)
|
|
191
|
+
|
|
192
|
+
File.read(path)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def service_descriptor(service)
|
|
196
|
+
return service if service.is_a?(Google::Protobuf::ServiceDescriptor)
|
|
197
|
+
|
|
198
|
+
name = service.respond_to?(:service_name) ? service.service_name : service.to_s
|
|
199
|
+
descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(name)
|
|
200
|
+
return descriptor if descriptor.is_a?(Google::Protobuf::ServiceDescriptor)
|
|
201
|
+
|
|
202
|
+
raise NotConfiguredError,
|
|
203
|
+
"#{name} is not a known protobuf service. Require its generated _pb.rb file (protoc --ruby_out) first."
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def request_message(path, input, request)
|
|
207
|
+
case request
|
|
208
|
+
when input then request
|
|
209
|
+
when Hash then input.new(request)
|
|
210
|
+
else raise TypeError, "#{path} expects a #{input} or Hash, got #{request.class}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def call_metadata(metadata)
|
|
215
|
+
metadata.empty? ? @metadata : @metadata.merge(Metadata.encode(metadata))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# When the call must end, as a monotonic clock time: the call's own
|
|
219
|
+
# deadline, or else the earlier of the client's default and the
|
|
220
|
+
# deadline of the RPC being handled.
|
|
221
|
+
def deadline_at(deadline)
|
|
222
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
223
|
+
case deadline
|
|
224
|
+
when nil then [(now + @deadline if @deadline), self.class.current_deadline].compact.min
|
|
225
|
+
when Numeric then now + deadline
|
|
226
|
+
when Time then now + (deadline - Time.now)
|
|
227
|
+
else raise ArgumentError, "deadline must be seconds (Numeric) or a Time, got #{deadline.class}"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Milliseconds left until `deadline_at`, or nil for no deadline.
|
|
232
|
+
def remaining_ms(deadline_at)
|
|
233
|
+
return unless deadline_at
|
|
234
|
+
|
|
235
|
+
remaining = deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
236
|
+
raise RpcError.new("deadline exceeded", code: :deadline_exceeded) unless remaining.positive?
|
|
237
|
+
|
|
238
|
+
(remaining * 1000).ceil
|
|
239
|
+
end
|
|
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
|
+
|
|
310
|
+
def check_status!(code, message, trailers, details)
|
|
311
|
+
return if code.zero?
|
|
312
|
+
|
|
313
|
+
raise RpcError.new(message.empty? ? nil : message, code: code, metadata: trailers,
|
|
314
|
+
details: RpcError.decode_details(details))
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Wire-form [name, value] pairs to a Hash: `-bin` values decoded,
|
|
318
|
+
# repeated names collected into an Array, and transport headers
|
|
319
|
+
# (content-type, grpc-*) dropped.
|
|
320
|
+
def decode_metadata(pairs)
|
|
321
|
+
pairs.each_with_object({}) do |(name, value), metadata|
|
|
322
|
+
next if name.start_with?("grpc-") || Metadata::RESERVED.include?(name)
|
|
323
|
+
|
|
324
|
+
value = value.unpack1("m") if name.end_with?("-bin")
|
|
325
|
+
metadata[name] = metadata.key?(name) ? [*metadata[name], value] : value
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def decode_response(output, bytes, path)
|
|
330
|
+
output.decode(bytes)
|
|
331
|
+
rescue Google::Protobuf::ParseError
|
|
332
|
+
raise RpcError.new("invalid protobuf response from #{path}", code: :internal)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Calls an RPC's methods. Built per service from its descriptor; get
|
|
336
|
+
# one with Client#stub.
|
|
337
|
+
class Stub
|
|
338
|
+
class << self
|
|
339
|
+
# @api private
|
|
340
|
+
attr_reader :service_name
|
|
341
|
+
|
|
342
|
+
# @api private
|
|
343
|
+
def for(descriptor)
|
|
344
|
+
Class.new(self) do
|
|
345
|
+
@service_name = descriptor.name
|
|
346
|
+
descriptor.each { |method| define_rpc(method) }
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def inspect
|
|
351
|
+
service_name ? "#{Stub.name}(#{service_name})" : super
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
private
|
|
355
|
+
|
|
356
|
+
def define_rpc(method)
|
|
357
|
+
path = "/#{service_name}/#{method.name}"
|
|
358
|
+
name = ActiveSupport::Inflector.underscore(method.name)
|
|
359
|
+
if method.client_streaming
|
|
360
|
+
define_method(name) do |*, **|
|
|
361
|
+
raise NotImplementedError,
|
|
362
|
+
"#{path} is a client-streaming RPC; the tonic-rails client makes unary and server-streaming calls"
|
|
363
|
+
end
|
|
364
|
+
return
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
input = method.input_type.msgclass
|
|
368
|
+
output = method.output_type.msgclass
|
|
369
|
+
if method.server_streaming
|
|
370
|
+
define_method(name) do |request = nil, deadline: nil, metadata: {}, retries: nil, **fields|
|
|
371
|
+
request = rpc_request(name, request, fields)
|
|
372
|
+
@client.server_streaming(path, input, output, request, deadline: deadline, metadata: metadata,
|
|
373
|
+
retries: retries)
|
|
374
|
+
end
|
|
375
|
+
else
|
|
376
|
+
# `get_item(id: 1)` works as well as `get_item({ id: 1 })`.
|
|
377
|
+
define_method(name) do |request = nil, deadline: nil, metadata: {}, return_call: false, retries: nil,
|
|
378
|
+
**fields|
|
|
379
|
+
request = rpc_request(name, request, fields)
|
|
380
|
+
@client.unary(path, input, output, request, deadline: deadline, metadata: metadata,
|
|
381
|
+
return_call: return_call, retries: retries)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# @return [Tonic::Rails::Client]
|
|
388
|
+
attr_reader :client
|
|
389
|
+
|
|
390
|
+
def initialize(client)
|
|
391
|
+
@client = client
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def inspect
|
|
395
|
+
"#<#{self.class.inspect} #{client.address}>"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private
|
|
399
|
+
|
|
400
|
+
def rpc_request(name, request, fields)
|
|
401
|
+
unless request.nil? || fields.empty?
|
|
402
|
+
raise ArgumentError, "#{name}: pass the request or its fields, not both (#{fields.keys.join(', ')})"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
request || fields
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# The responses of a server-streaming call. Nothing is sent until
|
|
410
|
+
# it's read; then messages are yielded as they arrive, a few at most
|
|
411
|
+
# buffered ahead of the reader.
|
|
412
|
+
#
|
|
413
|
+
# items.list_items(category: "books").each { |item| ... }
|
|
414
|
+
# items.list_items(category: "books").first(10)
|
|
415
|
+
#
|
|
416
|
+
# Stopping early (`break`, `first(n)`, an exception) cancels the call.
|
|
417
|
+
# An error from the server raises Tonic::Rails::RpcError after the
|
|
418
|
+
# messages that came before it. A stream can be read once.
|
|
419
|
+
class ServerStream
|
|
420
|
+
include Enumerable
|
|
421
|
+
|
|
422
|
+
# @return [Hash, nil] response headers, once the stream has ended
|
|
423
|
+
attr_reader :headers
|
|
424
|
+
|
|
425
|
+
# @return [Hash, nil] trailing metadata, once the stream has ended
|
|
426
|
+
attr_reader :trailers
|
|
427
|
+
|
|
428
|
+
# @api private
|
|
429
|
+
def initialize(path, &read)
|
|
430
|
+
@path = path
|
|
431
|
+
@read = read
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# @yieldparam response [Google::Protobuf::AbstractMessage]
|
|
435
|
+
# @return [self, Enumerator] an Enumerator without a block
|
|
436
|
+
def each(&block)
|
|
437
|
+
return enum_for(:each) unless block
|
|
438
|
+
raise Error, "the #{@path} stream has already been read" unless @read
|
|
439
|
+
|
|
440
|
+
read = @read
|
|
441
|
+
@read = nil
|
|
442
|
+
read.call(self, block)
|
|
443
|
+
self
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# @api private
|
|
447
|
+
def finish(headers, trailers)
|
|
448
|
+
@headers = headers
|
|
449
|
+
@trailers = trailers
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def inspect
|
|
453
|
+
"#<#{self.class.name} #{@path}>"
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
require "tonic/rails/client/retry_policy"
|
|
@@ -24,18 +24,34 @@ module Tonic
|
|
|
24
24
|
module Instrumentation
|
|
25
25
|
EVENT = "process_rpc.tonic_rails"
|
|
26
26
|
|
|
27
|
+
# Outgoing calls made with Tonic::Rails::Client publish
|
|
28
|
+
# "request_rpc.tonic_rails", with the same payload plus :address, the
|
|
29
|
+
# server called, and :attempts, how many times it was tried.
|
|
30
|
+
REQUEST_EVENT = "request_rpc.tonic_rails"
|
|
31
|
+
|
|
27
32
|
module_function
|
|
28
33
|
|
|
29
34
|
# @api private
|
|
30
|
-
def instrument(path, metadata)
|
|
35
|
+
def instrument(path, metadata, &)
|
|
36
|
+
publish(EVENT, path, metadata, &)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @api private
|
|
40
|
+
def instrument_request(path, address, metadata, &)
|
|
41
|
+
publish(REQUEST_EVENT, path, metadata, address: address, &)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Yields the payload, or nil when nothing is listening.
|
|
45
|
+
# @api private
|
|
46
|
+
def publish(event, path, metadata, **extra)
|
|
31
47
|
# Without subscribers, skip building the payload: this runs on
|
|
32
48
|
# every RPC.
|
|
33
|
-
return yield unless ActiveSupport::Notifications.notifier.listening?(
|
|
49
|
+
return yield(nil) unless ActiveSupport::Notifications.notifier.listening?(event)
|
|
34
50
|
|
|
35
51
|
service, rpc = path.delete_prefix("/").split("/", 2)
|
|
36
|
-
payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil }
|
|
37
|
-
ActiveSupport::Notifications.instrument(
|
|
38
|
-
result = yield
|
|
52
|
+
payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil, **extra }
|
|
53
|
+
ActiveSupport::Notifications.instrument(event, payload) do
|
|
54
|
+
result = yield(payload)
|
|
39
55
|
payload[:code] = 0
|
|
40
56
|
payload[:status] = :ok
|
|
41
57
|
result
|
|
@@ -19,6 +19,8 @@ module Tonic
|
|
|
19
19
|
# The message, metadata and details go to the client, so keep internal
|
|
20
20
|
# details out of them. Any other exception becomes INTERNAL with a
|
|
21
21
|
# generic message and is logged server-side.
|
|
22
|
+
#
|
|
23
|
+
# Tonic::Rails::Client raises it too, for calls that fail.
|
|
22
24
|
class RpcError < Error
|
|
23
25
|
CODES = {
|
|
24
26
|
ok: 0,
|
|
@@ -114,6 +116,65 @@ module Tonic
|
|
|
114
116
|
CODE_NAMES.fetch(@code)
|
|
115
117
|
end
|
|
116
118
|
|
|
119
|
+
# Unpacks the details of an encoded google.rpc.Status (from the
|
|
120
|
+
# grpc-status-details-bin trailer). Details whose message type is
|
|
121
|
+
# loaded come back as that message; others stay Google::Protobuf::Any.
|
|
122
|
+
# @api private
|
|
123
|
+
# @return [Array<Google::Protobuf::AbstractMessage>]
|
|
124
|
+
def self.decode_details(bytes)
|
|
125
|
+
return [] if bytes.nil? || bytes.empty?
|
|
126
|
+
|
|
127
|
+
require "google/protobuf/well_known_types"
|
|
128
|
+
details = []
|
|
129
|
+
each_length_delimited_field(bytes.b) do |field, value|
|
|
130
|
+
details << unpack_detail(Google::Protobuf::Any.decode(value)) if field == 3
|
|
131
|
+
end
|
|
132
|
+
details
|
|
133
|
+
rescue Google::Protobuf::ParseError, ArgumentError
|
|
134
|
+
[]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Yields (field number, bytes) for each length-delimited field,
|
|
138
|
+
# skipping varints; raises ArgumentError on anything malformed.
|
|
139
|
+
def self.each_length_delimited_field(buf)
|
|
140
|
+
pos = 0
|
|
141
|
+
while pos < buf.bytesize
|
|
142
|
+
key, pos = read_varint(buf, pos)
|
|
143
|
+
case key & 7
|
|
144
|
+
when 0 then _, pos = read_varint(buf, pos)
|
|
145
|
+
when 2
|
|
146
|
+
length, pos = read_varint(buf, pos)
|
|
147
|
+
raise ArgumentError, "truncated google.rpc.Status" if pos + length > buf.bytesize
|
|
148
|
+
|
|
149
|
+
yield key >> 3, buf.byteslice(pos, length)
|
|
150
|
+
pos += length
|
|
151
|
+
else raise ArgumentError, "unexpected wire type in google.rpc.Status"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
private_class_method :each_length_delimited_field
|
|
156
|
+
|
|
157
|
+
def self.read_varint(buf, pos)
|
|
158
|
+
value = 0
|
|
159
|
+
shift = 0
|
|
160
|
+
loop do
|
|
161
|
+
byte = buf.getbyte(pos) or raise ArgumentError, "truncated varint"
|
|
162
|
+
pos += 1
|
|
163
|
+
value |= (byte & 0x7f) << shift
|
|
164
|
+
return [value, pos] if byte < 0x80
|
|
165
|
+
|
|
166
|
+
shift += 7
|
|
167
|
+
raise ArgumentError, "varint too long" if shift > 63
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
private_class_method :read_varint
|
|
171
|
+
|
|
172
|
+
def self.unpack_detail(any)
|
|
173
|
+
type = Google::Protobuf::DescriptorPool.generated_pool.lookup(any.type_name)
|
|
174
|
+
type.is_a?(Google::Protobuf::Descriptor) ? any.unpack(type.msgclass) : any
|
|
175
|
+
end
|
|
176
|
+
private_class_method :unpack_detail
|
|
177
|
+
|
|
117
178
|
# @api private
|
|
118
179
|
def self.normalize_code(code)
|
|
119
180
|
case code
|
data/lib/tonic/rails/version.rb
CHANGED
data/lib/tonic-rails.rb
CHANGED
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.7.0
|
|
5
5
|
platform: x86_64-linux-gnu
|
|
6
6
|
authors:
|
|
7
7
|
- Matt Tanous
|
|
@@ -67,6 +67,8 @@ files:
|
|
|
67
67
|
- lib/tonic/rails/3.4/tonic_rails_native.so
|
|
68
68
|
- lib/tonic/rails/4.0/tonic_rails_native.so
|
|
69
69
|
- lib/tonic/rails/base.rb
|
|
70
|
+
- lib/tonic/rails/client.rb
|
|
71
|
+
- lib/tonic/rails/client/retry_policy.rb
|
|
70
72
|
- lib/tonic/rails/cluster.rb
|
|
71
73
|
- lib/tonic/rails/configuration.rb
|
|
72
74
|
- lib/tonic/rails/health.rb
|