tonic-rails 0.5.0-x86_64-linux-gnu → 0.6.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: 69a7ded16d01d0e9fc54dd2a24eecc85b36aa49362c4984dd5f4f234429e24a4
4
+ data.tar.gz: 267553cdb615846fd3e3c39c0e3417154cce6e7cbdb470b42b4a7229717908d1
5
5
  SHA512:
6
- metadata.gz: cc4417d8c41fb9fb0e363ffb0605ac745d9b564e63a413b7b86e6d2d8a0c861f035f2553a6df8602466ead880cd58d1cc50bfc798536e97bc967d67b7b107b0f
7
- data.tar.gz: 484f38fa65491cf4e34e32e30e35af03ee7b5de3e354b33e4c23fbbe1df50b55a7dc9d19411f59ef1db04767fca9eb69d90facdae940e79f4f0cd6f92c77700c
6
+ metadata.gz: 50ad4688167a303a40c8b64bca81de9301c997c9bc29e2d355cd773bcbec1696f029c357ec343fc96fa1eca9b1e936ba40e8e602a015dc49a804f6428090e425
7
+ data.tar.gz: d14c5ab406b10f432169da4917c44647cbca451c1e6d1b607b9ee1fab8d1038edfe998f2f7b597edf94404323a0654f9d6e98b8c18d682c747c7cf0b4bf042e1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - Client: `Tonic::Rails::Client` makes unary calls to other gRPC
15
+ services without the grpc gem. Stubs come from the descriptors that
16
+ `tonic_rails:compile_protos` generates. Calls take deadlines (and
17
+ default to the deadline of the RPC being handled), send metadata,
18
+ return headers and trailers with `return_call: true`, and raise
19
+ `RpcError` with rich details. TLS uses the system's root certificates,
20
+ a custom CA or mutual TLS. Clients keep working across `fork` with no
21
+ hooks, and `Timeout` and `Thread#raise` interrupt calls. Each call
22
+ publishes a `request_rpc.tonic_rails` event. Closes #37.
23
+ - Client: server-streaming calls. The stub method returns a stream that
24
+ yields each message as it arrives. A slow reader holds the server back
25
+ through HTTP/2 flow control, and stopping early (`break`, `first(n)`,
26
+ an exception) cancels the call. The deadline covers the whole stream,
27
+ an error partway through raises `RpcError` after the messages before
28
+ it, and `headers` and `trailers` are available once the stream ends.
29
+ Closes #38.
30
+
10
31
  ## [0.5.0] - 2026-10-11
11
32
 
12
33
  ### Added
@@ -145,7 +166,8 @@ First public release.
145
166
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
146
167
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
147
168
 
