async-grpc 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 219cdaeda983bb25f35394cea48f426917006b3934cf673a256423c049602120
4
- data.tar.gz: dda6c3c9607a451a9447d19c340f5c06c9baa8321f0e39838d6fb2be026e8cb9
3
+ metadata.gz: ea5b102766838b1d027771084f9696707e1cbf5dac9fdee9004daf4724b5a318
4
+ data.tar.gz: 105255db3b068be3a28f0234e1ce77600d5780a7220a15e848eb6ad9e855ddb2
5
5
  SHA512:
6
- metadata.gz: ea120b857416b034f83665941f130040c5fa16de13f87889f5020121172250b221447d2284952143a6baca305ad42d67be6281673cb3a1bb6aea2a0bb0511b38
7
- data.tar.gz: 340a5ba4fe97718e80047df7684aa0e5c7e07f900d2b2d5d7e8282e4bd28f84558502a9e010ddb19ab474bae138ee7019f95e8e8c91e21309b4ff8651d0b17b0
6
+ metadata.gz: 7ff58e16e452b3c37ce7c4a8ded6ed7381580f1d2a8b046d644b3449b1b5d0df791f3463acb6fcfd8168d8a9c53cb094cc40988c82afded2478d9b58a5059499
7
+ data.tar.gz: 7b9e4e143f778688971dbee371c1e6ebfc049ad002dd942d570e19570880336c18a36a30e681692e267d561fda48f0b1060813efe951625b76e2a1fc51dd1f49
checksums.yaml.gz.sig CHANGED
Binary file
@@ -53,12 +53,17 @@ module Async
53
53
 
54
54
  # Close output stream:
55
55
  output.close_write unless output.closed?
56
+
57
+ # gRPC supports trailers-only responses, but only if there are no data frames. If at this point, there are data frames (which may or may not have been sent yet), we need to mark trailers:
58
+ if output.count > 0
59
+ call.response.headers.trailer!
60
+ end
56
61
  end
57
62
 
58
- # Mark trailers and add status (if not already set by handler):
63
+ # Add status (if not already set by handler):
59
64
  if headers = call.response&.headers
60
65
  # Only add OK status if grpc-status hasn't been set by the handler:
61
- unless headers["grpc-status"]
66
+ unless headers.key?("grpc-status")
62
67
  Protocol::GRPC::Metadata.assign_status!(headers, status: Protocol::GRPC::Status::OK)
63
68
  end
64
69
  end
@@ -148,7 +153,7 @@ module Async
148
153
  dispatch_to_service(service, handler_method, input, output, call, deadline)
149
154
  end
150
155
 
151
- response
156
+ return response
152
157
  end
153
158
  end
154
159
  end
@@ -5,7 +5,14 @@
5
5
 
6
6
  module Async
7
7
  module GRPC
8
+ # Represents an error that originated from a remote gRPC server.
9
+ # Used as the `cause` of {Protocol::GRPC::Error} when the client receives a non-OK status.
10
+ # The message and optional backtrace are extracted from response metadata.
8
11
  class RemoteError < StandardError
12
+ # Create a RemoteError from server response metadata.
13
+ # @parameter message [String | Nil] The error message from `grpc-message` header.
14
+ # @parameter metadata [Hash] Response metadata (extracted from gRPC headers). If it contains a `"backtrace"` key (array of strings), it is set on the error and removed from the hash.
15
+ # @returns [RemoteError] The constructed error instance.
9
16
  def self.for(message, metadata)
10
17
  self.new(message).tap do |error|
11
18
  if backtrace = metadata.delete("backtrace")
@@ -7,7 +7,7 @@
7
7
  module Async
8
8
  # @namespace
9
9
  module GRPC
10
- VERSION = "0.5.1"
10
+ VERSION = "0.6.0"
11
11
  end
12
12
  end
13
13
 
