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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5aa7de65014581bfcd5575510bdcd6ec9ec4bd0ab81473e49077188093ff436a
4
- data.tar.gz: eaf8038cfa3d9b62e16d575b4e56001bb9873cdc30ed19f9ab43967869d907f5
3
+ metadata.gz: 83bded7e4b04dc72602f73fa065ac32bde6f97631fe4450525432ef6f93df506
4
+ data.tar.gz: 13bb870aa4bfb1487fa9827745aceac8c1a0a211053a9b5adafebe494975307e
5
5
  SHA512:
6
- metadata.gz: cc4417d8c41fb9fb0e363ffb0605ac745d9b564e63a413b7b86e6d2d8a0c861f035f2553a6df8602466ead880cd58d1cc50bfc798536e97bc967d67b7b107b0f
7
- data.tar.gz: 484f38fa65491cf4e34e32e30e35af03ee7b5de3e354b33e4c23fbbe1df50b55a7dc9d19411f59ef1db04767fca9eb69d90facdae940e79f4f0cd6f92c77700c
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.5.0...HEAD
189
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.7.0...HEAD
190
+ [0.7.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.6.0...v0.7.0
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
  ```
@@ -248,7 +248,10 @@ module Tonic
248
248
 
249
249
  @request = build_request(reader, spec)
250
250
  response = nil
251
- run_callbacks(:rpc) { response = respond(public_send(spec[:handler], @request), spec, sender) }
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?(EVENT)
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(EVENT, payload) do
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
data/lib/tonic-rails.rb CHANGED
@@ -21,6 +21,7 @@ require "tonic/rails/health"
21
21
  require "tonic/rails/reflection"
22
22
  require "tonic/rails/cluster"
23
23
  require "tonic/rails/server"
24
+ require "tonic/rails/client"
24
25
 
25
26
  module Tonic
26
27
  module Rails
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.5.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