148
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.5.0...HEAD
169
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.6.0...HEAD
170
+ [0.6.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.5.0...v0.6.0
149
171
  [0.5.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...v0.5.0
150
172
  [0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
151
173
  [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,197 @@ 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
+ ### Metadata
511
+
512
+ `metadata:` on a call, and on the client for every call, works like
513
+ response metadata in handlers: `-bin` names take binary values. To read
514
+ what the server sent back, pass `return_call: true`:
515
+
516
+ ```ruby
517
+ call = items.get_item({ id: 1 }, return_call: true)
518
+ call.response # the message
519
+ call.headers # response headers, -bin values decoded
520
+ call.trailers # trailing metadata
521
+ ```
522
+
523
+ ### TLS
524
+
525
+ TLS is on unless you turn it off, and plaintext needs an explicit
526
+ `tls: false`:
527
+
528
+ ```ruby
529
+ Tonic::Rails::Client.new("items.internal:50051") # system root certificates
530
+ Tonic::Rails::Client.new("items.internal:50051", tls_ca_path: "ca.pem") # your own CA instead
531
+ Tonic::Rails::Client.new("items.internal:50051", tls_ca_path: "ca.pem",
532
+ tls_cert_path: "client.pem", tls_key_path: "client.key") # mutual TLS
533
+ Tonic::Rails::Client.new("localhost:50051", tls: false) # plaintext
534
+ ```
535
+
536
+ `tls_server_name:` checks the server's certificate against another name
537
+ than the host you connect to. Unreadable certificates or keys raise when
538
+ the client is created, not on the first call.
539
+
540
+ ### Options
541
+
542
+ | Option | Default | |
543
+ |---|---|---|
544
+ | `tls` | `true` | `false` for plaintext |
545
+ | `tls_ca_path` | system roots | CA certificates (PEM) to trust instead |
546
+ | `tls_cert_path`, `tls_key_path` | none | client certificate and key (PEM), for mutual TLS |
547
+ | `tls_server_name` | the host | name to verify the server's certificate against |
548
+ | `deadline` | none | seconds each call may take, unless the call sets its own |
549
+ | `metadata` | `{}` | sent with every call |
550
+ | `max_message_size` | 4 MiB | largest message sent or received |
551
+ | `connect_timeout` | 5 | seconds to wait for a connection |
552
+
553
+ ### Fork safety
554
+
555
+ A client created before `fork` works in both processes, on Linux and
556
+ macOS, with nothing to call. Each process opens its own connection on
557
+ its first call, and a child never touches the threads or sockets it
558
+ inherited from its parent. Create clients in initializers, before Puma or
559
+ Sidekiq forks, and use them anywhere.
560
+
561
+ On macOS, also create the first client that uses the system's root
562
+ certificates before forking. Loading them goes through macOS's security
563
+ APIs, which abort in a process forked from a multi-threaded one. Loaded
564
+ once, they're kept and inherited, so clients created later in children
565
+ are fine. Linux has no such limit.
566
+
567
+ ### Migrating from the grpc gem
568
+
569
+ Call sites barely change; how you build the stub does:
570
+
571
+ ```ruby
572
+ # Before: grpc gem
573
+ stub = Demo::V1::ItemService::Stub.new("items.internal:50051", GRPC::Core::ChannelCredentials.new)
574
+ item = stub.get_item(Demo::V1::GetItemRequest.new(id: 1), deadline: Time.now + 2, metadata: { "x-id" => "1" })
575
+
576
+ # After: tonic-rails
577
+ stub = Tonic::Rails::Client.new("items.internal:50051").stub("demo.v1.ItemService")
578
+ item = stub.get_item(Demo::V1::GetItemRequest.new(id: 1), deadline: Time.now + 2, metadata: { "x-id" => "1" })
579
+ ```
580
+
581
+ - `stub` also accepts the grpc gem's service class
582
+ (`Demo::V1::ItemService::Service`), which helps while both gems are
583
+ installed.
584
+ - `deadline:` takes a `Time`, as before, or seconds.
585
+ - `return_op: true` becomes `return_call: true`, which returns the
586
+ response with its headers and trailers instead of an operation.
587
+ - `GRPC::BadStatus` subclasses become one `Tonic::Rails::RpcError`:
588
+ `rescue GRPC::NotFound` becomes `rescue Tonic::Rails::RpcError => e`
589
+ and a check of `e.code_name == :not_found`. The `raise_grpc_error`
590
+ matcher and `assert_grpc_error` accept both.
591
+ - `:this_channel_is_insecure` becomes `tls: false`.
592
+ - Server-streaming methods return a stream instead of an Enumerator.
593
+ Code that calls `each` or other Enumerable methods on the result works
594
+ unchanged; for `next`, call `each` first to get an Enumerator.
595
+ - Client and bidirectional streaming aren't supported yet. Stubs refuse
596
+ those methods with a `NotImplementedError`; keep the grpc gem for them
597
+ for now.
598
+
404
599
  ## Testing handlers
405
600
 
406
601
  `Tonic::Rails::Testing` calls a handler in-process, with real protobuf
@@ -634,6 +829,11 @@ When a call fails, `event.payload[:exception_object]` is the
634
829
  `Tonic::Rails::RpcError` sent to the client. If a handler raised something
635
830
  unexpected, that error's `cause` is the original exception.
636
831
 
832
+ Calls made with `Tonic::Rails::Client` publish `request_rpc.tonic_rails`,
833
+ with the same payload plus `:address`, the server called. For a
834
+ server-streaming call, the event covers the whole stream, including the
835
+ time your block spends on each message.
836
+
637
837
  ## How it works
638
838
 
639
839
  ```
@@ -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,371 @@
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
+ # Stubs are built from the protobuf descriptors that
18
+ # `tonic_rails:compile_protos` (protoc --ruby_out) generates; no
19
+ # `_services_pb.rb` is needed. A failed call raises
20
+ # Tonic::Rails::RpcError.
21
+ #
22
+ # A client holds one HTTP/2 connection and is safe to share between
23
+ # threads, so create it once (a constant or an initializer). It keeps
24
+ # working across `fork`: each process connects on its own, with no
25
+ # hooks to call.
26
+ class Client
27
+ # A response with its headers and trailers; see `return_call:`.
28
+ Call = Data.define(:response, :headers, :trailers)
29
+
30
+ DEFAULT_MAX_MESSAGE_SIZE = 4 * 1024 * 1024
31
+
32
+ # @return [String] "host:port"
33
+ attr_reader :address
34
+
35
+ # @return [Numeric, nil] default deadline in seconds
36
+ attr_reader :deadline
37
+
38
+ # @param address [String] "host:port"
39
+ # @param tls [Boolean] false for plaintext, which must be explicit
40
+ # @param tls_ca_path [String, nil] CA certificates (PEM) to trust instead
41
+ # of the system's root certificates
42
+ # @param tls_cert_path [String, nil] client certificate (PEM), for mutual TLS
43
+ # @param tls_key_path [String, nil] client private key (PEM), for mutual TLS
44
+ # @param tls_server_name [String, nil] name to verify the server's
45
+ # certificate against, if not the host in `address`
46
+ # @param deadline [Numeric, nil] seconds each call may take, unless the
47
+ # call sets its own
48
+ # @param metadata [Hash] sent with every call (see Tonic::Rails::Metadata)
49
+ # @param max_message_size [Integer] largest message sent or received, in bytes
50
+ # @param connect_timeout [Numeric] seconds to wait for a connection
51
+ def initialize(address, tls: true, tls_ca_path: nil, tls_cert_path: nil, tls_key_path: nil,
52
+ tls_server_name: nil, deadline: nil, metadata: {}, max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
53
+ connect_timeout: 5)
54
+ Tonic::Rails.native_extension!
55
+ validate_address!(address)
56
+ validate_tls!(tls, tls_cert_path, tls_key_path)
57
+ positive!(:deadline, deadline) unless deadline.nil?
58
+ positive!(:connect_timeout, connect_timeout)
59
+ unless max_message_size.is_a?(Integer) && max_message_size.positive?
60
+ raise ArgumentError, "max_message_size must be a positive Integer"
61
+ end
62
+
63
+ @address = address
64
+ @deadline = deadline
65
+ @metadata = Metadata.encode(metadata)
66
+ @stubs = {}
67
+ tls_options = [read_pem(tls_ca_path, "CA"), read_pem(tls_cert_path, "certificate"),
68
+ read_pem(tls_key_path, "key"), tls_server_name]
69
+ @native = TonicRails::Native::Channel.new(
70
+ "#{tls ? 'https' : 'http'}://#{address}", max_message_size, (connect_timeout * 1000).ceil,
71
+ (tls_options if tls)
72
+ )
73
+ end
74
+
75
+ # A stub with one method per RPC of the service, named in snake_case
76
+ # (`GetItem` becomes `get_item`).
77
+ #
78
+ # @param service [String, Google::Protobuf::ServiceDescriptor, #service_name]
79
+ # the fully-qualified service name, its descriptor, or a grpc gem
80
+ # service class (`Demo::V1::ItemService::Service`)
81
+ # @return [Tonic::Rails::Client::Stub]
82
+ def stub(service)
83
+ descriptor = service_descriptor(service)
84
+ @stubs[descriptor.name] ||= Stub.for(descriptor).new(self)
85
+ end
86
+
87
+ def inspect
88
+ "#<#{self.class.name} #{address}>"
89
+ end
90
+
91
+ # The deadline of the RPC this thread is handling, as a monotonic
92
+ # clock time; outgoing calls default to it.
93
+ # @api private
94
+ def self.current_deadline
95
+ Thread.current[:tonic_rails_deadline]
96
+ end
97
+
98
+ # @api private
99
+ def self.with_deadline(deadline)
100
+ previous = Thread.current[:tonic_rails_deadline]
101
+ Thread.current[:tonic_rails_deadline] = deadline
102
+ yield
103
+ ensure
104
+ Thread.current[:tonic_rails_deadline] = previous
105
+ end
106
+
107
+ # Make a unary call. Stubs call this; use them instead.
108
+ # @api private
109
+ def unary(path, input, output, request, deadline: nil, metadata: {}, return_call: false)
110
+ message = request_message(path, input, request)
111
+ metadata = call_metadata(metadata)
112
+ timeout_ms = remaining_ms(deadline_at(deadline))
113
+
114
+ Instrumentation.instrument_request(path, address, metadata) do
115
+ code, error_message, response, headers, trailers, details =
116
+ @native.unary(path, input.encode(message), metadata, timeout_ms)
117
+ headers = decode_metadata(headers)
118
+ trailers = decode_metadata(trailers)
119
+ check_status!(code, error_message, trailers, details)
120
+
121
+ response = decode_response(output, response, path)
122
+ return_call ? Call.new(response: response, headers: headers, trailers: trailers) : response
123
+ end
124
+ end
125
+
126
+ # Start a server-streaming call. Stubs call this; use them instead.
127
+ # @api private
128
+ def server_streaming(path, input, output, request, deadline: nil, metadata: {})
129
+ message = request_message(path, input, request)
130
+ metadata = call_metadata(metadata)
131
+ # Fixed now, so the stream keeps the deadline of the RPC this
132
+ # thread is handling even if it's read later.
133
+ deadline_at = deadline_at(deadline)
134
+
135
+ ServerStream.new(path) do |stream, block|
136
+ timeout_ms = remaining_ms(deadline_at)
137
+ Instrumentation.instrument_request(path, address, metadata) do
138
+ native = @native.server_streaming(path, input.encode(message), metadata, timeout_ms)
139
+ begin
140
+ while (bytes = native.next)
141
+ block.call(decode_response(output, bytes, path))
142
+ end
143
+ code, error_message, _, headers, trailers, details = native.result
144
+ trailers = decode_metadata(trailers)
145
+ stream.finish(decode_metadata(headers), trailers)
146
+ check_status!(code, error_message, trailers, details)
147
+ ensure
148
+ native.close
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ def validate_address!(address)
157
+ return if address.is_a?(String) && address.match?(%r{\A[^\s/]+:\d+\z})
158
+
159
+ raise ArgumentError, "address must be \"host:port\", got #{address.inspect}"
160
+ end
161
+
162
+ def validate_tls!(tls, cert_path, key_path)
163
+ raise ArgumentError, "tls must be true or false" unless [true, false].include?(tls)
164
+ raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if cert_path.nil? != key_path.nil?
165
+ raise ArgumentError, "tls_cert_path and tls_key_path need tls: true" if cert_path && !tls
166
+ end
167
+
168
+ def positive!(name, value)
169
+ raise ArgumentError, "#{name} must be a positive number of seconds" unless value.is_a?(Numeric) && value.positive?
170
+ end
171
+
172
+ def read_pem(path, what)
173
+ return unless path
174
+ raise ArgumentError, "TLS #{what} file not found: #{path}" unless File.file?(path)
175
+
176
+ File.read(path)
177
+ end
178
+
179
+ def service_descriptor(service)
180
+ return service if service.is_a?(Google::Protobuf::ServiceDescriptor)
181
+
182
+ name = service.respond_to?(:service_name) ? service.service_name : service.to_s
183
+ descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(name)
184
+ return descriptor if descriptor.is_a?(Google::Protobuf::ServiceDescriptor)
185
+
186
+ raise NotConfiguredError,
187
+ "#{name} is not a known protobuf service. Require its generated _pb.rb file (protoc --ruby_out) first."
188
+ end
189
+
190
+ def request_message(path, input, request)
191
+ case request
192
+ when input then request
193
+ when Hash then input.new(request)
194
+ else raise TypeError, "#{path} expects a #{input} or Hash, got #{request.class}"
195
+ end
196
+ end
197
+
198
+ def call_metadata(metadata)
199
+ metadata.empty? ? @metadata : @metadata.merge(Metadata.encode(metadata))
200
+ end
201
+
202
+ # When the call must end, as a monotonic clock time: the call's own
203
+ # deadline, or else the earlier of the client's default and the
204
+ # deadline of the RPC being handled.
205
+ def deadline_at(deadline)
206
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
207
+ case deadline
208
+ when nil then [(now + @deadline if @deadline), self.class.current_deadline].compact.min
209
+ when Numeric then now + deadline
210
+ when Time then now + (deadline - Time.now)
211
+ else raise ArgumentError, "deadline must be seconds (Numeric) or a Time, got #{deadline.class}"
212
+ end
213
+ end
214
+
215
+ # Milliseconds left until `deadline_at`, or nil for no deadline.
216
+ def remaining_ms(deadline_at)
217
+ return unless deadline_at
218
+
219
+ remaining = deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
220
+ raise RpcError.new("deadline exceeded", code: :deadline_exceeded) unless remaining.positive?
221
+
222
+ (remaining * 1000).ceil
223
+ end
224
+
225
+ def check_status!(code, message, trailers, details)
226
+ return if code.zero?
227
+
228
+ raise RpcError.new(message.empty? ? nil : message, code: code, metadata: trailers,
229
+ details: RpcError.decode_details(details))
230
+ end
231
+
232
+ # Wire-form [name, value] pairs to a Hash: `-bin` values decoded,
233
+ # repeated names collected into an Array, and transport headers
234
+ # (content-type, grpc-*) dropped.
235
+ def decode_metadata(pairs)
236
+ pairs.each_with_object({}) do |(name, value), metadata|
237
+ next if name.start_with?("grpc-") || Metadata::RESERVED.include?(name)
238
+
239
+ value = value.unpack1("m") if name.end_with?("-bin")
240
+ metadata[name] = metadata.key?(name) ? [*metadata[name], value] : value
241
+ end
242
+ end
243
+
244
+ def decode_response(output, bytes, path)
245
+ output.decode(bytes)
246
+ rescue Google::Protobuf::ParseError
247
+ raise RpcError.new("invalid protobuf response from #{path}", code: :internal)
248
+ end
249
+
250
+ # Calls an RPC's methods. Built per service from its descriptor; get
251
+ # one with Client#stub.
252
+ class Stub
253
+ class << self
254
+ # @api private
255
+ attr_reader :service_name
256
+
257
+ # @api private
258
+ def for(descriptor)
259
+ Class.new(self) do
260
+ @service_name = descriptor.name
261
+ descriptor.each { |method| define_rpc(method) }
262
+ end
263
+ end
264
+
265
+ def inspect
266
+ service_name ? "#{Stub.name}(#{service_name})" : super
267
+ end
268
+
269
+ private
270
+
271
+ def define_rpc(method)
272
+ path = "/#{service_name}/#{method.name}"
273
+ name = ActiveSupport::Inflector.underscore(method.name)
274
+ if method.client_streaming
275
+ define_method(name) do |*, **|
276
+ raise NotImplementedError,
277
+ "#{path} is a client-streaming RPC; the tonic-rails client makes unary and server-streaming calls"
278
+ end
279
+ return
280
+ end
281
+
282
+ input = method.input_type.msgclass
283
+ output = method.output_type.msgclass
284
+ if method.server_streaming
285
+ define_method(name) do |request = nil, deadline: nil, metadata: {}, **fields|
286
+ request = rpc_request(name, request, fields)
287
+ @client.server_streaming(path, input, output, request, deadline: deadline, metadata: metadata)
288
+ end
289
+ else
290
+ # `get_item(id: 1)` works as well as `get_item({ id: 1 })`.
291
+ define_method(name) do |request = nil, deadline: nil, metadata: {}, return_call: false, **fields|
292
+ request = rpc_request(name, request, fields)
293
+ @client.unary(path, input, output, request, deadline: deadline, metadata: metadata,
294
+ return_call: return_call)
295
+ end
296
+ end
297
+ end
298
+ end
299
+
300
+ # @return [Tonic::Rails::Client]
301
+ attr_reader :client
302
+
303
+ def initialize(client)
304
+ @client = client
305
+ end
306
+
307
+ def inspect
308
+ "#<#{self.class.inspect} #{client.address}>"
309
+ end
310
+
311
+ private
312
+
313
+ def rpc_request(name, request, fields)
314
+ unless request.nil? || fields.empty?
315
+ raise ArgumentError, "#{name}: pass the request or its fields, not both (#{fields.keys.join(', ')})"
316
+ end
317
+
318
+ request || fields
319
+ end
320
+ end
321
+
322
+ # The responses of a server-streaming call. Nothing is sent until
323
+ # it's read; then messages are yielded as they arrive, a few at most
324
+ # buffered ahead of the reader.
325
+ #
326
+ # items.list_items(category: "books").each { |item| ... }
327
+ # items.list_items(category: "books").first(10)
328
+ #
329
+ # Stopping early (`break`, `first(n)`, an exception) cancels the call.
330
+ # An error from the server raises Tonic::Rails::RpcError after the
331
+ # messages that came before it. A stream can be read once.
332
+ class ServerStream
333
+ include Enumerable
334
+
335
+ # @return [Hash, nil] response headers, once the stream has ended
336
+ attr_reader :headers
337
+
338
+ # @return [Hash, nil] trailing metadata, once the stream has ended
339
+ attr_reader :trailers
340
+
341
+ # @api private
342
+ def initialize(path, &read)
343
+ @path = path
344
+ @read = read
345
+ end
346
+
347
+ # @yieldparam response [Google::Protobuf::AbstractMessage]
348
+ # @return [self, Enumerator] an Enumerator without a block
349
+ def each(&block)
350
+ return enum_for(:each) unless block
351
+ raise Error, "the #{@path} stream has already been read" unless @read
352
+
353
+ read = @read
354
+ @read = nil
355
+ read.call(self, block)
356
+ self
357
+ end
358
+
359
+ # @api private
360
+ def finish(headers, trailers)
361
+ @headers = headers
362
+ @trailers = trailers
363
+ end
364
+
365
+ def inspect
366
+ "#<#{self.class.name} #{@path}>"
367
+ end
368
+ end
369
+ end
370
+ end
371
+ end
@@ -24,17 +24,32 @@ 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,
29
+ # the server called.
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
+ # @api private
45
+ def publish(event, path, metadata, **extra)
31
46
  # Without subscribers, skip building the payload: this runs on
32
47
  # every RPC.
33
- return yield unless ActiveSupport::Notifications.notifier.listening?(EVENT)
48
+ return yield unless ActiveSupport::Notifications.notifier.listening?(event)
34
49
 
35
50
  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
51
+ payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil, **extra }
52
+ ActiveSupport::Notifications.instrument(event, payload) do
38
53
  result = yield
39
54
  payload[:code] = 0
40
55
  payload[:status] = :ok
@@ -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.6.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.6.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Matt Tanous
@@ -67,6 +67,7 @@ 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
70
71
  - lib/tonic/rails/cluster.rb
71
72
  - lib/tonic/rails/configuration.rb
72
73
  - lib/tonic/rails/health.rb