data/readme.md CHANGED
@@ -24,6 +24,10 @@ Please see the [project documentation](https://socketry.github.io/async-grpc/) f
24
24
 
25
25
  Please see the [project releases](https://socketry.github.io/async-grpc/releases/index) for all releases.
26
26
 
27
+ ### v0.6.0
28
+
29
+ - Ensure `grpc-status` (and related metadata) is sent as a trailer, if data frames are written.
30
+
27
31
  ### v0.5.1
28
32
 
29
33
  - Better error logging on timeout.
data/releases.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Releases
2
2
 
3
+ ## v0.6.0
4
+
5
+ - Ensure `grpc-status` (and related metadata) is sent as a trailer, if data frames are written.
6
+
3
7
  ## v0.5.1
4
8
 
5
9
  - Better error logging on timeout.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-grpc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: protocol-http
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.60'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.60'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: protocol-grpc
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -73,7 +87,6 @@ files:
73
87
  - code.md
74
88
  - context/getting-started.md
75
89
  - context/index.yaml
76
- - design.md
77
90
  - lib/async/grpc.rb
78
91
  - lib/async/grpc/client.rb
79
92
  - lib/async/grpc/dispatcher.rb
@@ -98,7 +111,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
98
111
  requirements:
99
112
  - - ">="
100
113
  - !ruby/object:Gem::Version
101
- version: '3.2'
114
+ version: '3.3'
102
115
  required_rubygems_version: !ruby/object:Gem::Requirement
103
116
  requirements:
104
117
  - - ">="
metadata.gz.sig CHANGED
Binary file
data/design.md DELETED
@@ -1,1126 +0,0 @@
1
- # Async::GRPC Design
2
-
3
- Client and server implementation for gRPC using Async, built on top of `protocol-grpc`.
4
-
5
- ## Overview
6
-
7
- `async-grpc` provides the networking and concurrency layer for gRPC:
8
- - **`Async::GRPC::Client`** - wraps `Async::HTTP::Client` for making gRPC calls
9
- - **`Async::GRPC::Server`** - `Protocol::HTTP::Middleware` for handling gRPC requests
10
- - Built on top of `protocol-grpc` for protocol abstractions
11
- - Uses `async-http` for HTTP/2 transport
12
-
13
- ## Architecture
14
-
15
- ```
16
- ┌─────────────────────────────────────────────────────────────┐
17
- │ async-grpc │
18
- │ (Client/Server implementations with Async concurrency) │
19
- ├─────────────────────────────────────────────────────────────┤
20
- │ protocol-grpc │
21
- │ (Protocol abstractions: framing, headers, status codes) │
22
- ├─────────────────────────────────────────────────────────────┤
23
- │ protocol-http │
24
- │ (HTTP abstractions: Request, Response, Headers, Body) │
25
- ├─────────────────────────────────────────────────────────────┤
26
- │ async-http / protocol-http2 │
27
- │ (HTTP/2 transport and connection management) │
28
- └─────────────────────────────────────────────────────────────┘
29
- ```
30
-
31
- ## Design Pattern: Body Wrapping
32
-
33
- Following the pattern from `async-rest`, we wrap response bodies with rich parsing using `Protocol::HTTP::Body::Wrapper`:
34
-
35
- ```ruby
36
- # In protocol-grpc:
37
- class Protocol::GRPC::Body::Readable < Protocol::HTTP::Body::Wrapper
38
- # gRPC bodies are ALWAYS message-framed, so this is the standard readable body
39
- def initialize(body, message_class: nil, encoding: nil)
40
- super(body)
41
- @message_class = message_class
42
- @encoding = encoding
43
- @buffer = String.new.force_encoding(Encoding::BINARY)
44
- end
45
-
46
- # Override read to return decoded messages instead of raw chunks
47
- def read
48
- # Read 5-byte prefix + message data
49
- # Decompress if needed
50
- # Decode with message_class if provided
51
- end
52
- end
53
-
54
- # In async-grpc, wrap responses transparently:
55
- response = client.call(request)
56
- response.body = Protocol::GRPC::Body::Readable.new(
57
- response.body,
58
- message_class: HelloReply,
59
- encoding: response.headers["grpc-encoding"]
60
- )
61
-
62
- # Now reading is natural - standard Protocol::HTTP::Body interface:
63
- message = response.body.read # Returns decoded HelloReply message!
64
- ```
65
-
66
- This provides:
67
- - **Transparent wrapping**: Response body is automatically enhanced
68
- - **Lazy parsing**: Messages are decoded on demand
69
- - **Streaming support**: Can iterate over messages naturally
70
- - **Type safety**: Message class determines parsing
71
-
72
- ### Important: Homogeneous Message Types
73
-
74
- **In gRPC, all messages in a stream are always the same type.** This is defined in the `.proto` file:
75
-
76
- ```protobuf
77
- // All responses are HelloReply
78
- rpc StreamNumbers(HelloRequest) returns (stream HelloReply);
79
- ```
80
-
81
- This constraint simplifies the API significantly:
82
- - You specify `message_class` **once** when wrapping the body
83
- - All subsequent `read()` calls decode to that same class
84
- - No need to check message types or handle polymorphism
85
- - Standard `Protocol::HTTP::Body` interface (`read`, `each`) just works!
86
-
87
- The four RPC patterns:
88
- - **Unary**: 1 request of type A → 1 response of type B
89
- - **Server streaming**: 1 request of type A → N responses of type B (all type B)
90
- - **Client streaming**: N requests of type A (all type A) → 1 response of type B
91
- - **Bidirectional**: N requests of type A (all type A) ↔ M responses of type B (all type B)
92
-
93
- This is different from protocols like WebSockets where you might receive different message types in the same stream.
94
-
95
- ## Core Components Summary
96
-
97
- `async-grpc` provides networking and concurrency layer for gRPC:
98
-
99
- 1. **Client** - `Async::GRPC::Client` (wraps `Async::HTTP::Client`)
100
- - Four RPC methods: `unary`, `server_streaming`, `client_streaming`, `bidirectional_streaming`
101
- - Binary variants for channel adapter: `*_binary` methods
102
- - Automatic body wrapping with `Protocol::GRPC::Body::Readable`
103
-
104
- 2. **Server** - Use `Protocol::GRPC::Middleware` with `Async::HTTP::Server`
105
- - No separate Async::GRPC::Server needed!
106
- - Protocol middleware handles dispatch
107
- - Async::HTTP::Server handles connections
108
-
109
- 3. **Server Context** - `Async::GRPC::ServerCall` (extends `Protocol::GRPC::Call`)
110
- - Access request metadata
111
- - Set response metadata/trailers
112
- - Deadline tracking
113
- - Cancellation support
114
-
115
- 4. **Interceptors** - `ClientInterceptor` and `ServerInterceptor`
116
- - Wrap RPC calls
117
- - Add cross-cutting concerns (logging, auth, metrics)
118
-
119
- 5. **Channel Adapter** - `Async::GRPC::ChannelAdapter`
120
- - Compatible with `GRPC::Core::Channel` interface
121
- - Enables drop-in replacement for standard gRPC
122
- - Google Cloud library integration
123
-
124
- **Key Patterns:**
125
- - Response bodies automatically wrapped with `Protocol::GRPC::Body::Readable`
126
- - Standard `read`/`write`/`each` methods (not `read_message`/`write_message`)
127
- - Compression handled via `encoding:` parameter
128
-
129
- ### Detailed Components
130
-
131
- ### 1. `Async::GRPC::Client`
132
-
133
- Wraps `Async::HTTP::Client` to provide gRPC-specific call methods:
134
-
135
- ```ruby
136
- module Async
137
- module GRPC
138
- class Client
139
- # @parameter endpoint [Async::HTTP::Endpoint] The server endpoint
140
- # @parameter authority [String] The server authority for requests
141
- def initialize(endpoint, authority: nil)
142
- @client = Async::HTTP::Client.new(endpoint, protocol: Async::HTTP::Protocol::HTTP2)
143
- @authority = authority || endpoint.authority
144
- end
145
-
146
- # Make a unary RPC call
147
- # @parameter service [String] Service name, e.g., "my_service.Greeter"
148
- # @parameter method [String] Method name, e.g., "SayHello"
149
- # @parameter request [Object] Protobuf request message
150
- # @parameter response_class [Class] Expected response message class
151
- # @parameter metadata [Hash] Custom metadata
152
- # @parameter timeout [Numeric] Deadline for the request
153
- # @returns [Object] Protobuf response message
154
- def unary(service, method, request, response_class: nil, metadata: {}, timeout: nil)
155
- # Build request body with single message
156
- body = Protocol::GRPC::Body::Writable.new
157
- body.write(request)
158
- body.close_write
159
-
160
- # Build HTTP request
161
- http_request = build_request(service, method, body, metadata: metadata, timeout: timeout)
162
-
163
- # Make the call
164
- http_response = @client.call(http_request)
165
-
166
- # Wrap response body with gRPC message parser
167
- # This follows async-rest pattern of wrapping body for rich parsing
168
- wrap_response_body(http_response, response_class)
169
-
170
- # Read single message - standard Protocol::HTTP::Body interface
171
- # The wrapper makes .read return decoded messages instead of raw chunks
172
- message = http_response.body.read
173
-
174
- # Check status
175
- check_status!(http_response)
176
-
177
- message
178
- end
179
-
180
- # Make a server streaming RPC call
181
- # @parameter service [String] Service name
182
- # @parameter method [String] Method name
183
- # @parameter request [Object] Protobuf request message
184
- # @parameter response_class [Class] Expected response message class
185
- # @yields {|response| ...} Each response message
186
- def server_streaming(service, method, request, response_class: nil, metadata: {}, timeout: nil, &block)
187
- return enum_for(:server_streaming, service, method, request, response_class: response_class, metadata: metadata, timeout: timeout) unless block_given?
188
-
189
- # Build request body with single message
190
- body = Protocol::GRPC::Body::Writable.new
191
- body.write(request)
192
- body.close_write
193
-
194
- # Build HTTP request
195
- http_request = build_request(service, method, body, metadata: metadata, timeout: timeout)
196
-
197
- # Make the call
198
- http_response = @client.call(http_request)
199
-
200
- # Wrap response body
201
- wrap_response_body(http_response, response_class)
202
-
203
- # Stream responses - standard Protocol::HTTP::Body#each
204
- # The wrapper makes each iterate decoded messages
205
- http_response.body.each do |message|
206
- yield message
207
- end
208
-
209
- # Check status
210
- check_status!(http_response)
211
- end
212
-
213
- # Make a client streaming RPC call
214
- # @parameter service [String] Service name
215
- # @parameter method [String] Method name
216
- # @parameter response_class [Class] Expected response message class
217
- # @yields {|stream| ...} Block that writes request messages to stream
218
- # @returns [Object] Protobuf response message
219
- def client_streaming(service, method, response_class: nil, metadata: {}, timeout: nil, &block)
220
- # Build request body
221
- body = Protocol::GRPC::Body::Writable.new
222
-
223
- # Build HTTP request
224
- http_request = build_request(service, method, body, metadata: metadata, timeout: timeout)
225
-
226
- # Start the call in a task
227
- response_task = Async do
228
- @client.call(http_request)
229
- end
230
-
231
- # Yield the body writer to the caller
232
- begin
233
- yield body
234
- ensure
235
- body.close_write
236
- end
237
-
238
- # Wait for response
239
- http_response = response_task.wait
240
-
241
- # Wrap response body
242
- wrap_response_body(http_response, response_class)
243
-
244
- # Read single response
245
- message = http_response.body.read
246
-
247
- # Check status
248
- check_status!(http_response)
249
-
250
- message
251
- end
252
-
253
- # Make a bidirectional streaming RPC call
254
- # @parameter service [String] Service name
255
- # @parameter method [String] Method name
256
- # @parameter response_class [Class] Expected response message class
257
- # @yields {|input, output| ...} Block with input stream and output enumerator
258
- def bidirectional_streaming(service, method, response_class: nil, metadata: {}, timeout: nil)
259
- # Build request body
260
- body = Protocol::GRPC::Body::Writable.new
261
-
262
- # Build HTTP request
263
- http_request = build_request(service, method, body, metadata: metadata, timeout: timeout)
264
-
265
- # Start the call
266
- http_response = @client.call(http_request)
267
-
268
- # Wrap response body
269
- wrap_response_body(http_response, response_class)
270
-
271
- # Create output enumerator for reading responses
272
- # Standard Protocol::HTTP::Body#each returns enumerator of messages
273
- output = http_response.body.each
274
-
275
- # Yield input writer and output reader to caller
276
- yield body, output
277
-
278
- # Ensure body is closed
279
- body.close_write unless body.closed?
280
-
281
- # Check status
282
- check_status!(http_response)
283
- end
284
-
285
- # Close the underlying HTTP client
286
- def close
287
- @client.close
288
- end
289
-
290
- private
291
-
292
- def build_request(service, method, body, metadata: {}, timeout: nil)
293
- path = Protocol::GRPC::Methods.build_path(service, method)
294
- headers = Protocol::GRPC::Methods.build_headers(
295
- metadata: metadata,
296
- timeout: timeout
297
- )
298
-
299
- Protocol::HTTP::Request[
300
- "POST", path,
301
- headers: headers,
302
- body: body,
303
- scheme: "https",
304
- authority: @authority
305
- ]
306
- end
307
-
308
- # Wrap response body with gRPC message parser
309
- # This follows the async-rest pattern of transparent body wrapping
310
- def wrap_response_body(response, message_class)
311
- if response.body
312
- encoding = response.headers["grpc-encoding"]
313
- response.body = Protocol::GRPC::Body::Readable.new(
314
- response.body,
315
- message_class: message_class,
316
- encoding: encoding
317
- )
318
- end
319
- end
320
-
321
- # Check gRPC status and raise error if not OK
322
- def check_status!(response)
323
- status = Protocol::GRPC::Metadata.extract_status(response.headers)
324
-
325
- return if status == Protocol::GRPC::Status::OK
326
-
327
- message = Protocol::GRPC::Metadata.extract_message(response.headers)
328
- metadata = Protocol::GRPC::Methods.extract_metadata(response.headers)
329
-
330
- remote_error = RemoteError.for(message, metadata)
331
-
332
- raise Protocol::GRPC::Error.for(status, metadata: metadata), cause: remote_error
333
- end
334
- end
335
- end
336
- end
337
- ```
338
-
339
- ### 2. `Async::GRPC::ServerCall`
340
-
341
- Rich context object for server-side RPC handling:
342
-
343
- ```ruby
344
- module Async
345
- module GRPC
346
- # Server-side call context with metadata and deadline tracking
347
- class ServerCall < Protocol::GRPC::Call
348
- # @parameter request [Protocol::HTTP::Request]
349
- # @parameter response_headers [Protocol::HTTP::Headers]
350
- def initialize(request, response_headers)
351
- # Parse timeout from grpc-timeout header
352
- timeout_value = request.headers["grpc-timeout"]
353
- deadline = if timeout_value
354
- timeout_seconds = Protocol::GRPC::Methods.parse_timeout(timeout_value)
355
- Time.now + timeout_seconds if timeout_seconds
356
- end
357
-
358
- super(request, deadline: deadline)
359
- @response_headers = response_headers
360
- @response_metadata = {}
361
- @response_trailers = {}
362
- end
363
-
364
- # @attribute [Protocol::HTTP::Headers] Response headers
365
- attr :response_headers
366
-
367
- # Set response metadata (sent as initial headers)
368
- # @parameter key [String] Metadata key
369
- # @parameter value [String] Metadata value
370
- def set_metadata(key, value)
371
- @response_metadata[key] = value
372
- @response_headers[key] = value
373
- end
374
-
375
- # Set response trailer (sent after response body)
376
- # @parameter key [String] Trailer key
377
- # @parameter value [String] Trailer value
378
- def set_trailer(key, value)
379
- @response_trailers[key] = value
380
- @response_headers.trailer! unless @response_headers.trailer?
381
- @response_headers[key] = value
382
- end
383
-
384
- # Abort the RPC with an error
385
- # @parameter status [Integer] gRPC status code
386
- # @parameter message [String] Error message
387
- def abort!(status, message)
388
- raise Protocol::GRPC::Error.new(status, message)
389
- end
390
-
391
- # Check if we should stop processing
392
- # @returns [Boolean]
393
- def should_stop?
394
- cancelled? || deadline_exceeded?
395
- end
396
- end
397
- end
398
- end
399
- ```
400
-
401
- ### 3. `Async::GRPC::Interceptor`
402
-
403
- Middleware/interceptor pattern for client and server:
404
-
405
- ```ruby
406
- module Async
407
- module GRPC
408
- # Base class for client interceptors
409
- class ClientInterceptor
410
- # Intercept a client call
411
- # @parameter service [String] Service name
412
- # @parameter method [String] Method name
413
- # @parameter request [Object] Request message
414
- # @parameter call [Protocol::GRPC::Call] Call context
415
- # @yields The actual RPC call
416
- # @returns [Object] Response message
417
- def call(service, method, request, call)
418
- yield
419
- end
420
- end
421
-
422
- # Base class for server interceptors
423
- class ServerInterceptor
424
- # Intercept a server call
425
- # @parameter request [Protocol::HTTP::Request] HTTP request
426
- # @parameter call [ServerCall] Server call context
427
- # @yields The actual handler
428
- # @returns [Protocol::HTTP::Response] HTTP response
429
- def call(request, call)
430
- yield
431
- end
432
- end
433
-
434
- # Example: Logging interceptor
435
- class LoggingInterceptor < ClientInterceptor
436
- def call(service, method, request, call)
437
- Console.logger.info(self){"Calling #{service}/#{method}"}
438
- start_time = Time.now
439
-
440
- begin
441
- response = yield
442
- duration = Time.now - start_time
443
- Console.logger.info(self){"Completed #{service}/#{method} in #{duration}s"}
444
- response
445
- rescue => error
446
- Console.logger.error(self){"Failed #{service}/#{method}: #{error.message}"}
447
- raise
448
- end
449
- end
450
- end
451
-
452
- # Example: Metadata interceptor
453
- class MetadataInterceptor < ClientInterceptor
454
- def initialize(metadata = {})
455
- @metadata = metadata
456
- end
457
-
458
- def call(service, method, request, call)
459
- # Add metadata to all calls
460
- call.request.headers.merge!(@metadata)
461
- yield
462
- end
463
- end
464
- end
465
- end
466
- ```
467
-
468
- ### 4. Using with Async::HTTP::Server
469
-
470
- **You don't need a separate `Async::GRPC::Server` class!**
471
-
472
- Just use `Protocol::GRPC::Middleware` directly with `Async::HTTP::Server`. The async handling happens automatically because `Protocol::HTTP::Body::Writable` is already async-safe (uses Thread::Queue).
473
-
474
- ```ruby
475
- require "async"
476
- require "async/http/server"
477
- require "async/http/endpoint"
478
- require "protocol/grpc/middleware"
479
-
480
- # Create gRPC middleware
481
- middleware = Protocol::GRPC::Middleware.new
482
- middleware.register("my_service.Greeter", GreeterService.new)
483
-
484
- # Use with Async::HTTP::Server - it handles everything!
485
- endpoint = Async::HTTP::Endpoint.parse(
486
- "https://localhost:50051",
487
- protocol: Async::HTTP::Protocol::HTTP2
488
- )
489
-
490
- server = Async::HTTP::Server.new(middleware, endpoint)
491
-
492
- Async do
493
- server.run
494
- end
495
- ```
496
-
497
- `Async::HTTP::Server` provides:
498
- - Endpoint binding and connection acceptance
499
- - HTTP/2 protocol handling
500
- - Request/response loop in async tasks
501
- - Connection management
502
-
503
- `Protocol::GRPC::Middleware` just implements:
504
- - `call(request) → response`
505
- - Service dispatch
506
- - Message framing
507
- - Error handling
508
-
509
- **No additional async wrapper needed!** The protocol middleware is already async-compatible because:
510
- - Handlers can use `Async` tasks internally
511
- - `Body::Writable` uses async-safe queues
512
- - Reading/writing messages doesn't block the reactor
513
-
514
- ### 3. Service Handler Interface
515
-
516
- Service implementations should follow this pattern:
517
-
518
- ```ruby
519
- module Async
520
- module GRPC
521
- # Base class for service handlers (optional, but provides structure)
522
- class ServiceHandler
523
- # Each RPC method receives:
524
- # @parameter input [Protocol::GRPC::Body::Readable] Input message stream
525
- # @parameter output [Protocol::GRPC::Body::Writable] Output message stream
526
- # @parameter request [Protocol::HTTP::Request] Original HTTP request (for metadata)
527
-
528
- # Example unary RPC:
529
- def say_hello(input, output, request)
530
- # Read single request - standard .read method
531
- hello_request = input.read
532
-
533
- # Process
534
- reply = MyService::HelloReply.new(
535
- message: "Hello, #{hello_request.name}!"
536
- )
537
-
538
- # Write single response - standard .write method
539
- output.write(reply)
540
- end
541
-
542
- # Example server streaming RPC:
543
- def list_features(input, output, request)
544
- # Read single request
545
- rectangle = input.read
546
-
547
- # Write multiple responses
548
- 10.times do |i|
549
- feature = MyService::Feature.new(name: "Feature #{i}")
550
- output.write(feature)
551
- end
552
- end
553
-
554
- # Example client streaming RPC:
555
- def record_route(input, output, request)
556
- # Read multiple requests - standard .each iterator
557
- points = []
558
- input.each do |point|
559
- points << point
560
- end
561
-
562
- # Process and write single response
563
- summary = MyService::RouteSummary.new(
564
- point_count: points.size
565
- )
566
- output.write(summary)
567
- end
568
-
569
- # Example bidirectional streaming RPC:
570
- def route_chat(input, output, request)
571
- # Read and write concurrently
572
- Async do |task|
573
- # Read messages in background
574
- task.async do
575
- input.each do |note|
576
- # Process and respond
577
- response = MyService::RouteNote.new(
578
- message: "Echo: #{note.message}"
579
- )
580
- output.write(response)
581
- end
582
- end
583
- end
584
- end
585
- end
586
- end
587
- end
588
- ```
589
-
590
- ## Usage Examples
591
-
592
- ### Client Example
593
-
594
- ```ruby
595
- require "async"
596
- require "async/grpc/client"
597
- require_relative "my_service_pb"
598
-
599
- endpoint = Async::HTTP::Endpoint.parse("https://localhost:50051")
600
-
601
- Async do
602
- client = Async::GRPC::Client.new(endpoint)
603
-
604
- # Unary RPC
605
- request = MyService::HelloRequest.new(name: "World")
606
- response = client.unary(
607
- "my_service.Greeter",
608
- "SayHello",
609
- request,
610
- response_class: MyService::HelloReply
611
- )
612
- puts response.message
613
-
614
- # Server streaming RPC
615
- client.server_streaming(
616
- "my_service.Greeter",
617
- "StreamNumbers",
618
- request,
619
- response_class: MyService::HelloReply
620
- ) do |reply|
621
- puts reply.message
622
- end
623
-
624
- # Client streaming RPC
625
- response = client.client_streaming(
626
- "my_service.Greeter",
627
- "RecordRoute",
628
- response_class: MyService::RouteSummary
629
- ) do |stream|
630
- 10.times do |i|
631
- point = MyService::Point.new(latitude: i, longitude: i)
632
- stream.write(point)
633
- end
634
- end
635
- puts response.point_count
636
-
637
- # Bidirectional streaming RPC
638
- client.bidirectional_streaming(
639
- "my_service.Greeter",
640
- "RouteChat",
641
- response_class: MyService::RouteNote
642
- ) do |input, output|
643
- # Write in background
644
- task = Async do
645
- 5.times do |i|
646
- note = MyService::RouteNote.new(message: "Note #{i}")
647
- input.write(note)
648
- sleep 0.1
649
- end
650
- input.close_write
651
- end
652
-
653
- # Read responses
654
- output.each do |reply|
655
- puts reply.message
656
- end
657
-
658
- task.wait
659
- end
660
- ensure
661
- client.close
662
- end
663
- ```
664
-
665
- ### Server Example
666
-
667
- ```ruby
668
- require "async"
669
- require "async/http/server"
670
- require "async/http/endpoint"
671
- require "protocol/grpc/middleware"
672
- require_relative "my_service_pb"
673
-
674
- # Implement service handlers
675
- class GreeterService
676
- def say_hello(input, output, call)
677
- hello_request = input.read
678
-
679
- reply = MyService::HelloReply.new(
680
- message: "Hello, #{hello_request.name}!"
681
- )
682
-
683
- output.write(reply)
684
- end
685
-
686
- def stream_numbers(input, output, call)
687
- hello_request = input.read
688
-
689
- 10.times do |i|
690
- reply = MyService::HelloReply.new(
691
- message: "Number #{i} for #{hello_request.name}"
692
- )
693
- output.write(reply)
694
- sleep 0.1 # Simulate work
695
- end
696
- end
697
- end
698
-
699
- # Setup server
700
- endpoint = Async::HTTP::Endpoint.parse(
701
- "https://localhost:50051",
702
- protocol: Async::HTTP::Protocol::HTTP2
703
- )
704
-
705
- Async do
706
- # Create gRPC middleware
707
- grpc = Protocol::GRPC::Middleware.new
708
- grpc.register("my_service.Greeter", GreeterService.new)
709
-
710
- # Use with Async::HTTP::Server - no wrapper needed!
711
- server = Async::HTTP::Server.new(grpc, endpoint)
712
-
713
- server.run
714
- end
715
- ```
716
-
717
- ### Integration with Falcon
718
-
719
- ```ruby
720
- #!/usr/bin/env falcon-host
721
- # frozen_string_literal: true
722
-
723
- require "protocol/grpc/middleware"
724
- require_relative "my_service_pb"
725
-
726
- class GreeterService
727
- def say_hello(input, output, call)
728
- hello_request = input.read
729
- reply = MyService::HelloReply.new(message: "Hello, #{hello_request.name}!")
730
- output.write(reply)
731
- end
732
- end
733
-
734
- service "grpc.localhost" do
735
- include Falcon::Environment::Application
736
-
737
- middleware do
738
- # Just use Protocol::GRPC::Middleware directly!
739
- grpc = Protocol::GRPC::Middleware.new
740
- grpc.register("my_service.Greeter", GreeterService.new)
741
- grpc
742
- end
743
-
744
- scheme "https"
745
- protocol {Async::HTTP::Protocol::HTTP2}
746
-
747
- endpoint do
748
- Async::HTTP::Endpoint.for(scheme, "localhost", port: 50051, protocol: protocol)
749
- end
750
- end
751
- ```
752
-
753
- ## Integration with Existing gRPC Libraries
754
-
755
- ### Channel Adapter for Google Cloud Libraries
756
-
757
- Many existing Ruby libraries (like `google-cloud-spanner`) depend on the standard `grpc` gem and expect a `GRPC::Core::Channel` interface. To enable these libraries to use `async-grpc`, we provide a channel adapter.
758
-
759
- ```ruby
760
- module Async
761
- module GRPC
762
- # Adapter that makes Async::GRPC::Client compatible with
763
- # libraries expecting GRPC::Core::Channel
764
- class ChannelAdapter
765
- def initialize(endpoint, channel_args = {}, channel_creds = nil)
766
- @endpoint = endpoint
767
- @client = Client.new(endpoint)
768
- @channel_creds = channel_creds
769
- end
770
-
771
- # Unary RPC: "/package.Service/Method"
772
- def request_response(path, request, marshal, unmarshal, deadline: nil, metadata: {})
773
- service, method = parse_path(path)
774
- metadata = add_auth_metadata(metadata, path) if @channel_creds
775
- timeout = deadline ? [deadline - Time.now, 0].max : nil
776
-
777
- response_binary = Async do
778
- @client.unary_binary(service, method, marshal.call(request),
779
- metadata: metadata, timeout: timeout)
780
- end.wait
781
-
782
- unmarshal.call(response_binary)
783
- end
784
-
785
- # Server streaming
786
- def request_stream(path, request, marshal, unmarshal, deadline: nil, metadata: {})
787
- # Returns Enumerator of responses
788
- end
789
-
790
- # Client streaming
791
- def stream_request(path, marshal, unmarshal, deadline: nil, metadata: {})
792
- # Returns [input_stream, response_future]
793
- end
794
-
795
- # Bidirectional streaming
796
- def stream_stream(path, marshal, unmarshal, deadline: nil, metadata: {})
797
- # Returns [input_stream, output_enumerator]
798
- end
799
- end
800
- end
801
- end
802
- ```
803
-
804
- ### Binary Message Interface
805
-
806
- To support pre-marshaled protobuf data:
807
-
808
- ```ruby
809
- class Client
810
- # Unary with binary data
811
- def unary_binary(service, method, request_binary, metadata: {}, timeout: nil)
812
- # Returns binary response (no message_class decoding)
813
- end
814
-
815
- # Server streaming with binary
816
- def server_streaming_binary(service, method, request_binary, &block)
817
- # Yields binary strings
818
- end
819
- end
820
- ```
821
-
822
- ### Usage with Google Cloud
823
-
824
- ```ruby
825
- require "async/grpc/channel_adapter"
826
- require "google/cloud/spanner"
827
-
828
- endpoint = Async::HTTP::Endpoint.parse("https://spanner.googleapis.com")
829
- credentials = Google::Cloud::Spanner::Credentials.default
830
-
831
- # Create adapter
832
- channel = Async::GRPC::ChannelAdapter.new(endpoint, {}, credentials)
833
-
834
- # Use with Google Cloud libraries
835
- service = Google::Cloud::Spanner::Service.new
836
- service.instance_variable_set(:@channel, channel)
837
-
838
- # Now Spanner uses async-grpc!
839
- ```
840
-
841
- See [`SPANNER_INTEGRATION.md`](SPANNER_INTEGRATION.md) for detailed integration guide.
842
-
843
- ## Design Decisions
844
-
845
- ### Client Wraps Async::HTTP::Client
846
-
847
- The client is a thin wrapper that:
848
- - Manages the HTTP/2 connection lifecycle
849
- - Handles request/response conversion using `protocol-grpc`
850
- - Provides RPC-style methods (unary, server_streaming, etc.)
851
- - Manages streaming with Async tasks
852
-
853
- Benefits:
854
- - Reuses `Async::HTTP::Client` connection pooling
855
- - Automatic HTTP/2 multiplexing
856
- - Async-friendly streaming
857
-
858
- ### Server: Just Use Existing Infrastructure
859
-
860
- **No custom server class needed!** The design is even simpler:
861
-
862
- 1. `Protocol::GRPC::Middleware` (in protocol-grpc)
863
- - Implements `call(request) → response`
864
- - Handles gRPC protocol details
865
- - Works with any HTTP/2 server
866
-
867
- 2. `Async::HTTP::Server` (already exists in async-http)
868
- - Handles endpoint binding
869
- - Manages connections
870
- - Runs request/response loop in async tasks
871
-
872
- Benefits:
873
- - **No code duplication** - reuse existing Async::HTTP::Server
874
- - **Standard middleware** - works with any Protocol::HTTP::Middleware
875
- - **Composable** - can mix gRPC with HTTP endpoints
876
- - **Simple** - just one middleware class to implement
877
-
878
- Compare to Protocol::HTTP ecosystem:
879
- - `Protocol::HTTP::Middleware` - base middleware class
880
- - `Async::HTTP::Server` - uses any middleware
881
- - No need for `Async::HTTP::SpecialServer` - same here!
882
-
883
- ### Service Handler Interface
884
-
885
- Handlers receive `(input, output, request)`:
886
- - `input` - stream for reading request messages
887
- - `output` - stream for writing response messages
888
- - `request` - original HTTP request for accessing metadata
889
-
890
- Benefits:
891
- - Uniform interface for all RPC types
892
- - Handlers control streaming explicitly
893
- - Access to metadata via request headers
894
-
895
- ### Streaming with Async Tasks
896
-
897
- Bidirectional streaming uses Async tasks:
898
- - Input and output can be processed concurrently
899
- - Natural async/await patterns
900
- - Proper cleanup on errors
901
-
902
- ## Google Cloud Integration Requirements
903
-
904
- To support Google Cloud libraries (like `google-cloud-spanner`), async-grpc must provide:
905
-
906
- ### 1. Channel Adapter Interface
907
-
908
- Implement `GRPC::Core::Channel` interface methods:
909
- - `request_response(path, request, marshal, unmarshal, deadline:, metadata:)` - Unary
910
- - `request_stream(path, request, marshal, unmarshal, deadline:, metadata:)` - Server streaming
911
- - `stream_request(path, marshal, unmarshal, deadline:, metadata:)` - Client streaming
912
- - `stream_stream(path, marshal, unmarshal, deadline:, metadata:)` - Bidirectional
913
-
914
- ### 2. Binary Message Support
915
-
916
- Support pre-marshaled protobuf data:
917
- - `Client#unary_binary(service, method, request_binary, ...)` → `response_binary`
918
- - `Body::Readable` with `message_class: nil` returns raw binary
919
- - `Body::Writable` accepts binary strings directly
920
-
921
- ### 3. Authentication Integration
922
-
923
- Support Google Cloud authentication patterns:
924
- - OAuth2 access tokens (via credentials object)
925
- - Per-call credential refresh (credentials have `updater_proc`)
926
- - Token metadata format: `{"authorization" => "Bearer ya29.a0..."}`
927
-
928
- Example credential integration:
929
- ```ruby
930
- # Google's credential format
931
- credentials = Google::Cloud::Spanner::Credentials.default
932
- updater_proc = credentials.client.updater_proc
933
-
934
- # For each RPC call:
935
- auth_metadata = updater_proc.call(method_path)
936
- # => {"authorization" => "Bearer ..."}
937
-
938
- # Add to request metadata
939
- client.unary(service, method, request, metadata: auth_metadata)
940
- ```
941
-
942
- ### 4. Error Mapping
943
-
944
- Map gRPC status codes to Google Cloud errors:
945
- - `Protocol::GRPC::Status::INVALID_ARGUMENT` → `Google::Cloud::InvalidArgumentError`
946
- - `Protocol::GRPC::Status::NOT_FOUND` → `Google::Cloud::NotFoundError`
947
- - `Protocol::GRPC::Status::UNAVAILABLE` → `Google::Cloud::UnavailableError`
948
- - Preserve error messages and metadata
949
-
950
- ### 5. Metadata Conventions
951
-
952
- Support Google Cloud metadata conventions:
953
- - `google-cloud-resource-prefix` - resource path prefix
954
- - `x-goog-spanner-route-to-leader` - leader-aware routing
955
- - `x-goog-request-params` - request routing params
956
- - Custom quota project ID
957
-
958
- ### 6. Retry Logic Compatibility
959
-
960
- Support retry policies from Google Cloud:
961
- - `Gapic::CallOptions` with retry_policy
962
- - Exponential backoff configuration
963
- - Per-method retry settings
964
- - Idempotency awareness
965
-
966
- ### Implementation Checklist
967
-
968
- - [ ] `Async::GRPC::ChannelAdapter` class
969
- - [ ] Binary message methods in `Client`
970
- - [ ] `GRPC::Core::Channel` interface compatibility
971
- - [ ] OAuth2 credential integration
972
- - [ ] Error mapping to Google Cloud errors
973
- - [ ] Metadata convention support
974
- - [ ] Retry policy support
975
- - [ ] Integration tests with actual Spanner SDK
976
-
977
- See [`SPANNER_INTEGRATION.md`](SPANNER_INTEGRATION.md) for detailed implementation guide.
978
-
979
- ### Key Interfaces for Google Cloud Compatibility
980
-
981
- **Channel Interface** (from `GRPC::Core::Channel`):
982
- ```ruby
983
- channel = ChannelAdapter.new(endpoint, channel_args, channel_creds)
984
- response = channel.request_response(path, request, marshal, unmarshal, deadline:, metadata:)
985
- ```
986
-
987
- **Binary Client Methods**:
988
- ```ruby
989
- client.unary_binary(service, method, binary_request) # => binary_response
990
- client.server_streaming_binary(service, method, binary_request){|binary| do_stuff}
991
- client.client_streaming_binary(service, method){|output| output.write(binary)}
992
- client.bidirectional_streaming_binary(service, method){|input, output| do_stuff}
993
- ```
994
-
995
- **Authentication Hook**:
996
- ```ruby
997
- # Google Cloud credentials provide updater_proc
998
- auth_metadata = credentials.client.updater_proc.call(method_path)
999
- # => {"authorization" => "Bearer ya29.a0..."}
1000
- ```
1001
-
1002
- This enables async-grpc to be used as a drop-in replacement for the standard `grpc` gem in Google Cloud libraries.
1003
-
1004
- ## Implementation Roadmap
1005
-
1006
- ### Phase 1: Core Client (✅ Designed)
1007
- - `Async::GRPC::Client` with all four RPC types
1008
- - `Async::GRPC::ServerCall` context object (enhances Protocol::GRPC::Call)
1009
- - Error handling with backtrace support via `RemoteError` and exception chaining
1010
- - Response body wrapping pattern
1011
- - **Server**: Just use `Protocol::GRPC::Middleware` with `Async::HTTP::Server` (no wrapper needed!)
1012
-
1013
- ### Phase 2: Google Cloud Integration (✅ Designed)
1014
- - `Async::GRPC::ChannelAdapter` for GRPC::Core::Channel compatibility
1015
- - Binary message methods (`unary_binary`, etc.)
1016
- - OAuth2 authentication integration
1017
- - Google Cloud metadata conventions
1018
- - Error mapping to Google Cloud errors
1019
-
1020
- ### Phase 3: Interceptors & Middleware (✅ Designed)
1021
- - `Async::GRPC::ClientInterceptor` base class
1022
- - `Async::GRPC::ServerInterceptor` base class
1023
- - Chain multiple interceptors
1024
- - Built-in interceptors (logging, metrics, auth)
1025
-
1026
- ### Phase 4: Advanced Features
1027
- - Retry policies with exponential backoff
1028
- - Flow control & backpressure (bounded queues)
1029
- - Compression negotiation (`grpc-encoding` headers)
1030
- - Health check service implementation
1031
- - Server reflection implementation
1032
- - Graceful shutdown
1033
-
1034
- ## Missing/Future Features
1035
-
1036
- ### Core Features (Phase 1-2)
1037
-
1038
- 1. **Cancellation & Deadlines** (Partially designed)
1039
- - Proper cancellation propagation through async tasks
1040
- - Timeout enforcement for streaming RPCs
1041
- - Cancel ongoing streams when deadline exceeded
1042
- - Context cancellation (similar to Go's context.Context)
1043
- - **Note**: `ServerCall` has deadline tracking, need to wire up cancellation
1044
-
1045
- 2. **Flow Control & Backpressure**
1046
- - Respect HTTP/2 flow control (handled by async-http)
1047
- - Backpressure for streaming (don't buffer unbounded)
1048
- - Use `Protocol::HTTP::Body::Writable` with bounded queue option
1049
-
1050
- ### Advanced Features (Later)
1051
-
1052
- 5. **Health Checking**
1053
- - Standard gRPC health check protocol
1054
- - `grpc.health.v1.Health` service
1055
- - Per-service health status
1056
-
1057
- 6. **Reflection API**
1058
- - Server reflection protocol (grpc.reflection.v1alpha.ServerReflection)
1059
- - Allows tools like `grpcurl` to discover services
1060
- - List services, describe methods, get proto definitions
1061
-
1062
- 7. **Authentication & Authorization**
1063
- - Channel credentials (TLS, custom auth)
1064
- - Per-call credentials (tokens, API keys)
1065
- - Integration with standard auth patterns
1066
-
1067
- 8. **Retry Policies**
1068
- - Automatic retries with exponential backoff
1069
- - Configurable retry conditions (status codes)
1070
- - Hedging (parallel requests)
1071
-
1072
- 9. **Load Balancing**
1073
- - Client-side load balancing
1074
- - Service config (retry policy, timeout, LB config)
1075
- - Integration with service discovery
1076
-
1077
- 10. **Compression Negotiation**
1078
- - `grpc-encoding` header support
1079
- - `grpc-accept-encoding` for advertising support
1080
- - Multiple compression algorithms (gzip, deflate, etc.)
1081
-
1082
- ## Open Questions
1083
-
1084
- 1. **Interceptor API**: What should the interface be?
1085
- ```ruby
1086
- class LoggingInterceptor
1087
- def call(request, call)
1088
- # Before request
1089
- result = yield
1090
- # After response
1091
- result
1092
- end
1093
- end
1094
- ```
1095
-
1096
- 2. **Context/Call Object**: Should we have a rich call context?
1097
- - Access metadata, peer info, deadline
1098
- - Set trailers, check cancellation
1099
- - Pass context through interceptors
1100
-
1101
- 3. **Connection Pooling**: Client-side or server-side?
1102
- - Current: Single Async::HTTP::Client
1103
- - Could pool multiple connections
1104
- - Or rely on HTTP/2 multiplexing?
1105
-
1106
- 4. **Graceful Shutdown**: How should server shutdown work?
1107
- - Stop accepting new calls
1108
- - Wait for in-flight calls to complete
1109
- - Force close after timeout
1110
-
1111
- 5. **Error Propagation**: How to handle partial failures in streaming?
1112
- - Close stream immediately on error?
1113
- - Send error in trailers?
1114
- - Allow partial success?
1115
-
1116
- 6. **Type Validation**: Validate message types at runtime?
1117
- - Check message class matches expected type
1118
- - Or trust duck typing?
1119
-
1120
- ## References
1121
-
1122
- - [Protocol::GRPC Design](../protocol-grpc/design.md)
1123
- - [Async::HTTP](https://github.com/socketry/async-http)
1124
- - [Protocol::HTTP::Middleware](https://github.com/socketry/protocol-http)
1125
- - [gRPC Core Concepts](https://grpc.io/docs/what-is-grpc/core-concepts/)
1126
-