protocol-grpc 0.15.0 → 0.17.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: 2195318192e11fc3cafc54e82ccb65b4111e1e5ab9d1772ec8e4e002e6251136
4
- data.tar.gz: 9916efb3e3966c9242cbefca5534d233f82d079d363a4ecec86c4808dd707bcf
3
+ metadata.gz: bf0f2ec7ae263ceb0b3191c0f3da1b9a6222dd3e0b5e584cc2ec19fa7ebb8857
4
+ data.tar.gz: 303bf31a141edf34f610e1f2a4432571af30c15b65cfc784d1de00915cecb765
5
5
  SHA512:
6
- metadata.gz: 8236e53f25377fc556da30e47e36e8828a20fb7b2064d7a2b8046309f23ab26e98f3a8338842fb429f47fb510e58361ebcc1e06f24d097f4d37507159eb050b6
7
- data.tar.gz: 2941d6b8edb774e16c6530b20cc76d330fccc05021564a32bb5bf8b4e5448c4e664001d3a59bd71cf584d4c276688b84749c6096582ff2661be6e761ca729650
6
+ metadata.gz: e8d6063d80afcb48d81acc7ac8b3a568c73b8726bb5e369101a5aae49cd31ab66af5cb483304c578db95cf6d03757b8e92460f389a03216ab5870e2b332e519c
7
+ data.tar.gz: 4b7031d73d14092692c1fa05f0a6960446e50b37dd4088fa0132e89cf4be143f32c74f2ff1554ecf9248de67ed4688520bc98898566cb83dac7f9daef64c74d6
checksums.yaml.gz.sig CHANGED
Binary file
@@ -48,6 +48,7 @@ module Protocol
48
48
 
49
49
  # Read the next gRPC message.
50
50
  # Overrides Wrapper#read to transform raw HTTP body chunks into decoded gRPC messages.
51
+ # Errors raised by the underlying body propagate unchanged.
51
52
  # @returns [Object | String | Nil] Decoded message, raw binary, or `Nil` if stream ended
52
53
  def read
53
54
  # Read 5-byte prefix: 1 byte compression flag + 4 bytes length
@@ -85,14 +86,8 @@ module Protocol
85
86
  def read_exactly(n)
86
87
  # Fill buffer until we have enough data:
87
88
  while @buffer.bytesize < n
88
- if @body.nil? || @body.empty?
89
- return nil if @buffer.empty?
90
-
91
- raise Error.new(Status::INTERNAL, "Truncated gRPC frame: expected #{n} bytes, received #{@buffer.bytesize}")
92
- end
93
-
94
- # Read chunk from underlying body:
95
- chunk = @body.read
89
+ # An empty body can still have a pending error, so read to determine EOF:
90
+ chunk = @body&.read
96
91
 
97
92
  if chunk.nil?
98
93
  return nil if @buffer.empty?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "async/deadline"
7
7
  require_relative "metadata"
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025, by Samuel Williams.
5
+ # Copyright, 2026, by Alex Watt.
5
6
 
6
7
  module Protocol
7
8
  module GRPC
@@ -13,24 +13,29 @@ module Protocol
13
13
  # This header appears only in request headers, not in trailers.
14
14
  class Timeout < String
15
15
  # The wire format for a gRPC timeout value.
16
- FORMAT = /\A(?<amount>[1-9]\d{0,7})(?<unit>[HMSmun])\z/
16
+ FORMAT = /\A(?<amount>\d{1,8})(?<unit>[HMSmun])\z/
17
17
 
18
18
  # Format a timeout duration for the `grpc-timeout` header.
19
19
  # @parameter timeout [Numeric] The timeout duration in seconds.
20
20
  # @returns [String] The formatted timeout.
21
21
  def self.format(timeout)
22
- if timeout >= 3600
23
- "#{(timeout / 3600).to_i}H"
24
- elsif timeout >= 60
25
- "#{(timeout / 60).to_i}M"
26
- elsif timeout >= 1
27
- "#{timeout.to_i}S"
28
- elsif timeout >= 0.001
29
- "#{(timeout * 1000).to_i}m"
30
- elsif timeout >= 0.000001
31
- "#{(timeout * 1_000_000).to_i}u"
32
- else
33
- "#{(timeout * 1_000_000_000).to_i}n"
22
+ raise ArgumentError, "Timeout must be finite and non-negative!" unless timeout.finite? && timeout >= 0
23
+ raise RangeError, "Timeout exceeds the grpc-timeout wire limit!" if timeout > 99_999_999 * 3600
24
+ return "0n" if timeout.zero?
25
+
26
+ nanoseconds = (timeout * 1_000_000_000).ceil
27
+ units = {"H" => 3_600_000_000_000, "M" => 60_000_000_000, "S" => 1_000_000_000, "m" => 1_000_000, "u" => 1000, "n" => 1}
28
+
29
+ # Prefer an exact representation in the largest possible unit:
30
+ units.each do |unit, scale|
31
+ amount, remainder = nanoseconds.divmod(scale)
32
+ return "#{amount}#{unit}" if remainder.zero? && amount <= 99_999_999
33
+ end
34
+
35
+ # Otherwise round up in the finest unit that fits the wire limit:
36
+ units.reverse_each do |unit, scale|
37
+ amount = (nanoseconds + scale - 1).div(scale)
38
+ return "#{amount}#{unit}" if amount <= 99_999_999
34
39
  end
