Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions grpc_client/test/grpc/integration/server_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ defmodule GRPC.Integration.ServerTest do
end)
end)

assert logs =~ "Exception raised while handling /helloworld.Greeter/SayHello"
assert logs =~ "(GRPC.RPCError) Please authenticate"
end

test "return errors for unknown errors" do
Expand All @@ -284,7 +284,7 @@ defmodule GRPC.Integration.ServerTest do
end)
end)

assert logs =~ "Exception raised while handling /helloworld.Greeter/SayHello"
assert logs =~ "unknown error(This is a test, please ignore it)"
end

test "logs error if exception_log_filter returns true" do
Expand Down Expand Up @@ -329,7 +329,6 @@ defmodule GRPC.Integration.ServerTest do

{pid, ref} = :erlang.binary_to_term(data)
send(pid, {:exception_log_filter, ref, exception})

true
end
end
Expand Down Expand Up @@ -396,6 +395,7 @@ defmodule GRPC.Integration.ServerTest do
{:ok, channel} = GRPC.Stub.connect("localhost:#{port}")
rect = %Routeguide.Rectangle{}
error = %GRPC.RPCError{message: "Please authenticate", status: 16}

assert {:error, ^error} = channel |> Routeguide.RouteGuide.Stub.list_features(rect)
end)
end
Expand Down
5 changes: 3 additions & 2 deletions grpc_core/lib/grpc/rpc_error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ defmodule GRPC.RPCError do
See `GRPC.Status` for more details on possible statuses.
"""

defexception [:status, :message, :details]
@enforce_keys [:status]
defexception [:message, :details, status: :unknown]

defguard is_rpc_error(e, status) when is_struct(e, __MODULE__) and e.status == status

Expand All @@ -70,7 +71,7 @@ defmodule GRPC.RPCError do

@spec exception(args :: list()) :: t()
def exception(args) when is_list(args) do
error = parse_args(args, %__MODULE__{})
error = parse_args(args, %__MODULE__{status: :unknown})

%{
error
Expand Down
2 changes: 2 additions & 0 deletions grpc_server/lib/grpc/server/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ defmodule GRPC.Server.Adapter do
@callback send_reply(state, content :: binary(), opts :: keyword()) :: any()

@callback send_headers(state, headers :: map()) :: any()

@callback send_error(state, headers :: map()) :: any()
end
5 changes: 5 additions & 0 deletions grpc_server/lib/grpc/server/adapters/cowboy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ defmodule GRPC.Server.Adapters.Cowboy do
Handler.set_resp_trailers(pid, trailers)
end

@impl true
def send_error(%{pid: pid}, error) do
Handler.send_error(pid, error)
end

def send_trailers(%{pid: pid}, trailers) do
Handler.stream_trailers(pid, trailers)
end
Expand Down
24 changes: 21 additions & 3 deletions grpc_server/lib/grpc/server/adapters/cowboy/handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,13 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
sync_call(pid, :read_body)
end

@doc """
Asynchronously send an error to the client.
"""
def send_error(pid, error) do
send(pid, {:send_error, error})
end

@doc """
Asynchronously send back to client a chunk of `data`, when `http_transcode?` is true, the
data is sent back as it's, with no transformation of protobuf binaries to http2 data frames.
Expand Down Expand Up @@ -485,6 +492,16 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
{:ok, req, state}
end

def info({:send_error, error}, req, state) do
req = send_error(req, error, state, :rpc_error)

[req: req]
|> ReportException.new(error)
|> maybe_log_error(state.exception_log_filter)

{:stop, req, state}
end

def info({:handling_timeout, _}, req, state) do
error = %RPCError{status: GRPC.Status.deadline_exceeded(), message: "Deadline expired"}
req = send_error(req, error, state, :timeout)
Expand Down Expand Up @@ -525,13 +542,13 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do

# unknown error raised from rpc
def info({:EXIT, pid, {:handle_error, error}}, req, state = %{pid: pid}) do
%{kind: kind, reason: reason, stack: stack} = error
%{kind: kind, reason: reason, stack: stacktrace} = error
rpc_error = %RPCError{status: GRPC.Status.unknown(), message: "Internal Server Error"}
req = send_error(req, rpc_error, state, :error)

[req: req]
|> ReportException.new(reason, stack, kind)
|> maybe_log_error(state.exception_log_filter, stack)
|> ReportException.new(reason, stacktrace, kind)
|> maybe_log_error(state.exception_log_filter, stacktrace)

{:stop, req, state}
end
Expand Down Expand Up @@ -631,6 +648,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do

defp send_error_trailers(%{has_sent_resp: _} = req, _, trailers) do
:cowboy_req.stream_trailers(trailers, req)
req
end

defp send_error_trailers(req, status, trailers) do
Expand Down
5 changes: 5 additions & 0 deletions grpc_server/lib/grpc/server/stream.ex
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ defmodule GRPC.Server.Stream do
do_send_reply(stream, [], opts)
end

def send_reply(%{adapter: adapter} = stream, {:error, %GRPC.RPCError{} = error}, _opts) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing we need to check is if the only errors that can reach here are in the form of this struct, of if we need to add another clause

Copy link
Contributor Author

@aseigo aseigo Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a very good question.

I've oscillated between having one and not having one, as it would be "easy" enough to try and match on any error .. or even just errors with strings (an error message) and wrap those in an RPCError.

The flip side of that is should that occur, it is almost certainly a developer error. Either an RPCError , a thrown exception, or a protobuf-encodable struct should be the result. Returning Something Else(tm) is probably something like a raw Ecto query or whatever that has been returned, and simply passing that back silently to the client, even as an error, feels somewhere between dangerous and too much magic.

In those cases, I'd rather see a request crash on the server side that can be tracked and addressed.

adapter.send_error(stream.payload, error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need a new send_error callback? What does send_reply not have?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function already exists, it's just not accessible via the Adaptor behaviour.

The difference to send_reply is that the error path doesn't try to GRPC-encode the message, puts the error information into the trailers, and ends the connection immediately.

It would be possible to change the contract of Adapter.send_reply/3 so that implementations must pattern match on errors being passed in as the data, but that means overloading the purpose of send_reply: sometimes it sends a reply, sometimes it sends an error.

I felt it cleaner to keep these two paths clearly separated, and as the adapter will need to track its own errors anyways and have a way to deal with those, this is isn't actually introducing new complexity.

But if you'd prefer to overload send_reply and make it a requirement for adapters to handle RPCError structs there, that can also be done!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second paragraph sold me on the new callback!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second paragraph sold me on the new callback!

stream
end

def send_reply(
%{grpc_type: :server_stream, codec: codec, access_mode: :http_transcoding, rpc: rpc} =
stream,
Expand Down
4 changes: 2 additions & 2 deletions grpc_server/test/grpc/stream_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ defmodule GRPC.StreamTest do
__exception__: true,
details: nil,
message: ":mapped_error",
status: nil
status: :unknown
}}
]
end
Expand All @@ -287,7 +287,7 @@ defmodule GRPC.StreamTest do
1,
{:error,
%GRPC.RPCError{
status: nil,
status: :unknown,
message: "{:exception, %RuntimeError{message: \"boom\"}}",
details: nil
}}
Expand Down
4 changes: 4 additions & 0 deletions grpc_server/test/support/test_adapter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ defmodule GRPC.Test.ServerAdapter do
{stream, data}
end

def send_error(stream, _data) do
stream
end

def send_headers(stream, _headers) do
stream
end
Expand Down
Loading