Skip to content

Commit 152dd8f

Browse files
Better error handling.
1 parent d34d3e3 commit 152dd8f

File tree

9 files changed

+157
-7
lines changed

9 files changed

+157
-7
lines changed

async-grpc.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@ Gem::Specification.new do |spec|
2222
spec.required_ruby_version = ">= 3.2"
2323

2424
spec.add_dependency "async-http"
25-
spec.add_dependency "protocol-grpc", "~> 0.4"
25+
spec.add_dependency "protocol-grpc", "~> 0.5"
2626
end

context/getting-started.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,4 @@ Async do
169169
server.run
170170
end
171171
```
172+

gems.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@
3131
gem "bake-test"
3232
gem "bake-test-external"
3333
end
34+

lib/async/grpc/client.rb

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
require "protocol/grpc/metadata"
1717
require "protocol/grpc/error"
1818
require_relative "stub"
19+
require_relative "remote_error"
1920

2021
module Async
2122
module GRPC
@@ -316,16 +317,14 @@ def bidirectional_call(path, headers, request_class, response_class, encoding, &
316317
def check_status!(response)
317318
status = Protocol::GRPC::Metadata.extract_status(response.headers)
318319

319-
# If status is UNKNOWN (not found), default to OK:
320-
# This handles cases where trailers aren't available or status wasn't set
321-
status = Protocol::GRPC::Status::OK if status == Protocol::GRPC::Status::UNKNOWN
322-
323320
return if status == Protocol::GRPC::Status::OK
324321

325322
message = Protocol::GRPC::Metadata.extract_message(response.headers)
326323
metadata = Protocol::GRPC::Methods.extract_metadata(response.headers)
327324

328-
raise Protocol::GRPC::Error.for(status, message, metadata: metadata)
325+
remote_error = RemoteError.for(message, metadata)
326+
327+
raise Protocol::GRPC::Error.for(status, metadata: metadata), cause: remote_error
329328
end
330329
end
331330
end

lib/async/grpc/dispatcher_middleware.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def invoke_service(service, handler_method, input, output, call)
6161

6262
# Only add OK status if grpc-status hasn't been set by the handler:
6363
unless call.response.headers["grpc-status"]
64-
Protocol::GRPC::Metadata.add_status_trailer!(call.response.headers, status: Protocol::GRPC::Status::OK)
64+
Protocol::GRPC::Metadata.add_status!(call.response.headers, status: Protocol::GRPC::Status::OK)
6565
end
6666
end
6767
end

lib/async/grpc/remote_error.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
module Async
7+
module GRPC
8+
class RemoteError < StandardError
9+
def self.for(message, metadata)
10+
self.new(message).tap do |error|
11+
if backtrace = metadata.delete("backtrace")
12+
# Backtrace is always an array (Split header format):
13+
error.set_backtrace(backtrace)
14+
end
15+
end
16+
end
17+
end
18+
end
19+
end

lib/async/grpc/version.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ module GRPC
1010
VERSION = "0.1.0"
1111
end
1212
end
13+

releases.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Added `Async::GRPC::RemoteError` class to encapsulate remote error details including message and backtrace extracted from response headers.
6+
- Client-side error handling now extracts backtraces from response metadata and sets them on `RemoteError`, which is chained as the `cause` of `Protocol::GRPC::Error` for better debugging.
7+
- Updated to use `Protocol::GRPC::Metadata.add_status!` instead of deprecated `add_status_trailer!` method.
58
- Tidy up request and response body handling.
69

710
## v0.1.0

test/async/grpc/client.rb

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,132 @@
7878
stub.unknown_method(request)
7979
end.to raise_exception(NoMethodError)
8080
end
81+
82+
with "error handling" do
83+
let(:error_service_name) {"test.ErrorService"}
84+
let(:error_interface_class) do
85+
Class.new(Protocol::GRPC::Interface) do
86+
rpc :ReturnError, request_class: Protocol::GRPC::Fixtures::TestMessage,
87+
response_class: Protocol::GRPC::Fixtures::TestMessage, streaming: :unary
88+
end
89+
end
90+
let(:error_service) do
91+
Class.new(Async::GRPC::Service) do
92+
define_method(:return_error) do |input, output, call|
93+
request = input.read
94+
95+
# Set error status and message based on request value
96+
case request.value
97+
when "internal"
98+
status = Protocol::GRPC::Status::INTERNAL
99+
message = "Internal server error"
100+
when "not_found"
101+
status = Protocol::GRPC::Status::NOT_FOUND
102+
message = "Resource not found"
103+
when "with_backtrace"
104+
status = Protocol::GRPC::Status::INTERNAL
105+
message = "Error with backtrace"
106+
# Add backtrace to metadata (comma-separated string, Split header will parse into array)
107+
call.response.headers["backtrace"] = "/path/to/file.rb:10:in `method', /path/to/file.rb:5:in `block'"
108+
when "with_metadata"
109+
status = Protocol::GRPC::Status::INVALID_ARGUMENT
110+
message = "Invalid argument"
111+
# Add custom metadata
112+
call.response.headers["custom-key"] = "custom-value"
113+
else
114+
status = Protocol::GRPC::Status::UNKNOWN
115+
message = "Unknown error"
116+
end
117+
118+
Protocol::GRPC::Metadata.add_status!(call.response.headers, status: status, message: message)
119+
end
120+
end.new(error_interface_class, error_service_name)
121+
end
122+
let(:app) {Async::GRPC::DispatcherMiddleware.new(services: { error_service_name => error_service })}
123+
124+
it "raises Protocol::GRPC::Error with correct status code" do
125+
grpc_client = Async::GRPC::Client.new(client)
126+
stub = grpc_client.stub(error_interface_class, error_service_name)
127+
128+
request = Protocol::GRPC::Fixtures::TestMessage.new(value: "internal")
129+
130+
begin
131+
stub.return_error(request)
132+
expect(false).to be == true # Should not reach here
133+
rescue Protocol::GRPC::Internal => error
134+
expect(error.status_code).to be == Protocol::GRPC::Status::INTERNAL
135+
# Message comes from RemoteError (cause), not the Protocol::GRPC::Error itself
136+
expect(error.cause.message).to be == "Internal server error"
137+
end
138+
end
139+
140+
it "raises correct error class for status code" do
141+
grpc_client = Async::GRPC::Client.new(client)
142+
stub = grpc_client.stub(error_interface_class, error_service_name)
143+
144+
request = Protocol::GRPC::Fixtures::TestMessage.new(value: "not_found")
145+
146+
begin
147+
stub.return_error(request)
148+
expect(false).to be == true # Should not reach here
149+
rescue Protocol::GRPC::NotFound => error
150+
expect(error.status_code).to be == Protocol::GRPC::Status::NOT_FOUND
151+
expect(error.cause.message).to be == "Resource not found"
152+
end
153+
end
154+
155+
it "extracts and sets backtrace from metadata on RemoteError" do
156+
grpc_client = Async::GRPC::Client.new(client)
157+
stub = grpc_client.stub(error_interface_class, error_service_name)
158+
159+
request = Protocol::GRPC::Fixtures::TestMessage.new(value: "with_backtrace")
160+
161+
begin
162+
stub.return_error(request)
163+
expect(false).to be == true # Should not reach here
164+
rescue Protocol::GRPC::Internal => error
165+
expect(error.cause).to be_a(Async::GRPC::RemoteError)
166+
expect(error.cause.message).to be == "Error with backtrace"
167+
# Backtrace comes as array from Split header format
168+
backtrace = error.cause.backtrace
169+
expect(backtrace).to be_a(Array)
170+
# Split header splits comma-separated string into array
171+
expect(backtrace.length).to be >= 1
172+
expect(backtrace.any?{|line| line.include?("file.rb:10")}).to be == true
173+
expect(backtrace.any?{|line| line.include?("file.rb:5")}).to be == true
174+
end
175+
end
176+
177+
it "preserves metadata in error" do
178+
grpc_client = Async::GRPC::Client.new(client)
179+
stub = grpc_client.stub(error_interface_class, error_service_name)
180+
181+
request = Protocol::GRPC::Fixtures::TestMessage.new(value: "with_metadata")
182+
183+
begin
184+
stub.return_error(request)
185+
expect(false).to be == true # Should not reach here
186+
rescue Protocol::GRPC::InvalidArgument => error
187+
expect(error.metadata.key?("custom-key")).to be == true
188+
expect(error.metadata["custom-key"]).to be == "custom-value"
189+
end
190+
end
191+
192+
it "sets RemoteError as cause of Protocol::GRPC::Error" do
193+
grpc_client = Async::GRPC::Client.new(client)
194+
stub = grpc_client.stub(error_interface_class, error_service_name)
195+
196+
request = Protocol::GRPC::Fixtures::TestMessage.new(value: "internal")
197+
198+
begin
199+
stub.return_error(request)
200+
expect(false).to be == true # Should not reach here
201+
rescue Protocol::GRPC::Internal => error
202+
expect(error.cause).to be_a(Async::GRPC::RemoteError)
203+
expect(error.cause.message).to be == "Internal server error"
204+
end
205+
end
206+
end
81207
end
82208

83209
describe Async::GRPC::Client do

0 commit comments

Comments
 (0)