35
40
  end
36
41
 
@@ -69,7 +74,7 @@ module Protocol
69
74
  # @raises [ArgumentError] If the timeout value is invalid.
70
75
  def to_seconds
71
76
  unless match = FORMAT.match(self)
72
- raise ArgumentError, "Invalid grpc-timeout: #{self.inspect}"
77
+ raise ArgumentError, "Invalid grpc-timeout: #{self.inspect}!"
73
78
  end
74
79
 
75
80
  amount = match[:amount].to_i
@@ -17,7 +17,7 @@ module Protocol
17
17
  # @parameter timeout [Numeric | Nil] Optional timeout in seconds.
18
18
  # @parameter content_type [String] The request content type.
19
19
  # @returns [Protocol::HTTP::Headers] The constructed request headers.
20
- def self.build(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
20
+ def self.build(metadata: {}, timeout: nil, content_type: "application/grpc")
21
21
  headers = Protocol::HTTP::Headers.new(policy: Protocol::GRPC::HEADER_POLICY)
22
22
  headers["content-type"] = content_type
23
23
  headers["te"] = "trailers"
@@ -52,9 +52,9 @@ module Protocol
52
52
  # Decode binary headers:
53
53
  if key.end_with?("-bin")
54
54
  if value.is_a?(String)
55
- value = Base64.strict_decode64(value)
55
+ value = decode_binary(value)
56
56
  elsif value.is_a?(Array)
57
- value = value.map{|item| Base64.strict_decode64(item)}
57
+ value = value.map{|item| decode_binary(item)}
58
58
  end
59
59
  end
60
60
 
@@ -64,6 +64,24 @@ module Protocol
64
64
  metadata
65
65
  end
66
66
 
67
+ # Decode a padded or unpadded binary metadata value.
68
+ # @parameter value [String] The base64 encoded value.
69
+ # @returns [String] The decoded bytes.
70
+ # @raises [ArgumentError] If the value has invalid Base64 characters or padding.
71
+ def self.decode_binary(value)
72
+ # Only supply omitted padding; validate existing padding unchanged:
73
+ unless value.end_with?("=")
74
+ case value.bytesize % 4
75
+ when 2
76
+ value += "=="
77
+ when 3
78
+ value += "="
79
+ end
80
+ end
81
+
82
+ Base64.strict_decode64(value)
83
+ end
84
+
67
85
  # Extract gRPC status from headers.
68
86
  # Returns Status::UNKNOWN if status is not present.
69
87
  #
@@ -107,8 +125,9 @@ module Protocol
107
125
  # @parameter headers [Protocol::HTTP::Headers]
108
126
  # @parameter status [Integer] gRPC status code
109
127
  # @parameter message [String | Nil] Optional status message
110
- # @parameter error [Exception | Nil] Optional error object (used to extract backtrace)
111
- def self.assign_status!(headers, status: Status::OK, message: nil, error: nil)
128
+ # @parameter error [Exception | Nil] Optional error object used for the message.
129
+ # @parameter backtrace [Boolean] Whether to include the error backtrace for debugging.
130
+ def self.assign_status!(headers, status: Status::OK, message: nil, error: nil, backtrace: false)
112
131
  headers["grpc-status"] = status
113
132
 
114
133
  if error && message.nil?
@@ -121,7 +140,7 @@ module Protocol
121
140
  end
122
141
 
123
142
  # Add backtrace from error if available
124
- if error && error.backtrace && !error.backtrace.empty?
143
+ if backtrace && error && error.backtrace && !error.backtrace.empty?
125
144
  # Assign backtrace array directly - Split header will handle it
126
145
  headers["backtrace"] = error.backtrace
127
146
  end
@@ -1,12 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
+ # Copyright, 2026, by Alex Watt.
5
6
 
6
7
  module Protocol
7
8
  module GRPC
8
9
  # Provides gRPC status codes and their names.
9
10
  module Status
11
+ # Map an HTTP response status when the server did not provide grpc-status.
12
+ # @parameter status [Integer] The HTTP status code.
13
+ # @returns [Integer] The fallback gRPC status code.
14
+ def self.for_http_status(status)
15
+ case status
16
+ when 400 then INTERNAL
17
+ when 401 then UNAUTHENTICATED
18
+ when 403 then PERMISSION_DENIED
19
+ when 404 then UNIMPLEMENTED
20
+ when 429, 502, 503, 504 then UNAVAILABLE
21
+ else UNKNOWN
22
+ end
23
+ end
24
+
10
25
  OK = 0
11
26
  CANCELLED = 1
12
27
  UNKNOWN = 2
@@ -7,7 +7,7 @@
7
7
  module Protocol
8
8
  # @namespace
9
9
  module GRPC
10
- VERSION = "0.15.0"
10
+ VERSION = "0.17.0"
11
11
  end
12
12
  end
13
13
 
data/lib/protocol/grpc.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "grpc/version"
7
7
 
data/license.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # MIT License
2
2
 
3
3
  Copyright, 2025-2026, by Samuel Williams.
4
+ Copyright, 2026, by Alex Watt.
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
data/readme.md CHANGED
@@ -28,6 +28,17 @@ Please see the [project documentation](https://socketry.github.io/protocol-grpc/
28
28
 
29
29
  Please see the [project releases](https://socketry.github.io/protocol-grpc/releases/index) for all releases.
30
30
 
31
+ ### v0.17.0
32
+
33
+ - Preserve underlying body read errors, including failures before a message, during a partial frame, or while finishing the stream. These failures are no longer hidden as EOF or replaced with a truncated-frame error.
34
+
35
+ ### v0.16.0
36
+
37
+ - Preserve timeout precision within the eight-digit wire limit, rounding up when necessary.
38
+ - Accept padded and unpadded binary metadata.
39
+ - **Breaking**: Error backtraces are no longer sent to clients by default. Pass `backtrace: true` to `Metadata.assign_status!` to explicitly enable them for debugging.
40
+ - Default request metadata to `application/grpc` and provide `Status.for_http_status` for responses without `grpc-status`.
41
+
31
42
  ### v0.15.0
32
43
 
33
44
  - **Breaking**: Removed `Protocol::GRPC::Status::DESCRIPTIONS`. Use `Protocol::GRPC::Status::NAMES` for canonical gRPC status names.
@@ -65,16 +76,6 @@ Please see the [project releases](https://socketry.github.io/protocol-grpc/relea
65
76
 
66
77
  - Add `RPC#name`.
67
78
 
68
- ### v0.3.0
69
-
70
- - **Breaking**: `Protocol::GRPC::Call` now takes a `response` object parameter instead of separate `response_headers`.
71
- - **Breaking**: Removed `Call#response_headers` method. Use `call.response.headers` directly.
72
- - Added `RPC#streaming?` method to check if an RPC is streaming.
73
-
74
- ### v0.2.0
75
-
76
- - `RPC#method` is always defined (snake case).
77
-
78
79
  ## See Also
79
80
 
80
81
  - [async-grpc](https://github.com/socketry/async-grpc) — Asynchronous gRPC client and server implementation using this interface.
@@ -95,16 +96,16 @@ We welcome contributions to this project.
95
96
 
96
97
  To run the test suite:
97
98
 
98
- ``` shell
99
- bundle exec sus
99
+ ``` bash
100
+ $ bundle exec sus
100
101
  ```
101
102
 
102
103
  ### Making Releases
103
104
 
104
105
  To make a new release:
105
106
 
106
- ``` shell
107
- bundle exec bake gem:release:patch # or minor or major
107
+ ``` bash
108
+ $ bundle exec bake gem:release:patch # or minor or major
108
109
  ```
109
110
 
110
111
  ### Developer Certificate of Origin
data/releases.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Releases
2
2
 
3
+ ## v0.17.0
4
+
5
+ - Preserve underlying body read errors, including failures before a message, during a partial frame, or while finishing the stream. These failures are no longer hidden as EOF or replaced with a truncated-frame error.
6
+
7
+ ## v0.16.0
8
+
9
+ - Preserve timeout precision within the eight-digit wire limit, rounding up when necessary.
10
+ - Accept padded and unpadded binary metadata.
11
+ - **Breaking**: Error backtraces are no longer sent to clients by default. Pass `backtrace: true` to `Metadata.assign_status!` to explicitly enable them for debugging.
12
+ - Default request metadata to `application/grpc` and provide `Status.for_http_status` for responses without `grpc-status`.
13
+
3
14
  ## v0.15.0
4
15
 
5
16
  - **Breaking**: Removed `Protocol::GRPC::Status::DESCRIPTIONS`. Use `Protocol::GRPC::Status::NAMES` for canonical gRPC status names.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,10 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-grpc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
8
+ - Alex Watt
8
9
  bindir: bin
9
10
  cert_chain:
10
11
  - |
@@ -145,7 +146,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
145
146
  - !ruby/object:Gem::Version
146
147
  version: '0'
147
148
  requirements: []
148
- rubygems_version: 4.0.10
149
+ rubygems_version: 4.0.16
149
150
  specification_version: 4
150
151
  summary: Protocol abstractions for gRPC, built on top of protocol-http.
151
152
  test_files: []
metadata.gz.sig CHANGED
Binary file